feat(sdk,plugins): API publique d'accès fichiers/workspace + analyse structure (#124,#129)
This commit is contained in:
@ -397,6 +397,21 @@ pub fn run() {
|
|||||||
plugins::plugin_set_enabled,
|
plugins::plugin_set_enabled,
|
||||||
plugins::plugin_uninstall,
|
plugins::plugin_uninstall,
|
||||||
plugins::plugin_list_runtime_contributions,
|
plugins::plugin_list_runtime_contributions,
|
||||||
|
plugins::plugin_workspace_read_text,
|
||||||
|
plugins::plugin_workspace_read_binary,
|
||||||
|
plugins::plugin_workspace_write_text,
|
||||||
|
plugins::plugin_workspace_write_binary,
|
||||||
|
plugins::plugin_workspace_list_dir,
|
||||||
|
plugins::plugin_workspace_stat,
|
||||||
|
plugins::plugin_query_project_structure,
|
||||||
|
plugins::plugin_config_read_document,
|
||||||
|
plugins::plugin_config_update_document,
|
||||||
|
plugins::plugin_task_run_command,
|
||||||
|
plugins::plugin_task_get_status,
|
||||||
|
plugins::plugin_toolchain_diagnose,
|
||||||
|
plugins::plugin_events_subscribe,
|
||||||
|
plugins::plugin_events_poll,
|
||||||
|
plugins::plugin_events_unsubscribe,
|
||||||
plugins::plugin_open_plugins_folder,
|
plugins::plugin_open_plugins_folder,
|
||||||
commands::get_server_exposure_settings,
|
commands::get_server_exposure_settings,
|
||||||
commands::save_server_exposure_settings,
|
commands::save_server_exposure_settings,
|
||||||
@ -415,6 +430,28 @@ pub fn run() {
|
|||||||
.expect("error while running IdeA Tauri application");
|
.expect("error while running IdeA Tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
|
||||||
|
) -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static {
|
||||||
|
tauri::generate_handler![
|
||||||
|
plugins::plugin_workspace_read_text,
|
||||||
|
plugins::plugin_workspace_read_binary,
|
||||||
|
plugins::plugin_workspace_write_text,
|
||||||
|
plugins::plugin_workspace_write_binary,
|
||||||
|
plugins::plugin_workspace_list_dir,
|
||||||
|
plugins::plugin_workspace_stat,
|
||||||
|
plugins::plugin_query_project_structure,
|
||||||
|
plugins::plugin_config_read_document,
|
||||||
|
plugins::plugin_config_update_document,
|
||||||
|
plugins::plugin_task_run_command,
|
||||||
|
plugins::plugin_task_get_status,
|
||||||
|
plugins::plugin_toolchain_diagnose,
|
||||||
|
plugins::plugin_events_subscribe,
|
||||||
|
plugins::plugin_events_poll,
|
||||||
|
plugins::plugin_events_unsubscribe,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
async fn app_exit_work_guard_state(
|
async fn app_exit_work_guard_state(
|
||||||
handle: &tauri::AppHandle,
|
handle: &tauri::AppHandle,
|
||||||
) -> Result<application::AppExitWorkGuardState, AppError> {
|
) -> Result<application::AppExitWorkGuardState, AppError> {
|
||||||
@ -713,6 +750,7 @@ fn persisted_monitor_is_available(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::plugin_workspace_invoke_handler;
|
||||||
use super::{
|
use super::{
|
||||||
apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation,
|
apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation,
|
||||||
decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity,
|
decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity,
|
||||||
@ -720,7 +758,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use super::{should_close_with_main_window, PersistedWindowKind};
|
use super::{should_close_with_main_window, PersistedWindowKind};
|
||||||
use application::AppExitWorkGuardState;
|
use application::AppExitWorkGuardState;
|
||||||
|
use serde_json::json;
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn main_close_without_work_allows_shutdown_without_preventing_close() {
|
fn main_close_without_work_allows_shutdown_without_preventing_close() {
|
||||||
@ -880,4 +921,289 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
|
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_workspace_commands_are_registered_in_tauri_invoke_handler() {
|
||||||
|
let app_data = test_app_data_dir("plugin-workspace-commands");
|
||||||
|
let app = mock_builder()
|
||||||
|
.manage(crate::state::AppState::build(app_data.clone()))
|
||||||
|
.invoke_handler(plugin_workspace_invoke_handler())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app builds");
|
||||||
|
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.expect("mock webview builds");
|
||||||
|
let missing_project = uuid::Uuid::from_u128(124).to_string();
|
||||||
|
|
||||||
|
for command in [
|
||||||
|
"plugin_workspace_read_text",
|
||||||
|
"plugin_workspace_read_binary",
|
||||||
|
"plugin_workspace_list_dir",
|
||||||
|
"plugin_workspace_stat",
|
||||||
|
"plugin_query_project_structure",
|
||||||
|
] {
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
command,
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project.clone(),
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"maxDepth": 2,
|
||||||
|
"maxEntries": 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND", "{command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (command, input) in [
|
||||||
|
(
|
||||||
|
"plugin_workspace_write_text",
|
||||||
|
json!({
|
||||||
|
"projectId": missing_project.clone(),
|
||||||
|
"path": "generated.txt",
|
||||||
|
"content": "hello\n"
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plugin_workspace_write_binary",
|
||||||
|
json!({
|
||||||
|
"projectId": missing_project.clone(),
|
||||||
|
"path": "generated.bin",
|
||||||
|
"bytes": [1, 2, 3]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let err = invoke_plugin_command(&webview, command, json!({ "input": input }))
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND", "{command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_config_document_commands_are_registered_in_tauri_invoke_handler() {
|
||||||
|
let app_data = test_app_data_dir("plugin-config-document-commands");
|
||||||
|
let app = mock_builder()
|
||||||
|
.manage(crate::state::AppState::build(app_data.clone()))
|
||||||
|
.invoke_handler(plugin_workspace_invoke_handler())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app builds");
|
||||||
|
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.expect("mock webview builds");
|
||||||
|
let missing_project = uuid::Uuid::from_u128(130).to_string();
|
||||||
|
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_config_read_document",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project.clone(),
|
||||||
|
"path": "config/settings.json",
|
||||||
|
"format": "json"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_config_update_document",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project,
|
||||||
|
"path": "config/settings.json",
|
||||||
|
"format": "json",
|
||||||
|
"mode": "mergePatch",
|
||||||
|
"value": {"enabled": true}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
|
||||||
|
let app_data = test_app_data_dir("plugin-task-commands");
|
||||||
|
let app = mock_builder()
|
||||||
|
.manage(crate::state::AppState::build(app_data.clone()))
|
||||||
|
.invoke_handler(plugin_workspace_invoke_handler())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app builds");
|
||||||
|
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.expect("mock webview builds");
|
||||||
|
let missing_project = uuid::Uuid::from_u128(125).to_string();
|
||||||
|
let owner = uuid::Uuid::from_u128(126).to_string();
|
||||||
|
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_task_run_command",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project,
|
||||||
|
"ownerAgentId": owner,
|
||||||
|
"label": "cargo test",
|
||||||
|
"command": "cargo",
|
||||||
|
"args": ["test"],
|
||||||
|
"cwd": ".",
|
||||||
|
"env": [["RUST_LOG", "debug"]],
|
||||||
|
"recordOnly": true
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
let task_id = uuid::Uuid::from_u128(127).to_string();
|
||||||
|
let value = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_task_get_status",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"taskId": task_id
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("unknown task is a successful empty status");
|
||||||
|
assert_eq!(value, serde_json::Value::Null);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_toolchain_diagnostic_command_is_registered_in_tauri_invoke_handler() {
|
||||||
|
let app_data = test_app_data_dir("plugin-toolchain-diagnostic-command");
|
||||||
|
let app = mock_builder()
|
||||||
|
.manage(crate::state::AppState::build(app_data.clone()))
|
||||||
|
.invoke_handler(plugin_workspace_invoke_handler())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app builds");
|
||||||
|
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.expect("mock webview builds");
|
||||||
|
let missing_project = uuid::Uuid::from_u128(126).to_string();
|
||||||
|
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_toolchain_diagnose",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project,
|
||||||
|
"cwd": ".",
|
||||||
|
"tools": [{
|
||||||
|
"id": "rust",
|
||||||
|
"executable": "cargo",
|
||||||
|
"versionArgs": ["--version"],
|
||||||
|
"required": true
|
||||||
|
}],
|
||||||
|
"env": [{
|
||||||
|
"name": "RUSTUP_HOME",
|
||||||
|
"required": false
|
||||||
|
}],
|
||||||
|
"files": [{
|
||||||
|
"path": "Cargo.toml",
|
||||||
|
"required": true,
|
||||||
|
"kind": "file"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_event_commands_are_registered_in_tauri_invoke_handler() {
|
||||||
|
let app_data = test_app_data_dir("plugin-event-commands");
|
||||||
|
let app = mock_builder()
|
||||||
|
.manage(crate::state::AppState::build(app_data.clone()))
|
||||||
|
.invoke_handler(plugin_workspace_invoke_handler())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("mock app builds");
|
||||||
|
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||||
|
.build()
|
||||||
|
.expect("mock webview builds");
|
||||||
|
let missing_project = uuid::Uuid::from_u128(127).to_string();
|
||||||
|
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_events_subscribe",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": missing_project,
|
||||||
|
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
|
||||||
|
"capacity": 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("missing project must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
let subscription_id = uuid::Uuid::from_u128(128).to_string();
|
||||||
|
let err = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_events_poll",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"subscriptionId": subscription_id.clone(),
|
||||||
|
"maxEvents": 10
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("unknown subscription must surface through the registered command");
|
||||||
|
assert_eq!(err["code"], "NOT_FOUND");
|
||||||
|
|
||||||
|
let disposed = invoke_plugin_command(
|
||||||
|
&webview,
|
||||||
|
"plugin_events_unsubscribe",
|
||||||
|
json!({
|
||||||
|
"input": {
|
||||||
|
"subscriptionId": subscription_id
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("unsubscribe is idempotent");
|
||||||
|
assert_eq!(disposed["retention"], "disposed");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke_plugin_command<W: AsRef<tauri::Webview<tauri::test::MockRuntime>>>(
|
||||||
|
webview: &W,
|
||||||
|
command: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, serde_json::Value> {
|
||||||
|
get_ipc_response(
|
||||||
|
webview,
|
||||||
|
tauri::webview::InvokeRequest {
|
||||||
|
cmd: command.to_owned(),
|
||||||
|
callback: tauri::ipc::CallbackFn(0),
|
||||||
|
error: tauri::ipc::CallbackFn(1),
|
||||||
|
url: "tauri://localhost".parse().unwrap(),
|
||||||
|
body: tauri::ipc::InvokeBody::Json(body),
|
||||||
|
headers: Default::default(),
|
||||||
|
invoke_key: INVOKE_KEY.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|body| body.deserialize::<serde_json::Value>().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,16 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
|
||||||
use backend::dto::{
|
use backend::dto::{
|
||||||
ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto,
|
ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto,
|
||||||
PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto,
|
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
|
||||||
|
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||||
|
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||||
|
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
||||||
|
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
|
||||||
|
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
|
||||||
|
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
|
||||||
|
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
|
||||||
|
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||||
};
|
};
|
||||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
use domain::{PluginId, RelativePath};
|
use domain::{PluginId, RelativePath};
|
||||||
@ -156,6 +164,198 @@ pub async fn plugin_list_runtime_contributions(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a UTF-8 workspace file for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_read_text(
|
||||||
|
input: PluginWorkspacePathDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginWorkspaceTextFileDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.read_text(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a binary workspace file for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_read_binary(
|
||||||
|
input: PluginWorkspacePathDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginWorkspaceBinaryFileDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.read_binary(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a UTF-8 workspace file for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_write_text(
|
||||||
|
input: PluginWorkspaceWriteTextDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.write_text(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a binary workspace file for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_write_binary(
|
||||||
|
input: PluginWorkspaceWriteBinaryDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.write_binary(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists a workspace directory for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_list_dir(
|
||||||
|
input: PluginWorkspacePathDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginWorkspaceDirectoryListingDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.list_dir(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stats a workspace path for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_workspace_stat(
|
||||||
|
input: PluginWorkspacePathDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginWorkspaceStatDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_access
|
||||||
|
.stat(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries a bounded generic project structure for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_query_project_structure(
|
||||||
|
input: PluginProjectStructureQueryDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginProjectStructureDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.query_project_structure
|
||||||
|
.execute(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a structured configuration document for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_config_read_document(
|
||||||
|
input: PluginConfigDocumentReadDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginConfigDocumentDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_config_documents
|
||||||
|
.read(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a structured configuration document for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_config_update_document(
|
||||||
|
input: PluginConfigDocumentUpdateDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginConfigDocumentWriteResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_config_documents
|
||||||
|
.update(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launches a command-backed background task for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_task_run_command(
|
||||||
|
input: PluginRunCommandDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginTaskDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_command_tasks
|
||||||
|
.run_command(input.into())
|
||||||
|
.await
|
||||||
|
.map(PluginTaskDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one command task status for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_task_get_status(
|
||||||
|
input: PluginTaskStatusDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<PluginTaskDto>, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_command_tasks
|
||||||
|
.get_status(input.into())
|
||||||
|
.await
|
||||||
|
.map(|task| task.map(PluginTaskDto::from))
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnoses generic external toolchain prerequisites for the public plugin API.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_toolchain_diagnose(
|
||||||
|
input: PluginToolchainDiagnosticRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginToolchainDiagnosticDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_toolchain_diagnostics
|
||||||
|
.diagnose(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to stable public plugin events.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn plugin_events_subscribe(
|
||||||
|
input: PluginEventSubscribeDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginEventSubscriptionDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_event_subscriptions
|
||||||
|
.subscribe(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains retained public plugin events for one subscription.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugin_events_poll(
|
||||||
|
input: PluginEventPollDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<PluginEventBatchDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_event_subscriptions
|
||||||
|
.poll(input.into())
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes a public plugin event subscription.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugin_events_unsubscribe(
|
||||||
|
input: PluginEventUnsubscribeDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> PluginEventSubscriptionDto {
|
||||||
|
state.plugin_event_subscriptions.unsubscribe(input.into())
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
/// Opens the plugin store folder, or one plugin folder when an id is provided.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn plugin_open_plugins_folder(
|
pub fn plugin_open_plugins_folder(
|
||||||
|
|||||||
@ -17,11 +17,12 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock,
|
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock,
|
||||||
IdGenerator, SpawnSpec,
|
EventBus, IdGenerator, SpawnSpec,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
|
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
|
||||||
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ProjectId, TaskId,
|
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, ProjectId,
|
||||||
|
TaskId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -71,6 +72,7 @@ pub struct SpawnBackgroundCommand {
|
|||||||
runner: Arc<dyn BackgroundTaskRunner>,
|
runner: Arc<dyn BackgroundTaskRunner>,
|
||||||
clock: Arc<dyn Clock>,
|
clock: Arc<dyn Clock>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
events: Option<Arc<dyn EventBus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnBackgroundCommand {
|
impl SpawnBackgroundCommand {
|
||||||
@ -87,9 +89,17 @@ impl SpawnBackgroundCommand {
|
|||||||
runner,
|
runner,
|
||||||
clock,
|
clock,
|
||||||
ids,
|
ids,
|
||||||
|
events: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches the public event stream publisher.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
self.events = Some(events);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Allocates a task id, persists it (`Queued`→`Running`) and spawns it.
|
/// Allocates a task id, persists it (`Queued`→`Running`) and spawns it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -145,6 +155,19 @@ impl SpawnBackgroundCommand {
|
|||||||
.transition(BackgroundTaskState::Running, now)
|
.transition(BackgroundTaskState::Running, now)
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
self.store.save(&running).await.map_err(map_port_err)?;
|
self.store.save(&running).await.map_err(map_port_err)?;
|
||||||
|
if let Some(events) = &self.events {
|
||||||
|
events.publish(DomainEvent::BackgroundTaskStarted {
|
||||||
|
project_id,
|
||||||
|
task_id,
|
||||||
|
owner_agent_id,
|
||||||
|
});
|
||||||
|
events.publish(DomainEvent::BackgroundTaskStateChanged {
|
||||||
|
project_id,
|
||||||
|
task_id,
|
||||||
|
owner_agent_id,
|
||||||
|
state: BackgroundTaskState::Running,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let spec = BackgroundTaskSpec {
|
let spec = BackgroundTaskSpec {
|
||||||
task_id,
|
task_id,
|
||||||
@ -168,6 +191,14 @@ impl SpawnBackgroundCommand {
|
|||||||
}) {
|
}) {
|
||||||
let _ = self.store.save(&failed).await;
|
let _ = self.store.save(&failed).await;
|
||||||
}
|
}
|
||||||
|
if let Some(events) = &self.events {
|
||||||
|
events.publish(DomainEvent::BackgroundTaskFailed {
|
||||||
|
project_id,
|
||||||
|
task_id,
|
||||||
|
owner_agent_id,
|
||||||
|
rendezvous: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
return Err(map_port_err(err));
|
return Err(map_port_err(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,10 +155,23 @@ pub use permission::{
|
|||||||
};
|
};
|
||||||
pub use plugin::{
|
pub use plugin::{
|
||||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator,
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator,
|
||||||
ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary,
|
ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginCommandTasks,
|
||||||
PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin,
|
PluginConfigDocument, PluginConfigDocumentReadInput, PluginConfigDocumentUpdateInput,
|
||||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
PluginConfigDocumentWriteResult, PluginConfigDocuments, PluginContributionSummary,
|
||||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
PluginDiagnosticMessage, PluginEnvDiagnostic, PluginEnvRequirement, PluginEventBatch,
|
||||||
|
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||||
|
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||||
|
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||||
|
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
|
||||||
|
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||||
|
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||||
|
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||||
|
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||||
|
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||||
|
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||||
|
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
|
||||||
|
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
|
||||||
|
UninstallPluginInput, UninstallPluginResult,
|
||||||
};
|
};
|
||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@
|
|||||||
//! JSON convention already used in the domain (`agents.json` etc.).
|
//! JSON convention already used in the domain (`agents.json` etc.).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
||||||
@ -273,6 +274,408 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plugin workspace path request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginWorkspacePathDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative path under the project root.
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginWorkspacePathDto> for application::PluginWorkspacePathInput {
|
||||||
|
fn from(value: PluginWorkspacePathDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin workspace text write request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginWorkspaceWriteTextDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative path under the project root.
|
||||||
|
pub path: String,
|
||||||
|
/// UTF-8 content.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginWorkspaceWriteTextDto> for application::PluginWorkspaceWriteTextInput {
|
||||||
|
fn from(value: PluginWorkspaceWriteTextDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
content: value.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin workspace binary write request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginWorkspaceWriteBinaryDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative path under the project root.
|
||||||
|
pub path: String,
|
||||||
|
/// Raw bytes.
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBinaryInput {
|
||||||
|
fn from(value: PluginWorkspaceWriteBinaryDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
bytes: value.bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin structured config document read request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginConfigDocumentReadDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative path under the project root.
|
||||||
|
pub path: String,
|
||||||
|
/// Optional explicit format. Omitted means inferred from extension.
|
||||||
|
#[serde(default)]
|
||||||
|
pub format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginConfigDocumentReadDto> for application::PluginConfigDocumentReadInput {
|
||||||
|
fn from(value: PluginConfigDocumentReadDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
format: value.format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin structured config document update request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginConfigDocumentUpdateDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative path under the project root.
|
||||||
|
pub path: String,
|
||||||
|
/// Optional explicit format. Omitted means inferred from extension.
|
||||||
|
#[serde(default)]
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// Update mode: `mergePatch` (default) or `replace`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
/// JSON replacement or merge patch.
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginConfigDocumentUpdateDto> for application::PluginConfigDocumentUpdateInput {
|
||||||
|
fn from(value: PluginConfigDocumentUpdateDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
format: value.format,
|
||||||
|
mode: value.mode,
|
||||||
|
value: value.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin project structure query request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginProjectStructureQueryDto {
|
||||||
|
/// Project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Optional relative root path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Optional traversal depth.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_depth: Option<u8>,
|
||||||
|
/// Optional entry cap.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_entries: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginProjectStructureQueryDto> for application::QueryProjectStructureInput {
|
||||||
|
fn from(value: PluginProjectStructureQueryDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
path: value.path,
|
||||||
|
max_depth: value.max_depth,
|
||||||
|
max_entries: value.max_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin command-task launch request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginRunCommandDto {
|
||||||
|
/// Owning project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Agent id used for Work correlation and completion wake delivery.
|
||||||
|
pub owner_agent_id: String,
|
||||||
|
/// Human-facing task label.
|
||||||
|
pub label: String,
|
||||||
|
/// Executable to run.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments passed without shell parsing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Relative working directory under project root. Empty/omitted means root.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Extra environment variables.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// When true, completion is only recorded; otherwise the owner is woken.
|
||||||
|
#[serde(default)]
|
||||||
|
pub record_only: bool,
|
||||||
|
/// Optional absolute deadline, epoch milliseconds.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deadline_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginRunCommandDto> for application::PluginRunCommandInput {
|
||||||
|
fn from(value: PluginRunCommandDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
owner_agent_id: value.owner_agent_id,
|
||||||
|
label: value.label,
|
||||||
|
command: value.command,
|
||||||
|
args: value.args,
|
||||||
|
cwd: value.cwd,
|
||||||
|
env: value.env,
|
||||||
|
record_only: value.record_only,
|
||||||
|
deadline_ms: value.deadline_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin task status request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginTaskStatusDto {
|
||||||
|
/// Task id to read.
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginTaskStatusDto> for application::PluginTaskStatusInput {
|
||||||
|
fn from(value: PluginTaskStatusDto) -> Self {
|
||||||
|
Self {
|
||||||
|
task_id: value.task_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin command-task status/output DTO.
|
||||||
|
pub type PluginTaskDto = BackgroundTaskDto;
|
||||||
|
|
||||||
|
/// Public plugin external-toolchain diagnostic request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginToolchainDiagnosticRequestDto {
|
||||||
|
/// Owning project id.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Relative working directory under project root.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Executable probes to run.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tools: Vec<PluginToolRequirementDto>,
|
||||||
|
/// Environment variable prerequisites.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<PluginEnvRequirementDto>,
|
||||||
|
/// Workspace file prerequisites.
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Vec<PluginFileRequirementDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginToolchainDiagnosticRequestDto> for application::PluginToolchainDiagnosticInput {
|
||||||
|
fn from(value: PluginToolchainDiagnosticRequestDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
cwd: value.cwd,
|
||||||
|
tools: value.tools.into_iter().map(Into::into).collect(),
|
||||||
|
env: value.env.into_iter().map(Into::into).collect(),
|
||||||
|
files: value.files.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin executable probe DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginToolRequirementDto {
|
||||||
|
/// Stable requirement id.
|
||||||
|
pub id: String,
|
||||||
|
/// Executable name or path.
|
||||||
|
pub executable: String,
|
||||||
|
/// Version/diagnostic arguments.
|
||||||
|
#[serde(default)]
|
||||||
|
pub version_args: Vec<String>,
|
||||||
|
/// Whether this probe is required.
|
||||||
|
#[serde(default)]
|
||||||
|
pub required: bool,
|
||||||
|
/// Extra environment variables for the probe.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginToolRequirementDto> for application::PluginToolRequirement {
|
||||||
|
fn from(value: PluginToolRequirementDto) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
executable: value.executable,
|
||||||
|
version_args: value.version_args,
|
||||||
|
required: value.required,
|
||||||
|
env: value.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin environment prerequisite DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginEnvRequirementDto {
|
||||||
|
/// Environment variable name.
|
||||||
|
pub name: String,
|
||||||
|
/// Whether this variable is required.
|
||||||
|
#[serde(default)]
|
||||||
|
pub required: bool,
|
||||||
|
/// Optional exact expected value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub equals: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginEnvRequirementDto> for application::PluginEnvRequirement {
|
||||||
|
fn from(value: PluginEnvRequirementDto) -> Self {
|
||||||
|
Self {
|
||||||
|
name: value.name,
|
||||||
|
required: value.required,
|
||||||
|
equals: value.equals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin workspace file prerequisite DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginFileRequirementDto {
|
||||||
|
/// Relative path under project root.
|
||||||
|
pub path: String,
|
||||||
|
/// Whether this path is required.
|
||||||
|
#[serde(default)]
|
||||||
|
pub required: bool,
|
||||||
|
/// Expected kind: `file`, `directory`, or `any`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginFileRequirementDto> for application::PluginFileRequirement {
|
||||||
|
fn from(value: PluginFileRequirementDto) -> Self {
|
||||||
|
Self {
|
||||||
|
path: value.path,
|
||||||
|
required: value.required,
|
||||||
|
kind: value.kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin external-toolchain diagnostic output DTO.
|
||||||
|
pub type PluginToolchainDiagnosticDto = application::PluginToolchainDiagnostic;
|
||||||
|
|
||||||
|
/// Public plugin event subscription request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginEventSubscribeDto {
|
||||||
|
/// Project id to observe.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Public event types to retain. Empty means all supported types.
|
||||||
|
#[serde(default)]
|
||||||
|
pub event_types: Vec<String>,
|
||||||
|
/// Per-subscription retained event capacity.
|
||||||
|
#[serde(default)]
|
||||||
|
pub capacity: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginEventSubscribeDto> for application::PluginEventSubscribeInput {
|
||||||
|
fn from(value: PluginEventSubscribeDto) -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: value.project_id,
|
||||||
|
event_types: value.event_types,
|
||||||
|
capacity: value.capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin event poll request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginEventPollDto {
|
||||||
|
/// Subscription id returned by subscribe.
|
||||||
|
pub subscription_id: String,
|
||||||
|
/// Maximum number of events to drain.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_events: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginEventPollDto> for application::PluginEventPollInput {
|
||||||
|
fn from(value: PluginEventPollDto) -> Self {
|
||||||
|
Self {
|
||||||
|
subscription_id: value.subscription_id,
|
||||||
|
max_events: value.max_events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin event unsubscribe request DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginEventUnsubscribeDto {
|
||||||
|
/// Subscription id returned by subscribe.
|
||||||
|
pub subscription_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginEventUnsubscribeDto> for application::PluginEventUnsubscribeInput {
|
||||||
|
fn from(value: PluginEventUnsubscribeDto) -> Self {
|
||||||
|
Self {
|
||||||
|
subscription_id: value.subscription_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public plugin event subscription output DTO.
|
||||||
|
pub type PluginEventSubscriptionDto = application::PluginEventSubscription;
|
||||||
|
/// Public plugin event poll output DTO.
|
||||||
|
pub type PluginEventBatchDto = application::PluginEventBatch;
|
||||||
|
|
||||||
|
/// Plugin workspace text file DTO.
|
||||||
|
pub type PluginWorkspaceTextFileDto = application::PluginWorkspaceTextFile;
|
||||||
|
/// Plugin workspace binary file DTO.
|
||||||
|
pub type PluginWorkspaceBinaryFileDto = application::PluginWorkspaceBinaryFile;
|
||||||
|
/// Plugin workspace directory listing DTO.
|
||||||
|
pub type PluginWorkspaceDirectoryListingDto = application::PluginWorkspaceDirectoryListing;
|
||||||
|
/// Plugin workspace stat DTO.
|
||||||
|
pub type PluginWorkspaceStatDto = application::PluginWorkspaceStat;
|
||||||
|
/// Plugin structured config document DTO.
|
||||||
|
pub type PluginConfigDocumentDto = application::PluginConfigDocument;
|
||||||
|
/// Plugin structured config document write result DTO.
|
||||||
|
pub type PluginConfigDocumentWriteResultDto = application::PluginConfigDocumentWriteResult;
|
||||||
|
/// Plugin project structure result DTO.
|
||||||
|
pub type PluginProjectStructureDto = application::ProjectStructureQuery;
|
||||||
|
|
||||||
/// Request DTO for the `health` command.
|
/// Request DTO for the `health` command.
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -4352,6 +4755,356 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
||||||
|
let path = PluginWorkspacePathDto {
|
||||||
|
project_id: Uuid::from_u128(124).to_string(),
|
||||||
|
path: "src/main.rs".to_owned(),
|
||||||
|
};
|
||||||
|
let text = PluginWorkspaceWriteTextDto {
|
||||||
|
project_id: path.project_id.clone(),
|
||||||
|
path: path.path.clone(),
|
||||||
|
content: "fn main() {}\n".to_owned(),
|
||||||
|
};
|
||||||
|
let binary = PluginWorkspaceWriteBinaryDto {
|
||||||
|
project_id: path.project_id.clone(),
|
||||||
|
path: "assets/icon.bin".to_owned(),
|
||||||
|
bytes: vec![1, 2, 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&path).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": path.project_id,
|
||||||
|
"path": "src/main.rs"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&text).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": text.project_id,
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"content": "fn main() {}\n"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&binary).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": binary.project_id,
|
||||||
|
"path": "assets/icon.bin",
|
||||||
|
"bytes": [1, 2, 3]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_project_structure_query_maps_to_application_input() {
|
||||||
|
let dto = PluginProjectStructureQueryDto {
|
||||||
|
project_id: Uuid::from_u128(129).to_string(),
|
||||||
|
path: Some("crates".to_owned()),
|
||||||
|
max_depth: Some(4),
|
||||||
|
max_entries: Some(250),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&dto).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
json!({
|
||||||
|
"projectId": dto.project_id,
|
||||||
|
"path": "crates",
|
||||||
|
"maxDepth": 4,
|
||||||
|
"maxEntries": 250
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let input: application::QueryProjectStructureInput = dto.into();
|
||||||
|
assert_eq!(input.path.as_deref(), Some("crates"));
|
||||||
|
assert_eq!(input.max_depth, Some(4));
|
||||||
|
assert_eq!(input.max_entries, Some(250));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_config_document_requests_use_stable_camel_case_contract() {
|
||||||
|
let project_id = Uuid::from_u128(130).to_string();
|
||||||
|
let read = PluginConfigDocumentReadDto {
|
||||||
|
project_id: project_id.clone(),
|
||||||
|
path: "config/settings.json".to_owned(),
|
||||||
|
format: Some("json".to_owned()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&read).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"path": "config/settings.json",
|
||||||
|
"format": "json"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let input: application::PluginConfigDocumentReadInput = read.into();
|
||||||
|
assert_eq!(input.path, "config/settings.json");
|
||||||
|
assert_eq!(input.format.as_deref(), Some("json"));
|
||||||
|
|
||||||
|
let update = PluginConfigDocumentUpdateDto {
|
||||||
|
project_id: Uuid::from_u128(130).to_string(),
|
||||||
|
path: "config/settings.json".to_owned(),
|
||||||
|
format: Some("json".to_owned()),
|
||||||
|
mode: Some("mergePatch".to_owned()),
|
||||||
|
value: json!({"enabled": true, "removeMe": null}),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&update).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": Uuid::from_u128(130).to_string(),
|
||||||
|
"path": "config/settings.json",
|
||||||
|
"format": "json",
|
||||||
|
"mode": "mergePatch",
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"removeMe": null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let input: application::PluginConfigDocumentUpdateInput = update.into();
|
||||||
|
assert_eq!(input.mode.as_deref(), Some("mergePatch"));
|
||||||
|
assert_eq!(input.value["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
||||||
|
let listing = PluginWorkspaceDirectoryListingDto {
|
||||||
|
path: "src".to_owned(),
|
||||||
|
entries: vec![application::PluginWorkspaceDirEntry {
|
||||||
|
name: "main.rs".to_owned(),
|
||||||
|
path: "src/main.rs".to_owned(),
|
||||||
|
is_dir: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let stat = PluginWorkspaceStatDto {
|
||||||
|
path: "src/main.rs".to_owned(),
|
||||||
|
exists: true,
|
||||||
|
is_file: true,
|
||||||
|
is_dir: false,
|
||||||
|
len: Some(13),
|
||||||
|
};
|
||||||
|
let structure = PluginProjectStructureDto {
|
||||||
|
project_id: Uuid::from_u128(129).to_string(),
|
||||||
|
root_path: String::new(),
|
||||||
|
entries: vec![application::ProjectStructureEntry {
|
||||||
|
path: "Cargo.toml".to_owned(),
|
||||||
|
name: "Cargo.toml".to_owned(),
|
||||||
|
kind: "file".to_owned(),
|
||||||
|
}],
|
||||||
|
conventions: vec![application::ProjectConvention {
|
||||||
|
id: "rust-cargo".to_owned(),
|
||||||
|
marker_path: "Cargo.toml".to_owned(),
|
||||||
|
}],
|
||||||
|
modules: vec![application::ProjectModule {
|
||||||
|
path: String::new(),
|
||||||
|
marker_path: "Cargo.toml".to_owned(),
|
||||||
|
convention_id: "rust-cargo".to_owned(),
|
||||||
|
}],
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&listing).unwrap(),
|
||||||
|
json!({
|
||||||
|
"path": "src",
|
||||||
|
"entries": [{
|
||||||
|
"name": "main.rs",
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"isDir": false
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&stat).unwrap(),
|
||||||
|
json!({
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"exists": true,
|
||||||
|
"isFile": true,
|
||||||
|
"isDir": false,
|
||||||
|
"len": 13
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&structure).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": structure.project_id,
|
||||||
|
"rootPath": "",
|
||||||
|
"entries": [{
|
||||||
|
"path": "Cargo.toml",
|
||||||
|
"name": "Cargo.toml",
|
||||||
|
"kind": "file"
|
||||||
|
}],
|
||||||
|
"conventions": [{
|
||||||
|
"id": "rust-cargo",
|
||||||
|
"markerPath": "Cargo.toml"
|
||||||
|
}],
|
||||||
|
"modules": [{
|
||||||
|
"path": "",
|
||||||
|
"markerPath": "Cargo.toml",
|
||||||
|
"conventionId": "rust-cargo"
|
||||||
|
}],
|
||||||
|
"truncated": false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_command_task_requests_use_stable_camel_case_contract() {
|
||||||
|
let project_id = Uuid::from_u128(125).to_string();
|
||||||
|
let owner_agent_id = Uuid::from_u128(126).to_string();
|
||||||
|
let run = PluginRunCommandDto {
|
||||||
|
project_id: project_id.clone(),
|
||||||
|
owner_agent_id: owner_agent_id.clone(),
|
||||||
|
label: "cargo test".to_owned(),
|
||||||
|
command: "cargo".to_owned(),
|
||||||
|
args: vec!["test".to_owned(), "-p".to_owned(), "application".to_owned()],
|
||||||
|
cwd: Some("crates/application".to_owned()),
|
||||||
|
env: vec![("RUST_LOG".to_owned(), "debug".to_owned())],
|
||||||
|
record_only: true,
|
||||||
|
deadline_ms: Some(1_800_000_000_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&run).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"ownerAgentId": owner_agent_id,
|
||||||
|
"label": "cargo test",
|
||||||
|
"command": "cargo",
|
||||||
|
"args": ["test", "-p", "application"],
|
||||||
|
"cwd": "crates/application",
|
||||||
|
"env": [["RUST_LOG", "debug"]],
|
||||||
|
"recordOnly": true,
|
||||||
|
"deadlineMs": 1_800_000_000_000u64
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let input: application::PluginRunCommandInput = run.into();
|
||||||
|
assert_eq!(input.cwd.as_deref(), Some("crates/application"));
|
||||||
|
assert_eq!(input.env, vec![("RUST_LOG".to_owned(), "debug".to_owned())]);
|
||||||
|
assert!(input.record_only);
|
||||||
|
|
||||||
|
let status = PluginTaskStatusDto {
|
||||||
|
task_id: Uuid::from_u128(127).to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&status).unwrap(),
|
||||||
|
json!({ "taskId": status.task_id })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_toolchain_diagnostic_request_maps_to_application_input() {
|
||||||
|
let project_id = Uuid::from_u128(126).to_string();
|
||||||
|
let request = PluginToolchainDiagnosticRequestDto {
|
||||||
|
project_id: project_id.clone(),
|
||||||
|
cwd: Some("crates/backend".to_owned()),
|
||||||
|
tools: vec![PluginToolRequirementDto {
|
||||||
|
id: "rust".to_owned(),
|
||||||
|
executable: "cargo".to_owned(),
|
||||||
|
version_args: vec!["--version".to_owned()],
|
||||||
|
required: true,
|
||||||
|
env: vec![("CARGO_TERM_COLOR".to_owned(), "never".to_owned())],
|
||||||
|
}],
|
||||||
|
env: vec![PluginEnvRequirementDto {
|
||||||
|
name: "RUSTUP_HOME".to_owned(),
|
||||||
|
required: false,
|
||||||
|
equals: None,
|
||||||
|
}],
|
||||||
|
files: vec![PluginFileRequirementDto {
|
||||||
|
path: "Cargo.toml".to_owned(),
|
||||||
|
required: true,
|
||||||
|
kind: Some("file".to_owned()),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&request).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"cwd": "crates/backend",
|
||||||
|
"tools": [{
|
||||||
|
"id": "rust",
|
||||||
|
"executable": "cargo",
|
||||||
|
"versionArgs": ["--version"],
|
||||||
|
"required": true,
|
||||||
|
"env": [["CARGO_TERM_COLOR", "never"]]
|
||||||
|
}],
|
||||||
|
"env": [{
|
||||||
|
"name": "RUSTUP_HOME",
|
||||||
|
"required": false,
|
||||||
|
"equals": null
|
||||||
|
}],
|
||||||
|
"files": [{
|
||||||
|
"path": "Cargo.toml",
|
||||||
|
"required": true,
|
||||||
|
"kind": "file"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let input: application::PluginToolchainDiagnosticInput = request.into();
|
||||||
|
assert_eq!(input.cwd.as_deref(), Some("crates/backend"));
|
||||||
|
assert_eq!(input.tools[0].id, "rust");
|
||||||
|
assert_eq!(input.tools[0].env[0].0, "CARGO_TERM_COLOR");
|
||||||
|
assert_eq!(input.env[0].name, "RUSTUP_HOME");
|
||||||
|
assert_eq!(input.files[0].kind.as_deref(), Some("file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dto_plugins_event_subscription_requests_use_stable_camel_case_contract() {
|
||||||
|
let project_id = Uuid::from_u128(127).to_string();
|
||||||
|
let subscribe = PluginEventSubscribeDto {
|
||||||
|
project_id: project_id.clone(),
|
||||||
|
event_types: vec![
|
||||||
|
"workspaceFileChanged".to_owned(),
|
||||||
|
"backgroundTaskChanged".to_owned(),
|
||||||
|
],
|
||||||
|
capacity: Some(250),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&subscribe).unwrap(),
|
||||||
|
json!({
|
||||||
|
"projectId": project_id,
|
||||||
|
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
|
||||||
|
"capacity": 250
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let input: application::PluginEventSubscribeInput = subscribe.into();
|
||||||
|
assert_eq!(
|
||||||
|
input.event_types,
|
||||||
|
vec![
|
||||||
|
"workspaceFileChanged".to_owned(),
|
||||||
|
"backgroundTaskChanged".to_owned()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(input.capacity, Some(250));
|
||||||
|
|
||||||
|
let poll = PluginEventPollDto {
|
||||||
|
subscription_id: Uuid::from_u128(128).to_string(),
|
||||||
|
max_events: Some(50),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&poll).unwrap(),
|
||||||
|
json!({
|
||||||
|
"subscriptionId": poll.subscription_id,
|
||||||
|
"maxEvents": 50
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let input: application::PluginEventPollInput = poll.into();
|
||||||
|
assert_eq!(input.max_events, Some(50));
|
||||||
|
|
||||||
|
let unsubscribe = PluginEventUnsubscribeDto {
|
||||||
|
subscription_id: Uuid::from_u128(129).to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&unsubscribe).unwrap(),
|
||||||
|
json!({ "subscriptionId": unsubscribe.subscription_id })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
|
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
|
||||||
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
|
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
|
||||||
|
|||||||
@ -401,6 +401,16 @@ pub enum DomainEventDto {
|
|||||||
/// Project id.
|
/// Project id.
|
||||||
project_id: String,
|
project_id: String,
|
||||||
},
|
},
|
||||||
|
/// A workspace file changed through the public plugin workspace API.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
PluginWorkspaceFileChanged {
|
||||||
|
/// Project id.
|
||||||
|
project_id: String,
|
||||||
|
/// Relative workspace path.
|
||||||
|
path: String,
|
||||||
|
/// Public operation label.
|
||||||
|
operation: String,
|
||||||
|
},
|
||||||
/// An issue-backed public ticket was created.
|
/// An issue-backed public ticket was created.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
IssueCreated {
|
IssueCreated {
|
||||||
@ -1058,6 +1068,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::PluginWorkspaceFileChanged {
|
||||||
|
project_id,
|
||||||
|
path,
|
||||||
|
operation,
|
||||||
|
} => Self::PluginWorkspaceFileChanged {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
path: path.clone(),
|
||||||
|
operation: operation.clone(),
|
||||||
|
},
|
||||||
DomainEvent::IssueCreated {
|
DomainEvent::IssueCreated {
|
||||||
issue_id,
|
issue_id,
|
||||||
issue_ref,
|
issue_ref,
|
||||||
|
|||||||
@ -35,37 +35,39 @@ use application::{
|
|||||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||||
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
|
||||||
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||||
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||||
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
|
||||||
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
|
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
|
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
|
||||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||||
|
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
||||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
||||||
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||||
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle,
|
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
|
||||||
PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
|
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||||
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
|
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
|
||||||
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -93,13 +95,14 @@ use infrastructure::{
|
|||||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||||
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
|
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@ -1135,6 +1138,18 @@ pub struct BackendCore {
|
|||||||
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
||||||
/// Reconcile external MCP plugin servers.
|
/// Reconcile external MCP plugin servers.
|
||||||
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
||||||
|
/// Public plugin workspace/file access facade.
|
||||||
|
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
||||||
|
/// Public plugin structured config document facade.
|
||||||
|
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
||||||
|
/// Public plugin project-structure query use case.
|
||||||
|
pub query_project_structure: Arc<QueryProjectStructure>,
|
||||||
|
/// Public plugin command/task facade.
|
||||||
|
pub plugin_command_tasks: Arc<PluginCommandTasks>,
|
||||||
|
/// Public plugin external-toolchain diagnostic facade.
|
||||||
|
pub plugin_toolchain_diagnostics: Arc<PluginToolchainDiagnostics>,
|
||||||
|
/// Public plugin event subscription facade.
|
||||||
|
pub plugin_event_subscriptions: Arc<PluginEventSubscriptions>,
|
||||||
/// Package store exposed for the Tauri asset protocol adapter.
|
/// Package store exposed for the Tauri asset protocol adapter.
|
||||||
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
||||||
/// Registry store exposed for the Tauri asset protocol adapter.
|
/// Registry store exposed for the Tauri asset protocol adapter.
|
||||||
@ -1553,6 +1568,8 @@ impl BackendCore {
|
|||||||
// registry.
|
// registry.
|
||||||
let spawner = Arc::new(LocalProcessSpawner::new());
|
let spawner = Arc::new(LocalProcessSpawner::new());
|
||||||
let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
|
let spawner_port = Arc::clone(&spawner) as Arc<dyn ProcessSpawner>;
|
||||||
|
let environment_reader = Arc::new(LocalEnvironmentReader::new());
|
||||||
|
let environment_reader_port = Arc::clone(&environment_reader) as Arc<dyn EnvironmentReader>;
|
||||||
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
|
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
|
||||||
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
|
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
|
||||||
|
|
||||||
@ -2358,12 +2375,15 @@ impl BackendCore {
|
|||||||
let _ = drain.await;
|
let _ = drain.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let spawn_background_command = Arc::new(SpawnBackgroundCommand::new(
|
let spawn_background_command = Arc::new(
|
||||||
|
SpawnBackgroundCommand::new(
|
||||||
Arc::clone(&background_tasks_port),
|
Arc::clone(&background_tasks_port),
|
||||||
Arc::clone(&background_runner_port),
|
Arc::clone(&background_runner_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
)
|
||||||
|
.with_events(Arc::clone(&events_port)),
|
||||||
|
);
|
||||||
let cancel_background_task = Arc::new(CancelBackgroundTask::new(
|
let cancel_background_task = Arc::new(CancelBackgroundTask::new(
|
||||||
Arc::clone(&background_tasks_port),
|
Arc::clone(&background_tasks_port),
|
||||||
Arc::clone(&background_runner_port),
|
Arc::clone(&background_runner_port),
|
||||||
@ -2420,6 +2440,47 @@ impl BackendCore {
|
|||||||
Arc::clone(&plugin_manifest_validator),
|
Arc::clone(&plugin_manifest_validator),
|
||||||
Arc::clone(&plugin_mcp_supervisor_port),
|
Arc::clone(&plugin_mcp_supervisor_port),
|
||||||
));
|
));
|
||||||
|
let plugin_workspace_access = Arc::new(
|
||||||
|
PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||||
|
.with_events(Arc::clone(&events_port)),
|
||||||
|
);
|
||||||
|
let plugin_config_documents = Arc::new(
|
||||||
|
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||||
|
.with_events(Arc::clone(&events_port)),
|
||||||
|
);
|
||||||
|
let query_project_structure = Arc::new(QueryProjectStructure::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
));
|
||||||
|
let plugin_command_tasks = Arc::new(PluginCommandTasks::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&background_tasks_port),
|
||||||
|
Arc::clone(&spawn_background_command),
|
||||||
|
));
|
||||||
|
let plugin_toolchain_diagnostics = Arc::new(PluginToolchainDiagnostics::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&spawner_port),
|
||||||
|
Arc::clone(&environment_reader_port),
|
||||||
|
));
|
||||||
|
let plugin_event_subscriptions = Arc::new(PluginEventSubscriptions::new(
|
||||||
|
Arc::clone(&store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
));
|
||||||
|
{
|
||||||
|
let mut rx = event_bus.raw_receiver();
|
||||||
|
let subscriptions = Arc::clone(&plugin_event_subscriptions);
|
||||||
|
spawn_detached(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(event) => subscriptions.record_domain_event(&event),
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
let background_wake = Arc::new(AgentWakeService::new(
|
let background_wake = Arc::new(AgentWakeService::new(
|
||||||
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
||||||
Arc::clone(&input_mediator),
|
Arc::clone(&input_mediator),
|
||||||
@ -2982,6 +3043,12 @@ impl BackendCore {
|
|||||||
uninstall_plugin,
|
uninstall_plugin,
|
||||||
list_plugin_runtime_contributions,
|
list_plugin_runtime_contributions,
|
||||||
reconcile_plugin_mcp_servers,
|
reconcile_plugin_mcp_servers,
|
||||||
|
plugin_workspace_access,
|
||||||
|
plugin_config_documents,
|
||||||
|
query_project_structure,
|
||||||
|
plugin_command_tasks,
|
||||||
|
plugin_toolchain_diagnostics,
|
||||||
|
plugin_event_subscriptions,
|
||||||
plugin_package_store: Arc::clone(&plugin_packages),
|
plugin_package_store: Arc::clone(&plugin_packages),
|
||||||
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
||||||
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
||||||
|
|||||||
@ -366,6 +366,15 @@ pub enum DomainEvent {
|
|||||||
/// The project.
|
/// The project.
|
||||||
project_id: ProjectId,
|
project_id: ProjectId,
|
||||||
},
|
},
|
||||||
|
/// A file under a project workspace changed through a public plugin workspace API.
|
||||||
|
PluginWorkspaceFileChanged {
|
||||||
|
/// The owning project.
|
||||||
|
project_id: ProjectId,
|
||||||
|
/// Normalized path relative to the project root.
|
||||||
|
path: String,
|
||||||
|
/// Public operation label, for example `changed`.
|
||||||
|
operation: String,
|
||||||
|
},
|
||||||
/// An orchestrator request (dropped under `.ideai/requests/`) was processed
|
/// An orchestrator request (dropped under `.ideai/requests/`) was processed
|
||||||
/// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the
|
/// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the
|
||||||
/// frontend can surface orchestration activity; the resulting cell/tab opens
|
/// frontend can surface orchestration activity; the resulting cell/tab opens
|
||||||
|
|||||||
@ -480,6 +480,17 @@ pub struct DirEntry {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Basic metadata returned by [`FileSystem::metadata`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileMetadata {
|
||||||
|
/// Whether the path points to a regular file.
|
||||||
|
pub is_file: bool,
|
||||||
|
/// Whether the path points to a directory.
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// File length in bytes when known.
|
||||||
|
pub len: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// An owned, boxed stream of PTY output chunks.
|
/// An owned, boxed stream of PTY output chunks.
|
||||||
///
|
///
|
||||||
/// Concrete adapters decide the underlying transport; the domain only sees a
|
/// Concrete adapters decide the underlying transport; the domain only sees a
|
||||||
@ -1281,6 +1292,12 @@ pub trait ProcessSpawner: Send + Sync {
|
|||||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads host environment variables through an injected adapter.
|
||||||
|
pub trait EnvironmentReader: Send + Sync {
|
||||||
|
/// Returns one environment variable value, if present.
|
||||||
|
fn get(&self, name: &str) -> Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a local structured CLI version using only the allowed `--version` probe.
|
/// Read a local structured CLI version using only the allowed `--version` probe.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait CliVersionReader: Send + Sync {
|
pub trait CliVersionReader: Send + Sync {
|
||||||
@ -1526,6 +1543,25 @@ pub trait FileSystem: Send + Sync {
|
|||||||
/// [`FsError`] on failure.
|
/// [`FsError`] on failure.
|
||||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
|
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError>;
|
||||||
|
|
||||||
|
/// Returns basic metadata for a path.
|
||||||
|
///
|
||||||
|
/// The default keeps older remote/test adapters source-compatible. Concrete
|
||||||
|
/// adapters that can cheaply stat paths should override it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`FsError`] on failure.
|
||||||
|
async fn metadata(&self, path: &RemotePath) -> Result<FileMetadata, FsError> {
|
||||||
|
if self.exists(path).await? {
|
||||||
|
Ok(FileMetadata {
|
||||||
|
is_file: false,
|
||||||
|
is_dir: false,
|
||||||
|
len: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a single file. A **missing** file is treated as success (idempotent
|
/// Removes a single file. A **missing** file is treated as success (idempotent
|
||||||
/// delete), so this is safe to call best-effort.
|
/// delete), so this is safe to call best-effort.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use std::io;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{DirEntry, FileSystem, FsError, RemotePath};
|
use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
/// Filesystem adapter backed by the local OS via `tokio::fs`.
|
/// Filesystem adapter backed by the local OS via `tokio::fs`.
|
||||||
@ -54,6 +54,17 @@ impl FileSystem for LocalFileSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn metadata(&self, path: &RemotePath) -> Result<FileMetadata, FsError> {
|
||||||
|
let meta = fs::metadata(path.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_io(path, &e))?;
|
||||||
|
Ok(FileMetadata {
|
||||||
|
is_file: meta.is_file(),
|
||||||
|
is_dir: meta.is_dir(),
|
||||||
|
len: Some(meta.len()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||||
match fs::remove_file(path.as_str()).await {
|
match fs::remove_file(path.as_str()).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
|
|||||||
@ -89,7 +89,7 @@ pub use orchestrator::{
|
|||||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||||
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||||
pub use process::LocalProcessSpawner;
|
pub use process::{LocalEnvironmentReader, LocalProcessSpawner};
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use ratelimit::RateLimitParser;
|
pub use ratelimit::RateLimitParser;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use domain::ports::{ExitStatus, Output, ProcessError, ProcessSpawner, SpawnSpec};
|
use domain::ports::{
|
||||||
|
EnvironmentReader, ExitStatus, Output, ProcessError, ProcessSpawner, SpawnSpec,
|
||||||
|
};
|
||||||
|
|
||||||
/// Process spawner backed by the local OS via `tokio::process::Command`.
|
/// Process spawner backed by the local OS via `tokio::process::Command`.
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
@ -23,6 +25,24 @@ impl LocalProcessSpawner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Environment reader backed by the local process environment.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct LocalEnvironmentReader;
|
||||||
|
|
||||||
|
impl LocalEnvironmentReader {
|
||||||
|
/// Creates a new [`LocalEnvironmentReader`].
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvironmentReader for LocalEnvironmentReader {
|
||||||
|
fn get(&self, name: &str) -> Option<String> {
|
||||||
|
std::env::var(name).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProcessSpawner for LocalProcessSpawner {
|
impl ProcessSpawner for LocalProcessSpawner {
|
||||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
||||||
|
|||||||
@ -45,7 +45,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
WebDesktopServerGateway,
|
WebDesktopServerGateway,
|
||||||
WebFocusedProjectGateway,
|
WebFocusedProjectGateway,
|
||||||
|
WebPluginConfigGateway,
|
||||||
|
WebPluginEventGateway,
|
||||||
WebPluginGateway,
|
WebPluginGateway,
|
||||||
|
WebPluginTaskGateway,
|
||||||
|
WebPluginToolchainGateway,
|
||||||
|
WebPluginWorkspaceGateway,
|
||||||
WebRemoteGateway,
|
WebRemoteGateway,
|
||||||
WebWindowGateway,
|
WebWindowGateway,
|
||||||
} from "./unsupported";
|
} from "./unsupported";
|
||||||
@ -141,6 +146,11 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||||
plugin: new WebPluginGateway(),
|
plugin: new WebPluginGateway(),
|
||||||
|
pluginWorkspace: new WebPluginWorkspaceGateway(),
|
||||||
|
pluginTask: new WebPluginTaskGateway(),
|
||||||
|
pluginToolchain: new WebPluginToolchainGateway(),
|
||||||
|
pluginEvents: new WebPluginEventGateway(),
|
||||||
|
pluginConfig: new WebPluginConfigGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,21 @@ import type {
|
|||||||
EmbeddedServerStatus,
|
EmbeddedServerStatus,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
PluginAdmin,
|
PluginAdmin,
|
||||||
|
PluginCommandTask,
|
||||||
|
PluginConfigDocument,
|
||||||
|
PluginConfigDocumentWriteResult,
|
||||||
|
PluginEventBatch,
|
||||||
|
PluginEventSubscription,
|
||||||
PluginInstallResult,
|
PluginInstallResult,
|
||||||
|
PluginToolchainDiagnostic,
|
||||||
|
PluginProjectStructure,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
|
PluginWorkspaceBinaryFile,
|
||||||
|
PluginWorkspaceDirectoryListing,
|
||||||
|
PluginWorkspaceStat,
|
||||||
|
PluginWorkspaceTextFile,
|
||||||
ServerExposurePreview,
|
ServerExposurePreview,
|
||||||
ServerExposureSettings,
|
ServerExposureSettings,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
@ -25,7 +36,24 @@ import type {
|
|||||||
DesktopServerGateway,
|
DesktopServerGateway,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
|
PluginConfigDocumentReadInput,
|
||||||
|
PluginConfigDocumentUpdateInput,
|
||||||
|
PluginConfigGateway,
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginEventPollInput,
|
||||||
|
PluginEventSubscribeInput,
|
||||||
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
PluginProjectStructureQuery,
|
||||||
|
PluginRunCommandInput,
|
||||||
|
PluginTaskGateway,
|
||||||
|
PluginTaskStatusInput,
|
||||||
|
PluginToolchainDiagnosticRequest,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspacePathInput,
|
||||||
|
PluginWorkspaceWriteBinaryInput,
|
||||||
|
PluginWorkspaceWriteTextInput,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
@ -160,3 +188,78 @@ export class WebPluginGateway implements PluginGateway {
|
|||||||
return unsupportedOnWeb("Plugin management");
|
return unsupportedOnWeb("Plugin management");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
|
||||||
|
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||||
|
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async readBinary(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async writeText(_input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async writeBinary(_input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async listDir(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async stat(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
async queryProjectStructure(
|
||||||
|
_input: PluginProjectStructureQuery,
|
||||||
|
): Promise<PluginProjectStructure> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: plugin command tasks are desktop-hosted in this runtime. */
|
||||||
|
export class WebPluginTaskGateway implements PluginTaskGateway {
|
||||||
|
async runCommand(_input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||||
|
return unsupportedOnWeb("Plugin command tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(_input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||||
|
return unsupportedOnWeb("Plugin command tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: plugin toolchain diagnostics run on the desktop host. */
|
||||||
|
export class WebPluginToolchainGateway implements PluginToolchainGateway {
|
||||||
|
async diagnose(
|
||||||
|
_input: PluginToolchainDiagnosticRequest,
|
||||||
|
): Promise<PluginToolchainDiagnostic> {
|
||||||
|
return unsupportedOnWeb("Plugin toolchain diagnostics");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: plugin public events are sourced from the desktop host. */
|
||||||
|
export class WebPluginEventGateway implements PluginEventGateway {
|
||||||
|
async subscribe(_input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
return unsupportedOnWeb("Plugin public events");
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(_input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||||
|
return unsupportedOnWeb("Plugin public events");
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(_input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
return unsupportedOnWeb("Plugin public events");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web stub: plugin config documents are read/written by the desktop host. */
|
||||||
|
export class WebPluginConfigGateway implements PluginConfigGateway {
|
||||||
|
async readDocument(_input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||||
|
return unsupportedOnWeb("Plugin structured config documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDocument(
|
||||||
|
_input: PluginConfigDocumentUpdateInput,
|
||||||
|
): Promise<PluginConfigDocumentWriteResult> {
|
||||||
|
return unsupportedOnWeb("Plugin structured config documents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,11 @@ import { TauriWindowGateway } from "./window";
|
|||||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||||
import { TauriPluginGateway } from "./plugin";
|
import { TauriPluginGateway } from "./plugin";
|
||||||
|
import { TauriPluginWorkspaceGateway } from "./pluginWorkspace";
|
||||||
|
import { TauriPluginTaskGateway } from "./pluginTask";
|
||||||
|
import { TauriPluginToolchainGateway } from "./pluginToolchain";
|
||||||
|
import { TauriPluginEventGateway } from "./pluginEvents";
|
||||||
|
import { TauriPluginConfigGateway } from "./pluginConfig";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -77,6 +82,11 @@ export function createTauriGateways(): Gateways {
|
|||||||
focusedProject: new TauriFocusedProjectGateway(),
|
focusedProject: new TauriFocusedProjectGateway(),
|
||||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||||
plugin: new TauriPluginGateway(),
|
plugin: new TauriPluginGateway(),
|
||||||
|
pluginWorkspace: new TauriPluginWorkspaceGateway(),
|
||||||
|
pluginTask: new TauriPluginTaskGateway(),
|
||||||
|
pluginToolchain: new TauriPluginToolchainGateway(),
|
||||||
|
pluginEvents: new TauriPluginEventGateway(),
|
||||||
|
pluginConfig: new TauriPluginConfigGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,13 +42,30 @@ import type {
|
|||||||
PairingCode,
|
PairingCode,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
PluginAdmin,
|
PluginAdmin,
|
||||||
|
PluginCommandTask,
|
||||||
|
PluginConfigDocument,
|
||||||
|
PluginConfigDocumentWriteResult,
|
||||||
PluginContributionSummary,
|
PluginContributionSummary,
|
||||||
|
PluginEventBatch,
|
||||||
|
PluginEventSubscription,
|
||||||
PluginInstallResult,
|
PluginInstallResult,
|
||||||
PluginLifecycleState,
|
PluginLifecycleState,
|
||||||
|
PluginProjectConvention,
|
||||||
|
PluginProjectModule,
|
||||||
|
PluginProjectStructure,
|
||||||
|
PluginProjectStructureEntry,
|
||||||
PluginReview,
|
PluginReview,
|
||||||
PluginRuntimeContributionCatalog,
|
PluginRuntimeContributionCatalog,
|
||||||
|
PluginToolchainDiagnostic,
|
||||||
PluginUninstallResult,
|
PluginUninstallResult,
|
||||||
|
PluginPublicEvent,
|
||||||
|
PluginWorkspaceBinaryFile,
|
||||||
|
PluginWorkspaceDirectoryListing,
|
||||||
|
PluginWorkspaceDirEntry,
|
||||||
|
PluginWorkspaceStat,
|
||||||
|
PluginWorkspaceTextFile,
|
||||||
Project,
|
Project,
|
||||||
|
JsonValue,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
@ -104,7 +121,24 @@ import type {
|
|||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
PermissionGateway,
|
PermissionGateway,
|
||||||
|
PluginConfigDocumentReadInput,
|
||||||
|
PluginConfigDocumentUpdateInput,
|
||||||
|
PluginConfigGateway,
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginEventPollInput,
|
||||||
|
PluginEventSubscribeInput,
|
||||||
|
PluginEventUnsubscribeInput,
|
||||||
PluginGateway,
|
PluginGateway,
|
||||||
|
PluginProjectStructureQuery,
|
||||||
|
PluginRunCommandInput,
|
||||||
|
PluginTaskGateway,
|
||||||
|
PluginTaskStatusInput,
|
||||||
|
PluginToolchainDiagnosticRequest,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspacePathInput,
|
||||||
|
PluginWorkspaceWriteBinaryInput,
|
||||||
|
PluginWorkspaceWriteTextInput,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
@ -3540,6 +3574,448 @@ export class MockPluginGateway implements PluginGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_MARKERS: Record<string, string> = {
|
||||||
|
"package.json": "node-package",
|
||||||
|
"Cargo.toml": "rust-cargo",
|
||||||
|
"pyproject.toml": "python-project",
|
||||||
|
"go.mod": "go-module",
|
||||||
|
Makefile: "makefile",
|
||||||
|
makefile: "makefile",
|
||||||
|
".git": "git-repository",
|
||||||
|
};
|
||||||
|
|
||||||
|
function invalidWorkspacePath(path: string): GatewayError {
|
||||||
|
return {
|
||||||
|
code: "INVALID",
|
||||||
|
message: `workspace path must be relative to the project root: ${path}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspacePath(path: string): string {
|
||||||
|
const raw = path.trim();
|
||||||
|
if (raw === "" || raw === ".") return "";
|
||||||
|
if (raw.includes("\0") || raw.startsWith("/") || raw.startsWith("\\") || raw.includes(":")) {
|
||||||
|
throw invalidWorkspacePath(path);
|
||||||
|
}
|
||||||
|
const parts = raw.replace(/\\/g, "/").split("/");
|
||||||
|
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
||||||
|
throw invalidWorkspacePath(path);
|
||||||
|
}
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path: string): string {
|
||||||
|
return path.split("/").pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory plugin workspace gateway for offline plugin development/tests.
|
||||||
|
* It mirrors the public contract shape, not the host filesystem.
|
||||||
|
*/
|
||||||
|
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||||
|
private readonly files = new Map<string, Map<string, Uint8Array>>();
|
||||||
|
|
||||||
|
private bucket(projectId: string): Map<string, Uint8Array> {
|
||||||
|
let files = this.files.get(projectId);
|
||||||
|
if (!files) {
|
||||||
|
files = new Map();
|
||||||
|
this.files.set(projectId, files);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
_seedText(projectId: string, path: string, content: string): void {
|
||||||
|
this.bucket(projectId).set(normalizeWorkspacePath(path), new TextEncoder().encode(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||||
|
const file = await this.readBinary(input);
|
||||||
|
return { path: file.path, content: new TextDecoder().decode(file.bytes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
const bytes = this.bucket(input.projectId).get(path);
|
||||||
|
if (!bytes) {
|
||||||
|
const err: GatewayError = { code: "NOT_FOUND", message: `workspace file ${path} not found` };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { path, bytes: new Uint8Array(bytes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
this.bucket(input.projectId).set(path, new TextEncoder().encode(input.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
this.bucket(input.projectId).set(path, new Uint8Array(input.bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
const prefix = path === "" ? "" : `${path}/`;
|
||||||
|
const entries = new Map<string, PluginWorkspaceDirEntry>();
|
||||||
|
for (const filePath of this.bucket(input.projectId).keys()) {
|
||||||
|
if (!filePath.startsWith(prefix)) continue;
|
||||||
|
const rest = filePath.slice(prefix.length);
|
||||||
|
if (rest === "") continue;
|
||||||
|
const [name, ...tail] = rest.split("/");
|
||||||
|
const entryPath = path === "" ? name : `${path}/${name}`;
|
||||||
|
const existing = entries.get(name);
|
||||||
|
entries.set(name, {
|
||||||
|
name,
|
||||||
|
path: entryPath,
|
||||||
|
isDir: Boolean(existing?.isDir) || tail.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { path, entries: [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
const files = this.bucket(input.projectId);
|
||||||
|
const bytes = files.get(path);
|
||||||
|
if (bytes) {
|
||||||
|
return { path, exists: true, isFile: true, isDir: false, len: bytes.byteLength };
|
||||||
|
}
|
||||||
|
const prefix = path === "" ? "" : `${path}/`;
|
||||||
|
const isDir = [...files.keys()].some((filePath) => filePath.startsWith(prefix));
|
||||||
|
return { path, exists: isDir, isFile: false, isDir, len: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryProjectStructure(
|
||||||
|
input: PluginProjectStructureQuery,
|
||||||
|
): Promise<PluginProjectStructure> {
|
||||||
|
const rootPath = normalizeWorkspacePath(input.path ?? "");
|
||||||
|
const maxDepth = Math.min(input.maxDepth ?? 3, 8);
|
||||||
|
const maxEntries = Math.min(input.maxEntries ?? 500, 5000);
|
||||||
|
const prefix = rootPath === "" ? "" : `${rootPath}/`;
|
||||||
|
const entries = new Map<string, PluginProjectStructureEntry>();
|
||||||
|
const conventions = new Map<string, PluginProjectConvention>();
|
||||||
|
const modules = new Map<string, PluginProjectModule>();
|
||||||
|
|
||||||
|
for (const filePath of this.bucket(input.projectId).keys()) {
|
||||||
|
if (!filePath.startsWith(prefix)) continue;
|
||||||
|
const rest = filePath.slice(prefix.length);
|
||||||
|
const parts = rest.split("/").filter(Boolean);
|
||||||
|
for (let i = 0; i < parts.length && i <= maxDepth; i += 1) {
|
||||||
|
const path = [rootPath, ...parts.slice(0, i + 1)].filter(Boolean).join("/");
|
||||||
|
const isLeaf = i === parts.length - 1;
|
||||||
|
entries.set(path, {
|
||||||
|
path,
|
||||||
|
name: parts[i],
|
||||||
|
kind: isLeaf ? "file" : "directory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = basename(filePath);
|
||||||
|
const conventionId = PROJECT_MARKERS[marker];
|
||||||
|
if (conventionId) {
|
||||||
|
conventions.set(`${conventionId}:${filePath}`, { id: conventionId, markerPath: filePath });
|
||||||
|
const modulePath = filePath.slice(0, Math.max(0, filePath.length - marker.length - 1));
|
||||||
|
modules.set(`${conventionId}:${modulePath}`, {
|
||||||
|
path: modulePath,
|
||||||
|
markerPath: filePath,
|
||||||
|
conventionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
rootPath,
|
||||||
|
entries: sortedEntries.slice(0, maxEntries),
|
||||||
|
conventions: [...conventions.values()].sort((a, b) =>
|
||||||
|
a.markerPath.localeCompare(b.markerPath),
|
||||||
|
),
|
||||||
|
modules: [...modules.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
||||||
|
truncated: sortedEntries.length > maxEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory command-task gateway for plugin runtime tests/dev. It models host
|
||||||
|
* task creation and status reads; live output remains owned by WorkStateGateway.
|
||||||
|
*/
|
||||||
|
export class MockPluginTaskGateway implements PluginTaskGateway {
|
||||||
|
private readonly tasks = new Map<string, PluginCommandTask>();
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
|
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||||
|
if (input.command.trim() === "") {
|
||||||
|
const err: GatewayError = { code: "INVALID", message: "command must not be empty" };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const task: PluginCommandTask = {
|
||||||
|
taskId: `mock-plugin-task-${this.nextId++}`,
|
||||||
|
ownerAgentId: input.ownerAgentId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
kind: "command",
|
||||||
|
state: "running",
|
||||||
|
exitCode: null,
|
||||||
|
summary: input.label,
|
||||||
|
stdoutTail: null,
|
||||||
|
stderrTail: null,
|
||||||
|
createdAtMs: now,
|
||||||
|
updatedAtMs: now,
|
||||||
|
};
|
||||||
|
this.tasks.set(task.taskId, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||||
|
return this.tasks.get(input.taskId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory generic toolchain diagnostics for plugin tests/dev. It is
|
||||||
|
* deterministic and does not inspect the real host environment.
|
||||||
|
*/
|
||||||
|
export class MockPluginToolchainGateway implements PluginToolchainGateway {
|
||||||
|
async diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
|
||||||
|
const tools = (input.tools ?? []).map((tool) => {
|
||||||
|
const missing = tool.executable.includes("missing");
|
||||||
|
const ok = !missing;
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
executable: tool.executable,
|
||||||
|
present: ok,
|
||||||
|
ok,
|
||||||
|
status: ok ? ("ok" as const) : ("missing" as const),
|
||||||
|
required: tool.required ?? false,
|
||||||
|
exitCode: ok ? 0 : null,
|
||||||
|
version: ok ? `${tool.executable} mock-version` : null,
|
||||||
|
stdout: ok ? `${tool.executable} mock-version\n` : null,
|
||||||
|
stderr: null,
|
||||||
|
error: ok ? null : `executable not found: ${tool.executable}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = (input.env ?? []).map((requirement) => {
|
||||||
|
const present = !requirement.name.includes("MISSING");
|
||||||
|
const value = present ? (requirement.equals ?? "mock") : null;
|
||||||
|
const ok = present && (requirement.equals === undefined || value === requirement.equals);
|
||||||
|
return {
|
||||||
|
name: requirement.name,
|
||||||
|
present,
|
||||||
|
ok,
|
||||||
|
required: requirement.required ?? false,
|
||||||
|
value,
|
||||||
|
status: ok ? ("ok" as const) : present ? ("mismatch" as const) : ("missing" as const),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = (input.files ?? []).map((requirement) => {
|
||||||
|
const exists = !requirement.path.includes("missing");
|
||||||
|
const kind = exists ? (requirement.kind === "directory" ? "directory" : "file") : "missing";
|
||||||
|
const ok =
|
||||||
|
exists &&
|
||||||
|
(requirement.kind === undefined || requirement.kind === "any" || requirement.kind === kind);
|
||||||
|
return {
|
||||||
|
path: requirement.path,
|
||||||
|
exists,
|
||||||
|
ok,
|
||||||
|
required: requirement.required ?? false,
|
||||||
|
kind: kind as "file" | "directory" | "missing",
|
||||||
|
expectedKind: requirement.kind ?? null,
|
||||||
|
len: exists && kind === "file" ? 12 : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
...tools
|
||||||
|
.filter((tool) => tool.required && !tool.ok)
|
||||||
|
.map((tool) => ({ level: "error" as const, message: `${tool.id}: ${tool.error}` })),
|
||||||
|
...env
|
||||||
|
.filter((item) => item.required && !item.ok)
|
||||||
|
.map((item) => ({ level: "error" as const, message: `${item.name}: ${item.status}` })),
|
||||||
|
...files
|
||||||
|
.filter((file) => file.required && !file.ok)
|
||||||
|
.map((file) => ({ level: "error" as const, message: `${file.path}: ${file.kind}` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
cwd: input.cwd ?? "",
|
||||||
|
ok: messages.length === 0,
|
||||||
|
tools,
|
||||||
|
env,
|
||||||
|
files,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockPluginEventSubscriptionState {
|
||||||
|
subscription: PluginEventSubscription;
|
||||||
|
queue: PluginPublicEvent[];
|
||||||
|
dropped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory public plugin event gateway for offline runtime tests/dev.
|
||||||
|
*/
|
||||||
|
export class MockPluginEventGateway implements PluginEventGateway {
|
||||||
|
private readonly subscriptions = new Map<string, MockPluginEventSubscriptionState>();
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
|
async subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
const subscription: PluginEventSubscription = {
|
||||||
|
subscriptionId: `mock-plugin-events-${this.nextId++}`,
|
||||||
|
projectId: input.projectId,
|
||||||
|
eventTypes: input.eventTypes?.length
|
||||||
|
? input.eventTypes
|
||||||
|
: ["workspaceFileChanged", "backgroundTaskChanged"],
|
||||||
|
capacity: Math.min(Math.max(input.capacity ?? 100, 1), 1000),
|
||||||
|
retention: "bestEffortBounded",
|
||||||
|
};
|
||||||
|
this.subscriptions.set(subscription.subscriptionId, {
|
||||||
|
subscription,
|
||||||
|
queue: [],
|
||||||
|
dropped: 0,
|
||||||
|
});
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||||
|
const state = this.subscriptions.get(input.subscriptionId);
|
||||||
|
if (!state) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "plugin event subscription not found",
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const maxEvents = Math.min(Math.max(input.maxEvents ?? 100, 1), 1000);
|
||||||
|
const events = state.queue.splice(0, maxEvents);
|
||||||
|
const dropped = state.dropped;
|
||||||
|
state.dropped = 0;
|
||||||
|
return { subscriptionId: input.subscriptionId, events, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
const state = this.subscriptions.get(input.subscriptionId);
|
||||||
|
this.subscriptions.delete(input.subscriptionId);
|
||||||
|
return (
|
||||||
|
state?.subscription ?? {
|
||||||
|
subscriptionId: input.subscriptionId,
|
||||||
|
projectId: "",
|
||||||
|
eventTypes: [],
|
||||||
|
capacity: 0,
|
||||||
|
retention: "disposed",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(event: PluginPublicEvent): void {
|
||||||
|
for (const state of this.subscriptions.values()) {
|
||||||
|
if (
|
||||||
|
state.subscription.projectId !== event.projectId ||
|
||||||
|
!state.subscription.eventTypes.includes(event.type)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state.queue.length >= state.subscription.capacity) {
|
||||||
|
state.queue.shift();
|
||||||
|
state.dropped += 1;
|
||||||
|
}
|
||||||
|
state.queue.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneJson<T extends JsonValue>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJsonMergePatch(target: JsonValue, patch: JsonValue): JsonValue {
|
||||||
|
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return cloneJson(patch);
|
||||||
|
const base =
|
||||||
|
target !== null && typeof target === "object" && !Array.isArray(target)
|
||||||
|
? { ...target }
|
||||||
|
: {};
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key];
|
||||||
|
} else {
|
||||||
|
base[key] = applyJsonMergePatch(base[key] ?? null, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory JSON config-document gateway for plugin tests/dev.
|
||||||
|
*/
|
||||||
|
export class MockPluginConfigGateway implements PluginConfigGateway {
|
||||||
|
private readonly documents = new Map<string, JsonValue>();
|
||||||
|
|
||||||
|
_seed(projectId: string, path: string, value: JsonValue): void {
|
||||||
|
this.documents.set(`${projectId}:${normalizeWorkspacePath(path)}`, cloneJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
const format = input.format ?? "json";
|
||||||
|
if (format !== "json") {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "INVALID",
|
||||||
|
message: `unsupported structured config format: ${format}; supported formats: json`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const value = this.documents.get(`${input.projectId}:${path}`);
|
||||||
|
if (value === undefined) {
|
||||||
|
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { projectId: input.projectId, path, format, value: cloneJson(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDocument(
|
||||||
|
input: PluginConfigDocumentUpdateInput,
|
||||||
|
): Promise<PluginConfigDocumentWriteResult> {
|
||||||
|
const path = normalizeWorkspacePath(input.path);
|
||||||
|
const format = input.format ?? "json";
|
||||||
|
const mode = input.mode ?? "mergePatch";
|
||||||
|
if (format !== "json") {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "INVALID",
|
||||||
|
message: `unsupported structured config format: ${format}; supported formats: json`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (mode !== "mergePatch" && mode !== "replace") {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "INVALID",
|
||||||
|
message: `unsupported structured config update mode: ${mode}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const key = `${input.projectId}:${path}`;
|
||||||
|
const current = this.documents.get(key);
|
||||||
|
if (mode === "mergePatch" && current === undefined) {
|
||||||
|
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const next = mode === "replace" ? cloneJson(input.value) : applyJsonMergePatch(current!, input.value);
|
||||||
|
this.documents.set(key, next);
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
path,
|
||||||
|
format,
|
||||||
|
mode,
|
||||||
|
bytesWritten: JSON.stringify(next, null, 2).length + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds the full set of mock gateways. */
|
/** Builds the full set of mock gateways. */
|
||||||
export function createMockGateways(): Gateways {
|
export function createMockGateways(): Gateways {
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
@ -3569,6 +4045,11 @@ export function createMockGateways(): Gateways {
|
|||||||
focusedProject: new MockFocusedProjectGateway(),
|
focusedProject: new MockFocusedProjectGateway(),
|
||||||
uiPreferences: new MockUiPreferencesGateway(),
|
uiPreferences: new MockUiPreferencesGateway(),
|
||||||
plugin: new MockPluginGateway(),
|
plugin: new MockPluginGateway(),
|
||||||
|
pluginWorkspace: new MockPluginWorkspaceGateway(),
|
||||||
|
pluginTask: new MockPluginTaskGateway(),
|
||||||
|
pluginToolchain: new MockPluginToolchainGateway(),
|
||||||
|
pluginEvents: new MockPluginEventGateway(),
|
||||||
|
pluginConfig: new MockPluginConfigGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,11 @@ describe("createMockGateways", () => {
|
|||||||
"modelServer",
|
"modelServer",
|
||||||
"permission",
|
"permission",
|
||||||
"plugin",
|
"plugin",
|
||||||
|
"pluginConfig",
|
||||||
|
"pluginEvents",
|
||||||
|
"pluginTask",
|
||||||
|
"pluginToolchain",
|
||||||
|
"pluginWorkspace",
|
||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
|||||||
24
frontend/src/adapters/pluginConfig.ts
Normal file
24
frontend/src/adapters/pluginConfig.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for public plugin structured configuration documents (#130).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { PluginConfigDocument, PluginConfigDocumentWriteResult } from "@/domain";
|
||||||
|
import type {
|
||||||
|
PluginConfigDocumentReadInput,
|
||||||
|
PluginConfigDocumentUpdateInput,
|
||||||
|
PluginConfigGateway,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
|
export class TauriPluginConfigGateway implements PluginConfigGateway {
|
||||||
|
readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
|
||||||
|
return invoke<PluginConfigDocument>("plugin_config_read_document", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocument(
|
||||||
|
input: PluginConfigDocumentUpdateInput,
|
||||||
|
): Promise<PluginConfigDocumentWriteResult> {
|
||||||
|
return invoke<PluginConfigDocumentWriteResult>("plugin_config_update_document", { input });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
frontend/src/adapters/pluginEvents.ts
Normal file
27
frontend/src/adapters/pluginEvents.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for stable public plugin events (#127).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { PluginEventBatch, PluginEventSubscription } from "@/domain";
|
||||||
|
import type {
|
||||||
|
PluginEventGateway,
|
||||||
|
PluginEventPollInput,
|
||||||
|
PluginEventSubscribeInput,
|
||||||
|
PluginEventUnsubscribeInput,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
|
export class TauriPluginEventGateway implements PluginEventGateway {
|
||||||
|
subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
return invoke<PluginEventSubscription>("plugin_events_subscribe", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
|
||||||
|
return invoke<PluginEventBatch>("plugin_events_poll", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
|
||||||
|
return invoke<PluginEventSubscription>("plugin_events_unsubscribe", { input });
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/adapters/pluginTask.ts
Normal file
40
frontend/src/adapters/pluginTask.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for the public plugin command-task SDK facade (#125).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { PluginCommandTask } from "@/domain";
|
||||||
|
import type { PluginRunCommandInput, PluginTaskGateway, PluginTaskStatusInput } from "@/ports";
|
||||||
|
|
||||||
|
type PluginCommandTaskDto = Omit<
|
||||||
|
PluginCommandTask,
|
||||||
|
"exitCode" | "summary" | "stdoutTail" | "stderrTail"
|
||||||
|
> & {
|
||||||
|
exitCode?: number | null;
|
||||||
|
summary?: string | null;
|
||||||
|
stdoutTail?: string | null;
|
||||||
|
stderrTail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTask(task: PluginCommandTaskDto): PluginCommandTask {
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
exitCode: task.exitCode ?? null,
|
||||||
|
summary: task.summary ?? null,
|
||||||
|
stdoutTail: task.stdoutTail ?? null,
|
||||||
|
stderrTail: task.stderrTail ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TauriPluginTaskGateway implements PluginTaskGateway {
|
||||||
|
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
|
||||||
|
const task = await invoke<PluginCommandTaskDto>("plugin_task_run_command", { input });
|
||||||
|
return normalizeTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
|
||||||
|
const task = await invoke<PluginCommandTaskDto | null>("plugin_task_get_status", { input });
|
||||||
|
return task ? normalizeTask(task) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/adapters/pluginToolchain.ts
Normal file
17
frontend/src/adapters/pluginToolchain.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for public plugin external-toolchain diagnostics (#126).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type { PluginToolchainDiagnostic } from "@/domain";
|
||||||
|
import type {
|
||||||
|
PluginToolchainDiagnosticRequest,
|
||||||
|
PluginToolchainGateway,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
|
export class TauriPluginToolchainGateway implements PluginToolchainGateway {
|
||||||
|
diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
|
||||||
|
return invoke<PluginToolchainDiagnostic>("plugin_toolchain_diagnose", { input });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
frontend/src/adapters/pluginWorkspace.ts
Normal file
77
frontend/src/adapters/pluginWorkspace.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Tauri adapter for the public plugin workspace/project-structure SDK facade
|
||||||
|
* (#124 + #129). The commands are plugin-scoped even though the gateway is
|
||||||
|
* frontend-internal: plugins only see the stable service methods.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginProjectStructure,
|
||||||
|
PluginWorkspaceBinaryFile,
|
||||||
|
PluginWorkspaceDirectoryListing,
|
||||||
|
PluginWorkspaceStat,
|
||||||
|
PluginWorkspaceTextFile,
|
||||||
|
} from "@/domain";
|
||||||
|
import type {
|
||||||
|
PluginProjectStructureQuery,
|
||||||
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspacePathInput,
|
||||||
|
PluginWorkspaceWriteBinaryInput,
|
||||||
|
PluginWorkspaceWriteTextInput,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
|
type BinaryFileDto = Omit<PluginWorkspaceBinaryFile, "bytes"> & {
|
||||||
|
bytes: number[] | Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toByteArray(bytes: number[] | Uint8Array): Uint8Array {
|
||||||
|
return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBinaryFile(file: BinaryFileDto): PluginWorkspaceBinaryFile {
|
||||||
|
return { ...file, bytes: toByteArray(file.bytes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryInput(input: PluginWorkspaceWriteBinaryInput): {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
bytes: number[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
path: input.path,
|
||||||
|
bytes: Array.from(input.bytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||||
|
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||||
|
return invoke<PluginWorkspaceTextFile>("plugin_workspace_read_text", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
|
||||||
|
const file = await invoke<BinaryFileDto>("plugin_workspace_read_binary", { input });
|
||||||
|
return normalizeBinaryFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
|
||||||
|
await invoke("plugin_workspace_write_text", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
|
||||||
|
await invoke("plugin_workspace_write_binary", { input: binaryInput(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
|
||||||
|
return invoke<PluginWorkspaceDirectoryListing>("plugin_workspace_list_dir", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
|
||||||
|
return invoke<PluginWorkspaceStat>("plugin_workspace_stat", { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
|
||||||
|
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@ This first version intentionally stays small:
|
|||||||
- public manifest types for `idea-plugin.json`;
|
- public manifest types for `idea-plugin.json`;
|
||||||
- public runtime types for plugin modules exposing `activate(ctx)`;
|
- public runtime types for plugin modules exposing `activate(ctx)`;
|
||||||
- a stable `ctx.services` facade for workspace, background task and terminal operations;
|
- a stable `ctx.services` facade for workspace, background task and terminal operations;
|
||||||
|
- public workspace file APIs for reading, writing, listing, stat and path resolution;
|
||||||
|
- a bounded generic project-structure query API;
|
||||||
|
- public command-task APIs for launching and tracking generic tools;
|
||||||
|
- public external-toolchain diagnostics for executables, env vars and files;
|
||||||
|
- public best-effort event subscriptions and workspace watch;
|
||||||
|
- public structured config-document helpers for JSON documents;
|
||||||
- a lightweight manifest validator;
|
- a lightweight manifest validator;
|
||||||
- a minimal `examples/hello-plugin` plugin.
|
- a minimal `examples/hello-plugin` plugin.
|
||||||
|
|
||||||
@ -70,6 +76,59 @@ export function activate(ctx: ActivateContext): void {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Layout Runtime
|
||||||
|
|
||||||
|
Plugins can contribute custom layout panels by declaring `contributes.layouts`
|
||||||
|
in `idea-plugin.json` and registering the matching layout type during
|
||||||
|
`activate(ctx)`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributes": {
|
||||||
|
"layouts": [
|
||||||
|
{
|
||||||
|
"type": "com.example.status",
|
||||||
|
"label": "Status",
|
||||||
|
"component": "StatusPanel"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext, PluginLayoutProps } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
function StatusPanel(props: PluginLayoutProps): string {
|
||||||
|
return `status for ${props.projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activate(ctx: ActivateContext): void {
|
||||||
|
const disposable = ctx.layouts?.register({
|
||||||
|
type: "com.example.status",
|
||||||
|
component: StatusPanel
|
||||||
|
});
|
||||||
|
if (disposable) ctx.subscriptions.push(disposable);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Public layout props are:
|
||||||
|
|
||||||
|
- `projectId`: project hosting the layout cell;
|
||||||
|
- `nodeId`: stable layout node id for that cell instance;
|
||||||
|
- `layoutType`: contributed layout type from the manifest;
|
||||||
|
- `state`: opaque JSON-serializable state persisted by the host;
|
||||||
|
- `setState(next)`: replaces that state;
|
||||||
|
- `availability`: currently `"available"` when the component is mounted.
|
||||||
|
|
||||||
|
Lifecycle: register layouts during `activate(ctx)`, keep the returned disposable
|
||||||
|
in `ctx.subscriptions`, and let the host dispose it on plugin unload. Layout
|
||||||
|
components may be mounted, unmounted and remounted by the host; keep durable UI
|
||||||
|
state in `state` via `setState`, not in module globals. Call `setState` from
|
||||||
|
user actions, effects or asynchronous callbacks, not unconditionally while
|
||||||
|
rendering. Services are available from `ctx.services` to plugins declaring the
|
||||||
|
`tooling` capability; layout props do not expose private runtime gateways.
|
||||||
|
|
||||||
## Runtime Services
|
## Runtime Services
|
||||||
|
|
||||||
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
||||||
@ -91,12 +150,215 @@ export async function activate(ctx: ActivateContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Workspace Files
|
||||||
|
|
||||||
|
Workspace paths are always relative to the project root. Hosts reject absolute
|
||||||
|
paths, `..`, empty path segments and paths outside the sandbox. Text APIs use
|
||||||
|
UTF-8; binary APIs use `Uint8Array`. Missing files reject on reads and resolve
|
||||||
|
to `{ exists: false }` from `stat`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
|
const workspace = ctx.services?.workspace;
|
||||||
|
const project = await workspace?.getCurrentProject();
|
||||||
|
if (!workspace || !project) return;
|
||||||
|
|
||||||
|
await workspace.writeTextFile(".ideai/hello-plugin.txt", "hello\n", project.id);
|
||||||
|
|
||||||
|
const file = await workspace.readTextFile(".ideai/hello-plugin.txt", project.id);
|
||||||
|
const listing = await workspace.listDirectory(".ideai", project.id);
|
||||||
|
const stat = await workspace.stat(file.path, project.id);
|
||||||
|
|
||||||
|
ctx.logger.info("workspace file", {
|
||||||
|
path: file.path,
|
||||||
|
bytes: stat.len,
|
||||||
|
entries: listing.entries.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch(path, handler, projectId?)` subscribes to public workspace file-change
|
||||||
|
events for the given relative path. It is best-effort and bounded: plugins should
|
||||||
|
handle missed events by refreshing their own derived state when needed.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
`queryStructure()` returns a bounded, generic read model so plugins do not each
|
||||||
|
need to rescan the whole workspace for common markers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const structure = await ctx.services?.workspace.queryStructure({
|
||||||
|
maxDepth: 3,
|
||||||
|
maxEntries: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const convention of structure?.conventions ?? []) {
|
||||||
|
console.log(convention.id, convention.markerPath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The MVP detects generic marker-file conventions such as `package.json`,
|
||||||
|
`Cargo.toml`, `pyproject.toml`, `go.mod`, `Makefile` and `.git`. It deliberately
|
||||||
|
does not expose language-specific ASTs or Android-specific concepts.
|
||||||
|
|
||||||
Current terminal scope is intentionally minimal: it opens or reattaches a shell
|
Current terminal scope is intentionally minimal: it opens or reattaches a shell
|
||||||
PTY, writes bytes, resizes, detaches and closes. The background task service is
|
PTY, writes bytes, resizes, detaches and closes.
|
||||||
observation/control only in this SDK version: `list`, `getStatus`, `attachOutput`,
|
|
||||||
`cancel` and `retry` operate on existing tasks visible through IdeA's Work read
|
### Command Tasks
|
||||||
model. Starting new background tasks is not part of the public plugin API in this
|
|
||||||
lot.
|
Use `ctx.services.tasks.runCommand()` for non-interactive tools that should be
|
||||||
|
tracked as IdeA background tasks instead of opening a raw PTY. `command` and
|
||||||
|
`args` are passed separately, `cwd` is relative to the project root, and `env`
|
||||||
|
adds process environment variables. The current host requires an `ownerAgentId`
|
||||||
|
so the task can appear in Work and completion can be correlated to an agent.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
|
const project = await ctx.services?.workspace.getCurrentProject();
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
const task = await ctx.services?.tasks.runCommand({
|
||||||
|
projectId: project.id,
|
||||||
|
ownerAgentId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
label: "Check npm",
|
||||||
|
command: "npm",
|
||||||
|
args: ["--version"],
|
||||||
|
cwd: ".",
|
||||||
|
env: { CI: "1" },
|
||||||
|
recordOnly: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await ctx.services?.tasks.getCommandStatus(task.taskId);
|
||||||
|
ctx.logger.info("command task", {
|
||||||
|
taskId: task.taskId,
|
||||||
|
state: status?.state,
|
||||||
|
exitCode: status?.exitCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`list`, `getStatus`, `attachOutput`, `cancel` and `retry` continue to operate on
|
||||||
|
tasks visible through IdeA's Work read model. `getCommandStatus` reads a launched
|
||||||
|
command task directly from the host task store.
|
||||||
|
|
||||||
|
### Toolchain Diagnostics
|
||||||
|
|
||||||
|
Use `ctx.services.tooling.diagnose()` to check external prerequisites without
|
||||||
|
hard-coding one stack into the SDK. A request can probe executables, inspect
|
||||||
|
environment variables and validate workspace files in one structured result.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
|
const diagnostic = await ctx.services?.tooling.diagnose({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "node",
|
||||||
|
executable: "node",
|
||||||
|
versionArgs: ["--version"],
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
env: [{ name: "PATH", required: true }],
|
||||||
|
files: [{ path: "package.json", kind: "file" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const node = diagnostic?.tools.find((tool) => tool.id === "node");
|
||||||
|
ctx.logger.info("tooling diagnostic", {
|
||||||
|
ok: diagnostic?.ok,
|
||||||
|
nodePresent: node?.present,
|
||||||
|
nodeVersion: node?.version,
|
||||||
|
messages: diagnostic?.messages
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The diagnostic API is intentionally generic: it does not install tools, does not
|
||||||
|
model Android devices or emulators, and does not expose language-specific ASTs.
|
||||||
|
|
||||||
|
### Events And Watch
|
||||||
|
|
||||||
|
Use `ctx.services.events.subscribe()` for stable public host/project events. The
|
||||||
|
runtime hides the host polling details and returns a disposable subscription.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
|
const subscription = await ctx.services?.events.subscribe(
|
||||||
|
{
|
||||||
|
eventTypes: ["backgroundTaskChanged"],
|
||||||
|
capacity: 100,
|
||||||
|
onDropped: (count) => ctx.logger.warn("plugin events dropped", { count })
|
||||||
|
},
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "backgroundTaskChanged") {
|
||||||
|
ctx.logger.info("task changed", {
|
||||||
|
taskId: event.taskId,
|
||||||
|
state: event.state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscription) ctx.subscriptions.push(subscription);
|
||||||
|
|
||||||
|
const watch = await ctx.services?.workspace.watch("src", (event) => {
|
||||||
|
ctx.logger.info("workspace changed", {
|
||||||
|
path: event.path,
|
||||||
|
kind: event.kind,
|
||||||
|
operation: event.operation
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (watch) ctx.subscriptions.push(watch);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Public event retention is `bestEffortBounded`: events are retained per
|
||||||
|
subscription up to the requested/host-capped capacity, drained oldest-first, and
|
||||||
|
`onDropped` reports when older retained events were overwritten.
|
||||||
|
|
||||||
|
### Structured Config Documents
|
||||||
|
|
||||||
|
Use `ctx.services.config` when a plugin needs to read or update a structured
|
||||||
|
configuration file without reimplementing parsing and serialization.
|
||||||
|
|
||||||
|
First-lot format support is deliberately narrow:
|
||||||
|
|
||||||
|
- `json` only;
|
||||||
|
- inferred from `.json` when `format` is omitted;
|
||||||
|
- serialized as pretty JSON with a trailing newline;
|
||||||
|
- update modes: `mergePatch` and `replace`;
|
||||||
|
- `mergePatch` follows JSON merge-patch semantics: object keys are merged
|
||||||
|
recursively and `null` removes a key.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
||||||
|
const config = await ctx.services?.config.readDocument({
|
||||||
|
path: ".ideai/hello-plugin.json"
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.services?.config.updateDocument({
|
||||||
|
path: ".ideai/hello-plugin.json",
|
||||||
|
mode: "mergePatch",
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
lastReadFormat: config?.format ?? "json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
YAML, TOML, XML, `.properties` and stack-specific config models are not part of
|
||||||
|
this first lot.
|
||||||
|
|
||||||
Declare the additive `tooling` capability to receive `ctx.services` at runtime:
|
Declare the additive `tooling` capability to receive `ctx.services` at runtime:
|
||||||
|
|
||||||
|
|||||||
@ -1,29 +1,11 @@
|
|||||||
import type { ActivateContext, CommandDisposable, IdeAPluginModule } from "@idea/plugin-sdk";
|
import type { ActivateContext, IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
const COMMAND_ID = "hello-plugin";
|
const COMMAND_ID = "hello-plugin";
|
||||||
const LAYOUT_TYPE = "hello-plugin.hello-world";
|
const LAYOUT_TYPE = "hello-plugin.hello-world";
|
||||||
|
|
||||||
type HelloPluginLayoutProps = {
|
|
||||||
projectId?: string;
|
|
||||||
nodeId?: string;
|
|
||||||
layoutType?: string;
|
|
||||||
state?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LayoutRegistry = {
|
|
||||||
register(definition: {
|
|
||||||
type: string;
|
|
||||||
component: (props: HelloPluginLayoutProps) => string;
|
|
||||||
}): CommandDisposable;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HelloPluginContext = ActivateContext & {
|
|
||||||
layouts?: LayoutRegistry;
|
|
||||||
};
|
|
||||||
|
|
||||||
let hasLoggedFirstLayoutRender = false;
|
let hasLoggedFirstLayoutRender = false;
|
||||||
|
|
||||||
function HelloWorldLayout(props: HelloPluginLayoutProps): string {
|
function HelloWorldLayout(props: PluginLayoutProps): string {
|
||||||
if (!hasLoggedFirstLayoutRender) {
|
if (!hasLoggedFirstLayoutRender) {
|
||||||
hasLoggedFirstLayoutRender = true;
|
hasLoggedFirstLayoutRender = true;
|
||||||
console.info("[hello-plugin] layout first render", {
|
console.info("[hello-plugin] layout first render", {
|
||||||
@ -38,14 +20,13 @@ function HelloWorldLayout(props: HelloPluginLayoutProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function activate(ctx: ActivateContext): void {
|
export function activate(ctx: ActivateContext): void {
|
||||||
const pluginContext = ctx as HelloPluginContext;
|
|
||||||
ctx.logger.info("activating hello-plugin", {
|
ctx.logger.info("activating hello-plugin", {
|
||||||
pluginId: ctx.pluginId,
|
pluginId: ctx.pluginId,
|
||||||
hasCommands: Boolean(pluginContext.commands),
|
hasCommands: Boolean(ctx.commands),
|
||||||
hasLayouts: Boolean(pluginContext.layouts)
|
hasLayouts: Boolean(ctx.layouts)
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandDisposable = pluginContext.commands?.registerCommand(COMMAND_ID, () => {
|
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, () => {
|
||||||
ctx.logger.info("command executed", { commandId: COMMAND_ID });
|
ctx.logger.info("command executed", { commandId: COMMAND_ID });
|
||||||
return "hello-world";
|
return "hello-world";
|
||||||
});
|
});
|
||||||
@ -57,7 +38,7 @@ export function activate(ctx: ActivateContext): void {
|
|||||||
ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID });
|
ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID });
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutDisposable = pluginContext.layouts?.register({
|
const layoutDisposable = ctx.layouts?.register({
|
||||||
type: LAYOUT_TYPE,
|
type: LAYOUT_TYPE,
|
||||||
component: HelloWorldLayout
|
component: HelloWorldLayout
|
||||||
});
|
});
|
||||||
@ -72,12 +53,130 @@ export function activate(ctx: ActivateContext): void {
|
|||||||
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
|
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
|
||||||
}
|
}
|
||||||
|
|
||||||
void ctx.services?.workspace.getCurrentProject().then((project) => {
|
void useWorkspaceSdk(ctx);
|
||||||
ctx.logger.info("workspace service available", {
|
}
|
||||||
projectId: project?.id ?? null,
|
|
||||||
hasProjectRoot: Boolean(project?.root)
|
async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
|
||||||
|
const workspace = ctx.services?.workspace;
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
const project = await workspace.getCurrentProject();
|
||||||
|
if (!project) {
|
||||||
|
ctx.logger.info("workspace service available without a focused project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixturePath = ".ideai/hello-plugin.txt";
|
||||||
|
await workspace.writeTextFile(fixturePath, "hello from @idea/plugin-sdk\n", project.id);
|
||||||
|
const file = await workspace.readTextFile(fixturePath, project.id);
|
||||||
|
const stat = await workspace.stat(fixturePath, project.id);
|
||||||
|
const listing = await workspace.listDirectory(".ideai", project.id);
|
||||||
|
const structure = await workspace.queryStructure({
|
||||||
|
projectId: project.id,
|
||||||
|
maxDepth: 2,
|
||||||
|
maxEntries: 100
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ctx.logger.info("workspace file round-trip complete", {
|
||||||
|
projectId: project.id,
|
||||||
|
path: file.path,
|
||||||
|
bytes: stat.len,
|
||||||
|
ideaiEntries: listing.entries.length,
|
||||||
|
conventions: structure.conventions.map((convention) => convention.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const diagnostic = await ctx.services?.tooling.diagnose({
|
||||||
|
projectId: project.id,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "echo",
|
||||||
|
executable: "echo",
|
||||||
|
versionArgs: ["hello-plugin-toolcheck"],
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
env: [{ name: "PATH", required: true }],
|
||||||
|
files: [{ path: fixturePath, kind: "file" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.logger.info("tooling diagnostic complete", {
|
||||||
|
ok: diagnostic?.ok,
|
||||||
|
echoVersion: diagnostic?.tools.find((tool) => tool.id === "echo")?.version,
|
||||||
|
messages: diagnostic?.messages
|
||||||
|
});
|
||||||
|
|
||||||
|
const configPath = ".ideai/hello-plugin.json";
|
||||||
|
await workspace.writeTextFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ enabled: true, launches: 0 }, null, 2) + "\n",
|
||||||
|
project.id
|
||||||
|
);
|
||||||
|
const configDocument = await ctx.services?.config.readDocument({
|
||||||
|
projectId: project.id,
|
||||||
|
path: configPath
|
||||||
|
});
|
||||||
|
await ctx.services?.config.updateDocument({
|
||||||
|
projectId: project.id,
|
||||||
|
path: configPath,
|
||||||
|
mode: "mergePatch",
|
||||||
|
value: { lastFormat: configDocument?.format ?? "json", launches: 1 }
|
||||||
|
});
|
||||||
|
ctx.logger.info("config document updated", {
|
||||||
|
path: configDocument?.path,
|
||||||
|
format: configDocument?.format
|
||||||
|
});
|
||||||
|
|
||||||
|
const watch = await workspace.watch(".ideai", (event) => {
|
||||||
|
ctx.logger.info("workspace watch event", {
|
||||||
|
path: event.path,
|
||||||
|
kind: event.kind,
|
||||||
|
operation: event.operation
|
||||||
|
});
|
||||||
|
}, project.id);
|
||||||
|
ctx.subscriptions.push(watch);
|
||||||
|
|
||||||
|
const events = await ctx.services?.events.subscribe(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
eventTypes: ["backgroundTaskChanged"],
|
||||||
|
pollIntervalMs: 2000,
|
||||||
|
onDropped: (count) => ctx.logger.warn("plugin events dropped", { count })
|
||||||
|
},
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "backgroundTaskChanged") {
|
||||||
|
ctx.logger.info("background task changed", {
|
||||||
|
taskId: event.taskId,
|
||||||
|
state: event.state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (events) ctx.subscriptions.push(events);
|
||||||
|
|
||||||
|
const ownerAgentId = await ctx.storage?.get<string>("helloPlugin.ownerAgentId");
|
||||||
|
if (!ownerAgentId) {
|
||||||
|
ctx.logger.info("command task example skipped: no owner agent configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await ctx.services?.tasks.runCommand({
|
||||||
|
projectId: project.id,
|
||||||
|
ownerAgentId,
|
||||||
|
label: "Hello plugin command",
|
||||||
|
command: "echo",
|
||||||
|
args: ["hello from @idea/plugin-sdk"],
|
||||||
|
cwd: ".",
|
||||||
|
recordOnly: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
const status = await ctx.services?.tasks.getCommandStatus(task.taskId);
|
||||||
|
ctx.logger.info("command task launched", {
|
||||||
|
taskId: task.taskId,
|
||||||
|
state: status?.state ?? task.state,
|
||||||
|
exitCode: status?.exitCode ?? task.exitCode
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin: IdeAPluginModule = {
|
const plugin: IdeAPluginModule = {
|
||||||
|
|||||||
@ -14,22 +14,72 @@ export {
|
|||||||
} from "./manifest.js";
|
} from "./manifest.js";
|
||||||
export type {
|
export type {
|
||||||
ActivateContext,
|
ActivateContext,
|
||||||
|
BackgroundTaskChangedEvent,
|
||||||
CommandDisposable,
|
CommandDisposable,
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
|
CommandTaskStatus,
|
||||||
|
ConfigDocument,
|
||||||
|
ConfigDocumentFormat,
|
||||||
|
ConfigDocumentReadOptions,
|
||||||
|
ConfigDocumentService,
|
||||||
|
ConfigDocumentUpdateOptions,
|
||||||
|
ConfigDocumentWriteResult,
|
||||||
|
ConfigUpdateMode,
|
||||||
|
DiagnosticMessage,
|
||||||
|
EnvDiagnostic,
|
||||||
|
EnvRequirement,
|
||||||
|
EventHandler,
|
||||||
|
EventService,
|
||||||
|
EventSubscribeOptions,
|
||||||
|
EventSubscription,
|
||||||
|
FileDiagnostic,
|
||||||
|
FileRequirement,
|
||||||
BackgroundTaskOutputAttachment,
|
BackgroundTaskOutputAttachment,
|
||||||
BackgroundTaskRetryResult,
|
BackgroundTaskRetryResult,
|
||||||
BackgroundTaskService,
|
BackgroundTaskService,
|
||||||
BackgroundTaskStatus,
|
BackgroundTaskStatus,
|
||||||
IdeAPluginModule,
|
IdeAPluginModule,
|
||||||
|
JsonValue,
|
||||||
|
LayoutRegistry,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
|
PluginLayoutAvailability,
|
||||||
|
PluginLayoutComponent,
|
||||||
|
PluginLayoutDefinition,
|
||||||
|
PluginLayoutProps,
|
||||||
|
PluginLayoutRenderResult,
|
||||||
|
PluginLayoutState,
|
||||||
PluginServices,
|
PluginServices,
|
||||||
PluginStorage,
|
PluginStorage,
|
||||||
|
ProjectConvention,
|
||||||
|
ProjectModule,
|
||||||
|
ProjectStructure,
|
||||||
|
ProjectStructureEntry,
|
||||||
|
ProjectStructureEntryKind,
|
||||||
|
PublicEvent,
|
||||||
|
PublicEventType,
|
||||||
|
RunCommandTaskOptions,
|
||||||
TerminalOpenOptions,
|
TerminalOpenOptions,
|
||||||
TerminalReattachOptions,
|
TerminalReattachOptions,
|
||||||
TerminalReattachResult,
|
TerminalReattachResult,
|
||||||
TerminalService,
|
TerminalService,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
|
ToolchainDiagnostic,
|
||||||
|
ToolchainDiagnosticRequest,
|
||||||
|
ToolDiagnostic,
|
||||||
|
ToolingService,
|
||||||
|
ToolRequirement,
|
||||||
|
WorkspaceBinaryFile,
|
||||||
|
WorkspaceDirEntry,
|
||||||
|
WorkspaceDirectoryListing,
|
||||||
|
WorkspaceFileChangedEvent,
|
||||||
WorkspaceProject,
|
WorkspaceProject,
|
||||||
WorkspaceService
|
WorkspaceResolvedPath,
|
||||||
|
WorkspaceService,
|
||||||
|
WorkspaceStat,
|
||||||
|
WorkspaceStructureQuery,
|
||||||
|
WorkspaceTextFile,
|
||||||
|
WorkspaceWatch,
|
||||||
|
WorkspaceWatchEvent,
|
||||||
|
WorkspaceWatchHandler
|
||||||
} from "./runtime.js";
|
} from "./runtime.js";
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export interface ActivateContext {
|
|||||||
logger: PluginLogger;
|
logger: PluginLogger;
|
||||||
subscriptions: CommandDisposable[];
|
subscriptions: CommandDisposable[];
|
||||||
commands?: CommandRegistry;
|
commands?: CommandRegistry;
|
||||||
|
layouts?: LayoutRegistry;
|
||||||
storage?: PluginStorage;
|
storage?: PluginStorage;
|
||||||
/**
|
/**
|
||||||
* Stable public service facade for plugins that need workspace, background
|
* Stable public service facade for plugins that need workspace, background
|
||||||
@ -40,9 +41,47 @@ export interface PluginStorage {
|
|||||||
delete(key: string): Promise<void>;
|
delete(key: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginLayoutState = JsonValue | undefined;
|
||||||
|
export type PluginLayoutAvailability = "available";
|
||||||
|
export type PluginLayoutRenderResult = unknown;
|
||||||
|
|
||||||
|
export interface PluginLayoutProps<TState extends PluginLayoutState = PluginLayoutState> {
|
||||||
|
/** Project currently hosting this layout cell. */
|
||||||
|
projectId: string;
|
||||||
|
/** Stable layout node id for this cell instance. */
|
||||||
|
nodeId: string;
|
||||||
|
/** Layout contribution type declared in `idea-plugin.json`. */
|
||||||
|
layoutType: string;
|
||||||
|
/** Opaque JSON-serializable state persisted by the host for this cell. */
|
||||||
|
state: TState;
|
||||||
|
/** Replaces the opaque state for this cell. Values must be JSON-serializable. */
|
||||||
|
setState(next: TState): void;
|
||||||
|
/** Present layouts are only mounted when available; fallback UI is host-owned. */
|
||||||
|
availability: PluginLayoutAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginLayoutComponent<TState extends PluginLayoutState = PluginLayoutState> = (
|
||||||
|
props: PluginLayoutProps<TState>,
|
||||||
|
) => PluginLayoutRenderResult;
|
||||||
|
|
||||||
|
export interface PluginLayoutDefinition<TState extends PluginLayoutState = PluginLayoutState> {
|
||||||
|
/** Must match a layout `type` declared in this plugin's manifest. */
|
||||||
|
type: string;
|
||||||
|
component: PluginLayoutComponent<TState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutRegistry {
|
||||||
|
register<TState extends PluginLayoutState = PluginLayoutState>(
|
||||||
|
definition: PluginLayoutDefinition<TState>,
|
||||||
|
): CommandDisposable;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginServices {
|
export interface PluginServices {
|
||||||
workspace: WorkspaceService;
|
workspace: WorkspaceService;
|
||||||
tasks: BackgroundTaskService;
|
tasks: BackgroundTaskService;
|
||||||
|
tooling: ToolingService;
|
||||||
|
events: EventService;
|
||||||
|
config: ConfigDocumentService;
|
||||||
terminal: TerminalService;
|
terminal: TerminalService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,6 +100,117 @@ export interface WorkspaceService {
|
|||||||
readProjectContext(projectId?: string): Promise<string>;
|
readProjectContext(projectId?: string): Promise<string>;
|
||||||
/** Updates IdeA's shared project context for the given or current project. */
|
/** Updates IdeA's shared project context for the given or current project. */
|
||||||
updateProjectContext(content: string, projectId?: string): Promise<void>;
|
updateProjectContext(content: string, projectId?: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Resolves and normalizes a plugin-visible path under the project root.
|
||||||
|
* Rejects absolute paths, `..`, empty segments and other paths the host
|
||||||
|
* considers outside the workspace sandbox.
|
||||||
|
*/
|
||||||
|
resolvePath(path: string, projectId?: string): Promise<WorkspaceResolvedPath>;
|
||||||
|
/** Reads a UTF-8 text file under the project root. */
|
||||||
|
readTextFile(path: string, projectId?: string): Promise<WorkspaceTextFile>;
|
||||||
|
/** Reads raw bytes from a file under the project root. */
|
||||||
|
readBinaryFile(path: string, projectId?: string): Promise<WorkspaceBinaryFile>;
|
||||||
|
/** Writes UTF-8 text under the project root using the host's controlled write path. */
|
||||||
|
writeTextFile(path: string, content: string, projectId?: string): Promise<void>;
|
||||||
|
/** Writes raw bytes under the project root using the host's controlled write path. */
|
||||||
|
writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise<void>;
|
||||||
|
/** Lists one directory under the project root. Defaults to the workspace root. */
|
||||||
|
listDirectory(path?: string, projectId?: string): Promise<WorkspaceDirectoryListing>;
|
||||||
|
/**
|
||||||
|
* Returns basic metadata. Missing paths resolve to `{ exists: false }`; invalid
|
||||||
|
* paths and permission errors reject.
|
||||||
|
*/
|
||||||
|
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
|
||||||
|
/**
|
||||||
|
* Extension point for host file watching. The MVP SDK reserves the public
|
||||||
|
* shape; hosts may reject with a clear not-implemented error until #127 lands.
|
||||||
|
*/
|
||||||
|
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
|
||||||
|
/** Queries a bounded, generic project structure read model. */
|
||||||
|
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceResolvedPath {
|
||||||
|
projectId: string;
|
||||||
|
root: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTextFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceBinaryFile {
|
||||||
|
path: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceDirEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDir: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceDirectoryListing {
|
||||||
|
path: string;
|
||||||
|
entries: WorkspaceDirEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceStat {
|
||||||
|
path: string;
|
||||||
|
exists: boolean;
|
||||||
|
isFile: boolean;
|
||||||
|
isDir: boolean;
|
||||||
|
len: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceWatchEvent {
|
||||||
|
path: string;
|
||||||
|
kind: "created" | "modified" | "deleted" | "renamed" | "unknown";
|
||||||
|
operation: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceWatchHandler = (event: WorkspaceWatchEvent) => void;
|
||||||
|
|
||||||
|
export interface WorkspaceWatch {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceStructureQuery {
|
||||||
|
projectId?: string;
|
||||||
|
path?: string;
|
||||||
|
maxDepth?: number;
|
||||||
|
maxEntries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStructureEntryKind = "file" | "directory";
|
||||||
|
|
||||||
|
export interface ProjectStructureEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
kind: ProjectStructureEntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectConvention {
|
||||||
|
id: string;
|
||||||
|
markerPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectModule {
|
||||||
|
path: string;
|
||||||
|
markerPath: string;
|
||||||
|
conventionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectStructure {
|
||||||
|
projectId: string;
|
||||||
|
rootPath: string;
|
||||||
|
entries: ProjectStructureEntry[];
|
||||||
|
conventions: ProjectConvention[];
|
||||||
|
modules: ProjectModule[];
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundTaskStatus {
|
export interface BackgroundTaskStatus {
|
||||||
@ -88,7 +238,247 @@ export interface BackgroundTaskRetryResult {
|
|||||||
taskId?: string;
|
taskId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunCommandTaskOptions {
|
||||||
|
/** Project that owns the command workspace. Defaults to the focused project. */
|
||||||
|
projectId?: string;
|
||||||
|
/** Agent id used by IdeA Work for ownership, cancellation and completion delivery. */
|
||||||
|
ownerAgentId: string;
|
||||||
|
/** Human-facing label shown in Work. Defaults to the command line. */
|
||||||
|
label?: string;
|
||||||
|
/** Executable to run. Arguments are passed separately, without shell parsing. */
|
||||||
|
command: string;
|
||||||
|
/** Arguments passed to the executable. */
|
||||||
|
args?: string[];
|
||||||
|
/** Relative working directory under the project root. Defaults to the root. */
|
||||||
|
cwd?: string;
|
||||||
|
/** Extra environment variables for the command. */
|
||||||
|
env?: Record<string, string> | Array<[string, string]>;
|
||||||
|
/** When true, completion is recorded without waking the owner agent. */
|
||||||
|
recordOnly?: boolean;
|
||||||
|
/** Optional absolute deadline, epoch milliseconds. */
|
||||||
|
deadlineMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandTaskStatus {
|
||||||
|
taskId: string;
|
||||||
|
ownerAgentId: string;
|
||||||
|
projectId: string;
|
||||||
|
kind: string;
|
||||||
|
state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired";
|
||||||
|
exitCode: number | null;
|
||||||
|
summary: string | null;
|
||||||
|
stdoutTail: string | null;
|
||||||
|
stderrTail: string | null;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolRequirement {
|
||||||
|
/** Stable id chosen by the plugin for this executable prerequisite. */
|
||||||
|
id: string;
|
||||||
|
/** Executable name or path to probe. */
|
||||||
|
executable: string;
|
||||||
|
/** Version/diagnostic arguments. Defaults host-side to `--version`. */
|
||||||
|
versionArgs?: string[];
|
||||||
|
/** Whether this tool must pass for the whole diagnostic to be ok. */
|
||||||
|
required?: boolean;
|
||||||
|
/** Extra environment variables for this probe. */
|
||||||
|
env?: Record<string, string> | Array<[string, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvRequirement {
|
||||||
|
/** Environment variable name. */
|
||||||
|
name: string;
|
||||||
|
/** Whether the variable must be present and match. */
|
||||||
|
required?: boolean;
|
||||||
|
/** Optional exact expected value. */
|
||||||
|
equals?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRequirement {
|
||||||
|
/** Relative workspace path. */
|
||||||
|
path: string;
|
||||||
|
/** Whether the path must exist and match `kind`. */
|
||||||
|
required?: boolean;
|
||||||
|
/** Expected workspace path kind. */
|
||||||
|
kind?: "file" | "directory" | "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolchainDiagnosticRequest {
|
||||||
|
/** Project to inspect. Defaults to the focused project. */
|
||||||
|
projectId?: string;
|
||||||
|
/** Relative working directory under the project root. Defaults to the root. */
|
||||||
|
cwd?: string;
|
||||||
|
/** Executable probes to run. */
|
||||||
|
tools?: ToolRequirement[];
|
||||||
|
/** Environment variable prerequisites to inspect. */
|
||||||
|
env?: EnvRequirement[];
|
||||||
|
/** Workspace file prerequisites to validate. */
|
||||||
|
files?: FileRequirement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolchainDiagnostic {
|
||||||
|
projectId: string;
|
||||||
|
cwd: string;
|
||||||
|
ok: boolean;
|
||||||
|
tools: ToolDiagnostic[];
|
||||||
|
env: EnvDiagnostic[];
|
||||||
|
files: FileDiagnostic[];
|
||||||
|
messages: DiagnosticMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDiagnostic {
|
||||||
|
id: string;
|
||||||
|
executable: string;
|
||||||
|
present: boolean;
|
||||||
|
ok: boolean;
|
||||||
|
status: "ok" | "failed" | "missing";
|
||||||
|
required: boolean;
|
||||||
|
exitCode: number | null;
|
||||||
|
version: string | null;
|
||||||
|
stdout: string | null;
|
||||||
|
stderr: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvDiagnostic {
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
ok: boolean;
|
||||||
|
required: boolean;
|
||||||
|
value: string | null;
|
||||||
|
status: "ok" | "missing" | "mismatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDiagnostic {
|
||||||
|
path: string;
|
||||||
|
exists: boolean;
|
||||||
|
ok: boolean;
|
||||||
|
required: boolean;
|
||||||
|
kind: "file" | "directory" | "other" | "missing";
|
||||||
|
expectedKind: "file" | "directory" | "any" | null;
|
||||||
|
len: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticMessage {
|
||||||
|
level: "info" | "warning" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolingService {
|
||||||
|
/** Runs generic external-toolchain diagnostics for executables, env and files. */
|
||||||
|
diagnose(request: ToolchainDiagnosticRequest): Promise<ToolchainDiagnostic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicEventType = "workspaceFileChanged" | "backgroundTaskChanged";
|
||||||
|
|
||||||
|
export type PublicEvent = WorkspaceFileChangedEvent | BackgroundTaskChangedEvent;
|
||||||
|
|
||||||
|
export interface WorkspaceFileChangedEvent {
|
||||||
|
type: "workspaceFileChanged";
|
||||||
|
sequence: number;
|
||||||
|
occurredAtMs: number;
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
operation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundTaskChangedEvent {
|
||||||
|
type: "backgroundTaskChanged";
|
||||||
|
sequence: number;
|
||||||
|
occurredAtMs: number;
|
||||||
|
projectId: string;
|
||||||
|
taskId: string;
|
||||||
|
ownerAgentId: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSubscribeOptions {
|
||||||
|
/** Project to observe. Defaults to the focused project. */
|
||||||
|
projectId?: string;
|
||||||
|
/** Public event types to retain. Empty/omitted means every supported event. */
|
||||||
|
eventTypes?: PublicEventType[];
|
||||||
|
/** Per-subscription retained capacity. Host clamps to its supported bounds. */
|
||||||
|
capacity?: number;
|
||||||
|
/** Polling cadence used by the runtime facade. Defaults to 1000 ms. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
/** Maximum events drained per poll. Host clamps to its supported bounds. */
|
||||||
|
maxEventsPerPoll?: number;
|
||||||
|
/** Called when the host reports dropped retained events for this subscription. */
|
||||||
|
onDropped?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSubscription {
|
||||||
|
readonly subscriptionId: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly eventTypes: PublicEventType[];
|
||||||
|
readonly retention: string;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventHandler = (event: PublicEvent) => void;
|
||||||
|
|
||||||
|
export interface EventService {
|
||||||
|
/** Subscribes to stable, best-effort bounded public host/project events. */
|
||||||
|
subscribe(options: EventSubscribeOptions, handler: EventHandler): Promise<EventSubscription>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type ConfigDocumentFormat = "json";
|
||||||
|
export type ConfigUpdateMode = "mergePatch" | "replace";
|
||||||
|
|
||||||
|
export interface ConfigDocumentReadOptions {
|
||||||
|
/** Project that owns the config document. Defaults to the focused project. */
|
||||||
|
projectId?: string;
|
||||||
|
/** Relative path under the project root. */
|
||||||
|
path: string;
|
||||||
|
/** Explicit format. Omit to infer from extension. First lot supports only `json`. */
|
||||||
|
format?: ConfigDocumentFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDocumentUpdateOptions extends ConfigDocumentReadOptions {
|
||||||
|
/** Update mode. Defaults host-side to `mergePatch`. */
|
||||||
|
mode?: ConfigUpdateMode;
|
||||||
|
/** Replacement value or JSON merge patch. */
|
||||||
|
value: JsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDocument<T extends JsonValue = JsonValue> {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
format: ConfigDocumentFormat;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDocumentWriteResult {
|
||||||
|
projectId: string;
|
||||||
|
path: string;
|
||||||
|
format: ConfigDocumentFormat;
|
||||||
|
mode: ConfigUpdateMode;
|
||||||
|
bytesWritten: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDocumentService {
|
||||||
|
/** Reads and parses a structured config document. First lot supports JSON only. */
|
||||||
|
readDocument<T extends JsonValue = JsonValue>(
|
||||||
|
options: ConfigDocumentReadOptions,
|
||||||
|
): Promise<ConfigDocument<T>>;
|
||||||
|
/** Writes a full replacement or JSON merge patch. First lot supports JSON only. */
|
||||||
|
updateDocument(options: ConfigDocumentUpdateOptions): Promise<ConfigDocumentWriteResult>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundTaskService {
|
export interface BackgroundTaskService {
|
||||||
|
/** Launches a non-interactive command as a first-class IdeA background task. */
|
||||||
|
runCommand(options: RunCommandTaskOptions): Promise<CommandTaskStatus>;
|
||||||
|
/** Reads one command task directly from the host task store. */
|
||||||
|
getCommandStatus(taskId: string): Promise<CommandTaskStatus | null>;
|
||||||
/** Lists background tasks visible in the project work-state read model. */
|
/** Lists background tasks visible in the project work-state read model. */
|
||||||
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
|
list(projectId?: string): Promise<BackgroundTaskStatus[]>;
|
||||||
/** Reads one task status from the project work-state read model. */
|
/** Reads one task status from the project work-state read model. */
|
||||||
|
|||||||
Reference in New Issue
Block a user