From dce61ae1aa785855b033da27936ba3a76344721c Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 13:34:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(sdk,plugins):=20API=20publique=20d'acc?= =?UTF-8?q?=C3=A8s=20fichiers/workspace=20+=20analyse=20structure=20(#124,?= =?UTF-8?q?#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/lib.rs | 326 ++ crates/app-tauri/src/plugins.rs | 204 +- crates/application/src/background/mod.rs | 35 +- crates/application/src/lib.rs | 21 +- crates/application/src/plugin/mod.rs | 2739 ++++++++++++++++- crates/backend/src/dto.rs | 753 +++++ crates/backend/src/events.rs | 19 + crates/backend/src/lib.rs | 143 +- crates/domain/src/events.rs | 9 + crates/domain/src/ports.rs | 36 + crates/infrastructure/src/fs/mod.rs | 13 +- crates/infrastructure/src/lib.rs | 2 +- crates/infrastructure/src/process/mod.rs | 22 +- frontend/src/adapters/http/index.ts | 10 + frontend/src/adapters/http/unsupported.ts | 103 + frontend/src/adapters/index.ts | 10 + frontend/src/adapters/mock/index.ts | 481 +++ frontend/src/adapters/mock/mock.test.ts | 5 + frontend/src/adapters/pluginConfig.ts | 24 + frontend/src/adapters/pluginEvents.ts | 27 + frontend/src/adapters/pluginTask.ts | 40 + frontend/src/adapters/pluginToolchain.ts | 17 + frontend/src/adapters/pluginWorkspace.ts | 77 + sdk/IdeaSDK/README.md | 272 +- .../examples/hello-plugin/src/index.ts | 159 +- sdk/IdeaSDK/src/index.ts | 52 +- sdk/IdeaSDK/src/runtime.ts | 390 +++ 27 files changed, 5895 insertions(+), 94 deletions(-) create mode 100644 frontend/src/adapters/pluginConfig.ts create mode 100644 frontend/src/adapters/pluginEvents.ts create mode 100644 frontend/src/adapters/pluginTask.ts create mode 100644 frontend/src/adapters/pluginToolchain.ts create mode 100644 frontend/src/adapters/pluginWorkspace.ts diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 0787119..ca1600a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -397,6 +397,21 @@ pub fn run() { plugins::plugin_set_enabled, plugins::plugin_uninstall, 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, commands::get_server_exposure_settings, commands::save_server_exposure_settings, @@ -415,6 +430,28 @@ pub fn run() { .expect("error while running IdeA Tauri application"); } +#[cfg(test)] +fn plugin_workspace_invoke_handler( +) -> impl Fn(tauri::ipc::Invoke) -> 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( handle: &tauri::AppHandle, ) -> Result { @@ -713,6 +750,7 @@ fn persisted_monitor_is_available( #[cfg(test)] mod tests { + use super::plugin_workspace_invoke_handler; use super::{ 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, @@ -720,7 +758,10 @@ mod tests { }; use super::{should_close_with_main_window, PersistedWindowKind}; use application::AppExitWorkGuardState; + use serde_json::json; 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] 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()); } + + #[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>>( + webview: &W, + command: &str, + body: serde_json::Value, + ) -> Result { + 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::().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())) + } } diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 70bbc21..4352f2a 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -5,8 +5,16 @@ use std::path::{Path, PathBuf}; use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput}; use backend::dto::{ - ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto, - PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto, + ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto, + 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::{PluginId, RelativePath}; @@ -156,6 +164,198 @@ pub async fn plugin_list_runtime_contributions( .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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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. #[tauri::command] pub fn plugin_open_plugins_folder( diff --git a/crates/application/src/background/mod.rs b/crates/application/src/background/mod.rs index 12ba5bf..7d93132 100644 --- a/crates/application/src/background/mod.rs +++ b/crates/application/src/background/mod.rs @@ -17,11 +17,12 @@ use std::sync::Arc; use domain::ports::{ BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock, - IdGenerator, SpawnSpec, + EventBus, IdGenerator, SpawnSpec, }; use domain::{ AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, - BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ProjectId, TaskId, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, ProjectId, + TaskId, }; use crate::error::AppError; @@ -71,6 +72,7 @@ pub struct SpawnBackgroundCommand { runner: Arc, clock: Arc, ids: Arc, + events: Option>, } impl SpawnBackgroundCommand { @@ -87,9 +89,17 @@ impl SpawnBackgroundCommand { runner, clock, ids, + events: None, } } + /// Attaches the public event stream publisher. + #[must_use] + pub fn with_events(mut self, events: Arc) -> Self { + self.events = Some(events); + self + } + /// Allocates a task id, persists it (`Queued`→`Running`) and spawns it. /// /// # Errors @@ -145,6 +155,19 @@ impl SpawnBackgroundCommand { .transition(BackgroundTaskState::Running, now) .map_err(|e| AppError::Invalid(e.to_string()))?; 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 { task_id, @@ -168,6 +191,14 @@ impl SpawnBackgroundCommand { }) { 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)); } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index e1cfb67..754ddce 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -155,10 +155,23 @@ pub use permission::{ }; pub use plugin::{ InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, - ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary, - PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin, - ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, - SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult, + ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginCommandTasks, + PluginConfigDocument, PluginConfigDocumentReadInput, PluginConfigDocumentUpdateInput, + PluginConfigDocumentWriteResult, PluginConfigDocuments, PluginContributionSummary, + 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::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 7cfce80..767be89 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -1,21 +1,26 @@ //! Plugin application use cases. -use std::collections::HashSet; -use std::sync::Arc; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex}; +use domain::ports::EnvironmentReader; use domain::ports::{ - EventBus, LocalPath, PluginManifestBytes, PluginManifestError, PluginManifestValidator, + BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator, + LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, - PluginRegistryStore, PluginStoreError, + PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath, + SpawnSpec, }; use domain::{ - ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, - PluginInstallSource, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, - PluginRegistryEntry, PluginTrustLevel, RemovalOutcome, StagedPluginPackage, + AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash, + DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource, + PluginLifecycleState, PluginManifest, PluginMcpServerSpec, PluginRegistryEntry, + PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome, StagedPluginPackage, TaskId, }; use serde::{Deserialize, Serialize}; +use uuid::Uuid; -use crate::AppError; +use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput}; /// Contribution counts for admin display. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -167,6 +172,1849 @@ pub enum ReviewPluginPackageInput { }, } +/// Input used by plugin workspace file commands. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspacePathInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative path inside the project root. Empty or `.` targets the root. + pub path: String, +} + +/// Text write input for plugin workspace files. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceWriteTextInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative path inside the project root. + pub path: String, + /// UTF-8 content to write. + pub content: String, +} + +/// Binary write input for plugin workspace files. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceWriteBinaryInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative path inside the project root. + pub path: String, + /// Raw bytes to write. + pub bytes: Vec, +} + +/// Text file read result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceTextFile { + /// Normalized relative path. + pub path: String, + /// UTF-8 content. + pub content: String, +} + +/// Binary file read result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceBinaryFile { + /// Normalized relative path. + pub path: String, + /// Raw bytes. + pub bytes: Vec, +} + +/// Directory entry visible to plugins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceDirEntry { + /// Entry basename. + pub name: String, + /// Normalized relative path from project root. + pub path: String, + /// Whether the entry is a directory. + pub is_dir: bool, +} + +/// Directory listing result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceDirectoryListing { + /// Normalized relative path listed. + pub path: String, + /// Entries sorted by name for stable plugin behavior. + pub entries: Vec, +} + +/// Basic stat result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceStat { + /// Normalized relative path. + pub path: String, + /// Whether the path exists. + pub exists: bool, + /// Whether the path is a file, when known. + pub is_file: bool, + /// Whether the path is a directory, when known. + pub is_dir: bool, + /// File length in bytes when known. + pub len: Option, +} + +/// Safe resolved workspace path. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedWorkspacePath { + relative: String, + absolute: RemotePath, +} + +/// Generic workspace/file API exposed to plugins through public use cases. +pub struct PluginWorkspaceAccess { + projects: Arc, + fs: Arc, + events: Option>, +} + +impl PluginWorkspaceAccess { + /// Builds the workspace access facade. + #[must_use] + pub fn new(projects: Arc, fs: Arc) -> Self { + Self { + projects, + fs, + events: None, + } + } + + /// Attaches a public workspace-change event publisher. + #[must_use] + pub fn with_events(mut self, events: Arc) -> Self { + self.events = Some(events); + self + } + + /// Reads a UTF-8 file under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths, invalid UTF-8 or I/O failures. + pub async fn read_text( + &self, + input: PluginWorkspacePathInput, + ) -> Result { + let (_project, path) = self.resolve_input(&input).await?; + let bytes = self.fs.read(&path.absolute).await?; + let content = String::from_utf8(bytes) + .map_err(|_| AppError::Invalid(format!("file is not valid UTF-8: {}", input.path)))?; + Ok(PluginWorkspaceTextFile { + path: path.relative, + content, + }) + } + + /// Reads raw bytes under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or I/O failures. + pub async fn read_binary( + &self, + input: PluginWorkspacePathInput, + ) -> Result { + let (_project, path) = self.resolve_input(&input).await?; + let bytes = self.fs.read(&path.absolute).await?; + Ok(PluginWorkspaceBinaryFile { + path: path.relative, + bytes, + }) + } + + /// Writes UTF-8 content under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or I/O failures. + pub async fn write_text(&self, input: PluginWorkspaceWriteTextInput) -> Result<(), AppError> { + let path_input = PluginWorkspacePathInput { + project_id: input.project_id, + path: input.path, + }; + let (project, path) = self.resolve_input(&path_input).await?; + self.fs + .write(&path.absolute, input.content.as_bytes()) + .await?; + self.publish_workspace_file_changed(project.id, &path.relative); + Ok(()) + } + + /// Writes raw bytes under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or I/O failures. + pub async fn write_binary( + &self, + input: PluginWorkspaceWriteBinaryInput, + ) -> Result<(), AppError> { + let path_input = PluginWorkspacePathInput { + project_id: input.project_id, + path: input.path, + }; + let (project, path) = self.resolve_input(&path_input).await?; + self.fs.write(&path.absolute, &input.bytes).await?; + self.publish_workspace_file_changed(project.id, &path.relative); + Ok(()) + } + + /// Lists one directory under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or I/O failures. + pub async fn list_dir( + &self, + input: PluginWorkspacePathInput, + ) -> Result { + let (_project, path) = self.resolve_input(&input).await?; + let mut entries = self.fs.list(&path.absolute).await?; + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(PluginWorkspaceDirectoryListing { + path: path.relative.clone(), + entries: entries + .into_iter() + .map(|entry| dir_entry_to_workspace_entry(&path.relative, entry)) + .collect(), + }) + } + + /// Stats one path under the project root. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or permission/I/O failures. + pub async fn stat( + &self, + input: PluginWorkspacePathInput, + ) -> Result { + let (_project, path) = self.resolve_input(&input).await?; + match self.fs.metadata(&path.absolute).await { + Ok(metadata) => Ok(stat_from_metadata(path.relative, true, metadata)), + Err(domain::ports::FsError::NotFound(_)) => Ok(PluginWorkspaceStat { + path: path.relative, + exists: false, + is_file: false, + is_dir: false, + len: None, + }), + Err(err) => Err(AppError::from(err)), + } + } + + async fn resolve_input( + &self, + input: &PluginWorkspacePathInput, + ) -> Result<(Project, ResolvedWorkspacePath), AppError> { + let project_id = parse_project_id(&input.project_id)?; + let project = self.projects.load_project(project_id).await?; + let path = resolve_workspace_path(&project, &input.path)?; + Ok((project, path)) + } + + fn publish_workspace_file_changed(&self, project_id: ProjectId, path: &str) { + if let Some(events) = &self.events { + events.publish(DomainEvent::PluginWorkspaceFileChanged { + project_id, + path: path.to_owned(), + operation: "changed".to_owned(), + }); + } + } +} + +fn parse_project_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(ProjectId::from_uuid) + .map_err(|_| AppError::Invalid(format!("invalid project id: {raw}"))) +} + +fn dir_entry_to_workspace_entry(parent: &str, entry: DirEntry) -> PluginWorkspaceDirEntry { + let path = if parent.is_empty() { + entry.name.clone() + } else { + format!("{parent}/{}", entry.name) + }; + PluginWorkspaceDirEntry { + name: entry.name, + path, + is_dir: entry.is_dir, + } +} + +fn stat_from_metadata(path: String, exists: bool, metadata: FileMetadata) -> PluginWorkspaceStat { + PluginWorkspaceStat { + path, + exists, + is_file: metadata.is_file, + is_dir: metadata.is_dir, + len: metadata.len, + } +} + +fn resolve_workspace_path( + project: &Project, + raw_relative: &str, +) -> Result { + let relative = normalize_workspace_relative_path(raw_relative)?; + let root = project.root.as_str(); + let absolute = if relative.is_empty() { + root.to_owned() + } else { + let separator = if root.contains('\\') && !root.contains('/') { + "\\" + } else { + "/" + }; + format!( + "{}{}{}", + root.trim_end_matches(['/', '\\']), + separator, + relative.replace('/', separator) + ) + }; + Ok(ResolvedWorkspacePath { + relative, + absolute: RemotePath::new(absolute), + }) +} + +fn normalize_workspace_relative_path(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() || raw == "." { + return Ok(String::new()); + } + if raw.contains('\0') || raw.starts_with('/') || raw.starts_with('\\') || raw.contains(':') { + return Err(AppError::Invalid(format!( + "workspace path must be relative to the project root: {raw}" + ))); + } + let normalized = raw.replace('\\', "/"); + let mut parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." || part == ".." { + return Err(AppError::Invalid(format!( + "workspace path must not contain empty, '.', or '..' segments: {raw}" + ))); + } + parts.push(part); + } + Ok(parts.join("/")) +} + +/// Input for reading one structured configuration document. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginConfigDocumentReadInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative document path under the project root. + pub path: String, + /// Optional explicit format. When omitted, the format is inferred from the extension. + #[serde(default)] + pub format: Option, +} + +/// Input for updating one structured configuration document. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginConfigDocumentUpdateInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative document path under the project root. + pub path: String, + /// Optional explicit format. When omitted, the format is inferred from the extension. + #[serde(default)] + pub format: Option, + /// Update mode: `mergePatch` (default) or `replace`. + #[serde(default)] + pub mode: Option, + /// JSON value used as replacement or merge patch. + pub value: serde_json::Value, +} + +/// Structured configuration document visible to plugins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginConfigDocument { + /// Project id. + pub project_id: String, + /// Normalized relative document path. + pub path: String, + /// Document format. + pub format: String, + /// Parsed document value. + pub value: serde_json::Value, +} + +/// Structured configuration document write result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginConfigDocumentWriteResult { + /// Project id. + pub project_id: String, + /// Normalized relative document path. + pub path: String, + /// Document format. + pub format: String, + /// Applied update mode. + pub mode: String, + /// Number of bytes written. + pub bytes_written: usize, +} + +/// Public plugin facade for structured configuration documents. +pub struct PluginConfigDocuments { + projects: Arc, + fs: Arc, + events: Option>, +} + +impl PluginConfigDocuments { + /// Builds the facade. + #[must_use] + pub fn new(projects: Arc, fs: Arc) -> Self { + Self { + projects, + fs, + events: None, + } + } + + /// Attaches a public workspace-change event publisher. + #[must_use] + pub fn with_events(mut self, events: Arc) -> Self { + self.events = Some(events); + self + } + + /// Reads one structured configuration document. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths, unsupported formats, invalid UTF-8, + /// invalid document syntax, or I/O failures. + pub async fn read( + &self, + input: PluginConfigDocumentReadInput, + ) -> Result { + let (project, path) = self + .resolve_document(&input.project_id, &input.path) + .await?; + let format = resolve_config_format(input.format.as_deref(), &path.relative)?; + let bytes = self.fs.read(&path.absolute).await?; + let text = String::from_utf8(bytes).map_err(|_| { + AppError::Invalid(format!("document is not valid UTF-8: {}", input.path)) + })?; + let value = parse_config_document(&format, &text)?; + Ok(PluginConfigDocument { + project_id: project.id.to_string(), + path: path.relative, + format, + value, + }) + } + + /// Updates one structured configuration document. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths, unsupported formats, invalid UTF-8, + /// invalid document syntax, invalid update mode, or I/O failures. + pub async fn update( + &self, + input: PluginConfigDocumentUpdateInput, + ) -> Result { + let (project, path) = self + .resolve_document(&input.project_id, &input.path) + .await?; + let format = resolve_config_format(input.format.as_deref(), &path.relative)?; + let mode = normalize_config_update_mode(input.mode.as_deref())?; + let next = match mode.as_str() { + "replace" => input.value, + "mergePatch" => { + let bytes = self.fs.read(&path.absolute).await?; + let text = String::from_utf8(bytes).map_err(|_| { + AppError::Invalid(format!("document is not valid UTF-8: {}", input.path)) + })?; + let mut current = parse_config_document(&format, &text)?; + apply_json_merge_patch(&mut current, input.value); + current + } + _ => unreachable!("mode is normalized"), + }; + let text = serialize_config_document(&format, &next)?; + self.fs.write(&path.absolute, text.as_bytes()).await?; + self.publish_workspace_file_changed(project.id, &path.relative); + Ok(PluginConfigDocumentWriteResult { + project_id: project.id.to_string(), + path: path.relative, + format, + mode, + bytes_written: text.len(), + }) + } + + async fn resolve_document( + &self, + project_id: &str, + raw_path: &str, + ) -> Result<(Project, ResolvedWorkspacePath), AppError> { + let project_id = parse_project_id(project_id)?; + let project = self.projects.load_project(project_id).await?; + let path = resolve_workspace_path(&project, raw_path)?; + Ok((project, path)) + } + + fn publish_workspace_file_changed(&self, project_id: ProjectId, path: &str) { + if let Some(events) = &self.events { + events.publish(DomainEvent::PluginWorkspaceFileChanged { + project_id, + path: path.to_owned(), + operation: "changed".to_owned(), + }); + } + } +} + +fn resolve_config_format(raw: Option<&str>, path: &str) -> Result { + let format = match raw.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => value.to_ascii_lowercase(), + None if path.ends_with(".json") => "json".to_owned(), + None => { + return Err(AppError::Invalid(format!( + "could not infer structured config format for: {path}" + ))) + } + }; + if format == "json" { + Ok(format) + } else { + Err(AppError::Invalid(format!( + "unsupported structured config format: {format}; supported formats: json" + ))) + } +} + +fn normalize_config_update_mode(raw: Option<&str>) -> Result { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok("mergePatch".to_owned()), + Some("mergePatch") | Some("replace") => Ok(raw.unwrap().trim().to_owned()), + Some(other) => Err(AppError::Invalid(format!( + "unsupported structured config update mode: {other}; supported modes: mergePatch, replace" + ))), + } +} + +fn parse_config_document(format: &str, text: &str) -> Result { + match format { + "json" => serde_json::from_str(text) + .map_err(|err| AppError::Invalid(format!("invalid json document: {err}"))), + _ => Err(AppError::Invalid(format!( + "unsupported structured config format: {format}; supported formats: json" + ))), + } +} + +fn serialize_config_document(format: &str, value: &serde_json::Value) -> Result { + match format { + "json" => { + let mut text = serde_json::to_string_pretty(value) + .map_err(|err| AppError::Invalid(format!("invalid json value: {err}")))?; + text.push('\n'); + Ok(text) + } + _ => Err(AppError::Invalid(format!( + "unsupported structured config format: {format}; supported formats: json" + ))), + } +} + +fn apply_json_merge_patch(target: &mut serde_json::Value, patch: serde_json::Value) { + match patch { + serde_json::Value::Object(patch) => { + if !target.is_object() { + *target = serde_json::Value::Object(serde_json::Map::new()); + } + let target = target.as_object_mut().expect("target object was just set"); + for (key, value) in patch { + if value.is_null() { + target.remove(&key); + } else { + apply_json_merge_patch( + target.entry(key).or_insert(serde_json::Value::Null), + value, + ); + } + } + } + value => *target = value, + } +} + +/// Input for querying a bounded, generic project structure. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryProjectStructureInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Optional relative root to inspect. + #[serde(default)] + pub path: Option, + /// Maximum directory depth to traverse. Defaults to 3 and is capped at 8. + #[serde(default)] + pub max_depth: Option, + /// Maximum number of entries to return. Defaults to 500 and is capped at 5000. + #[serde(default)] + pub max_entries: Option, +} + +/// Bounded project structure query result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectStructureQuery { + /// Project id. + pub project_id: String, + /// Query root, relative to the project root. + pub root_path: String, + /// Returned entries. + pub entries: Vec, + /// Detected generic conventions. + pub conventions: Vec, + /// Logical modules inferred from generic marker files. + pub modules: Vec, + /// Whether traversal stopped because `maxEntries` was reached. + pub truncated: bool, +} + +/// One project structure entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectStructureEntry { + /// Relative path. + pub path: String, + /// Basename. + pub name: String, + /// Entry kind: `file` or `directory`. + pub kind: String, +} + +/// Generic convention detected from marker files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConvention { + /// Stable convention id. + pub id: String, + /// Marker path that triggered the convention. + pub marker_path: String, +} + +/// Logical project module inferred from marker files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectModule { + /// Relative module root path. + pub path: String, + /// Marker path that made this directory a module. + pub marker_path: String, + /// Convention id associated with the marker. + pub convention_id: String, +} + +/// Generic project structure query use case for plugins. +pub struct QueryProjectStructure { + projects: Arc, + fs: Arc, +} + +impl QueryProjectStructure { + /// Builds the use case. + #[must_use] + pub fn new(projects: Arc, fs: Arc) -> Self { + Self { projects, fs } + } + + /// Executes a bounded structure query. + /// + /// # Errors + /// [`AppError`] for unknown projects, invalid paths or I/O failures. + pub async fn execute( + &self, + input: QueryProjectStructureInput, + ) -> Result { + let project_id = parse_project_id(&input.project_id)?; + let project = self.projects.load_project(project_id).await?; + let root_path = input.path.unwrap_or_default(); + let root = resolve_workspace_path(&project, &root_path)?; + let max_depth = input.max_depth.unwrap_or(3).min(8); + let max_entries = input.max_entries.unwrap_or(500).min(5000); + let mut builder = StructureBuilder { + fs: self.fs.as_ref(), + entries: Vec::new(), + conventions: Vec::new(), + modules: Vec::new(), + truncated: false, + max_depth, + max_entries, + }; + builder.visit_dir(&project, &root.relative, 0).await?; + Ok(ProjectStructureQuery { + project_id: input.project_id, + root_path: root.relative, + entries: builder.entries, + conventions: builder.conventions, + modules: builder.modules, + truncated: builder.truncated, + }) + } +} + +struct StructureBuilder<'a> { + fs: &'a dyn FileSystem, + entries: Vec, + conventions: Vec, + modules: Vec, + truncated: bool, + max_depth: u8, + max_entries: usize, +} + +impl StructureBuilder<'_> { + async fn visit_dir( + &mut self, + project: &Project, + relative: &str, + depth: u8, + ) -> Result<(), AppError> { + if self.truncated || depth > self.max_depth { + return Ok(()); + } + let resolved = resolve_workspace_path(project, relative)?; + let mut children = self.fs.list(&resolved.absolute).await?; + children.sort_by(|a, b| a.name.cmp(&b.name)); + detect_module_markers( + relative, + &children, + &mut self.conventions, + &mut self.modules, + ); + for child in children { + if self.entries.len() >= self.max_entries { + self.truncated = true; + return Ok(()); + } + let child_path = if relative.is_empty() { + child.name.clone() + } else { + format!("{relative}/{}", child.name) + }; + let kind = if child.is_dir { "directory" } else { "file" }.to_owned(); + self.entries.push(ProjectStructureEntry { + path: child_path.clone(), + name: child.name, + kind, + }); + if child.is_dir && depth < self.max_depth && should_descend(&child_path) { + Box::pin(self.visit_dir(project, &child_path, depth + 1)).await?; + } + } + Ok(()) + } +} + +fn should_descend(path: &str) -> bool { + let name = path.rsplit('/').next().unwrap_or(path); + !matches!( + name, + ".git" | ".idea" | ".ideai" | "node_modules" | "target" | "dist" | "build" + ) +} + +fn detect_module_markers( + dir: &str, + children: &[DirEntry], + conventions: &mut Vec, + modules: &mut Vec, +) { + for child in children.iter().filter(|entry| !entry.is_dir) { + if let Some(convention_id) = convention_for_marker(&child.name) { + let marker_path = if dir.is_empty() { + child.name.clone() + } else { + format!("{dir}/{}", child.name) + }; + conventions.push(ProjectConvention { + id: convention_id.to_owned(), + marker_path: marker_path.clone(), + }); + modules.push(ProjectModule { + path: dir.to_owned(), + marker_path, + convention_id: convention_id.to_owned(), + }); + } + } +} + +fn convention_for_marker(name: &str) -> Option<&'static str> { + match name { + "Cargo.toml" => Some("rust-cargo"), + "package.json" => Some("node-package"), + "pyproject.toml" | "setup.py" => Some("python-project"), + "go.mod" => Some("go-module"), + "pom.xml" => Some("maven-project"), + "Makefile" | "makefile" => Some("makefile"), + ".git" => Some("git-repository"), + _ => None, + } +} + +/// Input for launching a command-backed task from the public plugin API. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRunCommandInput { + /// Project id owning the command workspace. + pub project_id: String, + /// Agent id that owns Work-panel correlation and optional 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, + /// Optional relative working directory under the project root. Defaults to root. + #[serde(default)] + pub cwd: Option, + /// 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, +} + +/// Input for reading one plugin-launched task status. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskStatusInput { + /// Task id to read. + pub task_id: String, +} + +/// Toolchain diagnostic request for the public plugin API. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginToolchainDiagnosticInput { + /// Project id owning the workspace root. + pub project_id: String, + /// Relative working directory under the project root. Defaults to root. + #[serde(default)] + pub cwd: Option, + /// Executable probes to run. + #[serde(default)] + pub tools: Vec, + /// Environment variable prerequisites to read and validate. + #[serde(default)] + pub env: Vec, + /// Workspace file prerequisites to validate. + #[serde(default)] + pub files: Vec, +} + +/// Declarative executable probe requested by a plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginToolRequirement { + /// Stable requirement id chosen by the plugin. + pub id: String, + /// Executable name or absolute path. + pub executable: String, + /// Arguments used to read a version or diagnostic. Defaults to `--version`. + #[serde(default)] + pub version_args: Vec, + /// Whether this probe must pass for the whole diagnostic to be ok. + #[serde(default)] + pub required: bool, + /// Extra environment variables for this probe. + #[serde(default)] + pub env: Vec<(String, String)>, +} + +/// Declarative environment variable prerequisite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEnvRequirement { + /// Environment variable name. + pub name: String, + /// Whether the variable must be present and match. + #[serde(default)] + pub required: bool, + /// Optional exact value requirement. + #[serde(default)] + pub equals: Option, +} + +/// Declarative workspace file prerequisite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileRequirement { + /// Relative workspace path. + pub path: String, + /// Whether the path must exist and match `kind`. + #[serde(default)] + pub required: bool, + /// Optional kind: `file`, `directory`, or `any`. + #[serde(default)] + pub kind: Option, +} + +/// Toolchain diagnostic result for the public plugin API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginToolchainDiagnostic { + /// Project id inspected. + pub project_id: String, + /// Working directory used for executable probes. + pub cwd: String, + /// Whether every required prerequisite passed. + pub ok: bool, + /// Executable probe results. + pub tools: Vec, + /// Environment prerequisite results. + pub env: Vec, + /// File prerequisite results. + pub files: Vec, + /// Human-readable diagnostics. + pub messages: Vec, +} + +/// One executable probe result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginToolDiagnostic { + /// Requirement id. + pub id: String, + /// Executable name or path. + pub executable: String, + /// Whether the tool could be started. + pub present: bool, + /// Whether the probe satisfied this requirement. + pub ok: bool, + /// Probe status: `ok`, `failed`, or `missing`. + pub status: String, + /// Whether this requirement was required. + pub required: bool, + /// Process exit code, if the process started. + pub exit_code: Option, + /// First non-empty stdout/stderr line observed. + pub version: Option, + /// Bounded stdout diagnostic. + pub stdout: Option, + /// Bounded stderr diagnostic. + pub stderr: Option, + /// Error text when the process could not start or run. + pub error: Option, +} + +/// One environment prerequisite result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEnvDiagnostic { + /// Environment variable name. + pub name: String, + /// Whether the variable was present. + pub present: bool, + /// Whether the variable satisfied this requirement. + pub ok: bool, + /// Whether this requirement was required. + pub required: bool, + /// Observed value, if present. + pub value: Option, + /// Environment status: `ok`, `missing`, or `mismatch`. + pub status: String, +} + +/// One workspace file prerequisite result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFileDiagnostic { + /// Normalized relative path. + pub path: String, + /// Whether the path exists. + pub exists: bool, + /// Whether the file prerequisite was satisfied. + pub ok: bool, + /// Whether this requirement was required. + pub required: bool, + /// Observed kind: `file`, `directory`, `other`, or `missing`. + pub kind: String, + /// Requested kind, if any. + pub expected_kind: Option, + /// File length in bytes when known. + pub len: Option, +} + +/// One human-readable diagnostic message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDiagnosticMessage { + /// Severity: `info`, `warning`, or `error`. + pub level: String, + /// Message text. + pub message: String, +} + +/// Public plugin facade for generic external toolchain diagnostics. +pub struct PluginToolchainDiagnostics { + projects: Arc, + fs: Arc, + processes: Arc, + env: Arc, +} + +impl PluginToolchainDiagnostics { + /// Builds the facade. + #[must_use] + pub fn new( + projects: Arc, + fs: Arc, + processes: Arc, + env: Arc, + ) -> Self { + Self { + projects, + fs, + processes, + env, + } + } + + /// Runs a generic diagnostic over executable, environment, and file requirements. + /// + /// # Errors + /// [`AppError`] for malformed ids, unsafe workspace paths, unknown projects, or I/O failures. + pub async fn diagnose( + &self, + input: PluginToolchainDiagnosticInput, + ) -> Result { + let project_id = parse_project_id(&input.project_id)?; + let project = self.projects.load_project(project_id).await?; + let cwd_relative = input.cwd.as_deref().unwrap_or("."); + let cwd = resolve_workspace_path(&project, cwd_relative)?; + let cwd_path = ProjectPath::new(cwd.absolute.as_str().to_owned()) + .map_err(|err| AppError::Invalid(err.to_string()))?; + let mut messages = Vec::new(); + let mut ok = true; + + let mut tools = Vec::new(); + for requirement in input.tools { + let diagnostic = self + .diagnose_tool(requirement, cwd_path.clone(), &mut messages) + .await?; + if diagnostic.required && !diagnostic.ok { + ok = false; + } + tools.push(diagnostic); + } + + let mut env = Vec::new(); + for requirement in input.env { + let diagnostic = diagnose_env(self.env.as_ref(), requirement, &mut messages)?; + if diagnostic.required && !diagnostic.ok { + ok = false; + } + env.push(diagnostic); + } + + let mut files = Vec::new(); + for requirement in input.files { + let diagnostic = self + .diagnose_file(&project, requirement, &mut messages) + .await?; + if diagnostic.required && !diagnostic.ok { + ok = false; + } + files.push(diagnostic); + } + + Ok(PluginToolchainDiagnostic { + project_id: input.project_id, + cwd: cwd.relative, + ok, + tools, + env, + files, + messages, + }) + } + + async fn diagnose_tool( + &self, + requirement: PluginToolRequirement, + cwd: ProjectPath, + messages: &mut Vec, + ) -> Result { + let id = trimmed_non_empty("tool id", &requirement.id)?; + let executable = trimmed_non_empty("tool executable", &requirement.executable)?; + let args = if requirement.version_args.is_empty() { + vec!["--version".to_owned()] + } else { + requirement.version_args + }; + let output = self + .processes + .run(SpawnSpec { + command: executable.clone(), + args, + cwd, + env: requirement.env, + context_plan: None, + sandbox: None, + }) + .await; + Ok(match output { + Ok(output) => diagnostic_from_output(id, executable, requirement.required, output), + Err(err) => { + let message = match &err { + ProcessError::Spawn(message) | ProcessError::Io(message) => message.clone(), + }; + messages.push(PluginDiagnosticMessage { + level: if requirement.required { + "error".to_owned() + } else { + "warning".to_owned() + }, + message: format!("{id}: {message}"), + }); + PluginToolDiagnostic { + id, + executable, + present: false, + ok: false, + status: "missing".to_owned(), + required: requirement.required, + exit_code: None, + version: None, + stdout: None, + stderr: None, + error: Some(message), + } + } + }) + } + + async fn diagnose_file( + &self, + project: &Project, + requirement: PluginFileRequirement, + messages: &mut Vec, + ) -> Result { + let resolved = resolve_workspace_path(project, &requirement.path)?; + let expected_kind = normalize_expected_kind(requirement.kind)?; + match self.fs.metadata(&resolved.absolute).await { + Ok(metadata) => { + let kind = metadata_kind(&metadata); + let kind_ok = expected_kind + .as_deref() + .map_or(true, |expected| expected == "any" || expected == kind); + Ok(PluginFileDiagnostic { + path: resolved.relative, + exists: true, + ok: kind_ok, + required: requirement.required, + kind: kind.to_owned(), + expected_kind, + len: metadata.len, + }) + } + Err(domain::ports::FsError::NotFound(_)) => { + if requirement.required { + messages.push(PluginDiagnosticMessage { + level: "error".to_owned(), + message: format!("missing required path: {}", resolved.relative), + }); + } + Ok(PluginFileDiagnostic { + path: resolved.relative, + exists: false, + ok: false, + required: requirement.required, + kind: "missing".to_owned(), + expected_kind, + len: None, + }) + } + Err(err) => Err(AppError::from(err)), + } + } +} + +fn diagnostic_from_output( + id: String, + executable: String, + required: bool, + output: Output, +) -> PluginToolDiagnostic { + let exit_code = output.status.code; + let stdout = bounded_utf8(output.stdout); + let stderr = bounded_utf8(output.stderr); + let version = + first_non_empty_line(stdout.as_deref()).or_else(|| first_non_empty_line(stderr.as_deref())); + let ok = exit_code == Some(0); + PluginToolDiagnostic { + id, + executable, + present: true, + ok, + status: if ok { "ok" } else { "failed" }.to_owned(), + required, + exit_code, + version, + stdout, + stderr, + error: None, + } +} + +fn diagnose_env( + reader: &dyn EnvironmentReader, + requirement: PluginEnvRequirement, + messages: &mut Vec, +) -> Result { + let name = trimmed_non_empty("environment variable name", &requirement.name)?; + let value = reader.get(&name); + let present = value.is_some(); + let matches_expected = match (&value, &requirement.equals) { + (Some(value), Some(expected)) => value == expected, + (Some(_), None) => true, + (None, _) => false, + }; + let ok = matches_expected; + let status = if ok { + "ok" + } else if present { + "mismatch" + } else { + "missing" + } + .to_owned(); + if requirement.required && !ok { + messages.push(PluginDiagnosticMessage { + level: "error".to_owned(), + message: format!("environment variable {name} is {status}"), + }); + } + Ok(PluginEnvDiagnostic { + name, + present, + ok, + required: requirement.required, + value, + status, + }) +} + +fn trimmed_non_empty(label: &str, value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::Invalid(format!("{label} must not be empty"))); + } + Ok(trimmed.to_owned()) +} + +fn normalize_expected_kind(kind: Option) -> Result, AppError> { + kind.map(|kind| { + let kind = kind.trim().to_ascii_lowercase(); + match kind.as_str() { + "file" | "directory" | "any" => Ok(kind), + _ => Err(AppError::Invalid(format!( + "file prerequisite kind must be file, directory, or any: {kind}" + ))), + } + }) + .transpose() +} + +fn metadata_kind(metadata: &FileMetadata) -> &'static str { + if metadata.is_file { + "file" + } else if metadata.is_dir { + "directory" + } else { + "other" + } +} + +fn bounded_utf8(bytes: Vec) -> Option { + let text = String::from_utf8_lossy(&bytes).trim().to_owned(); + if text.is_empty() { + return None; + } + const LIMIT: usize = 4096; + if text.len() <= LIMIT { + Some(text) + } else { + let mut end = LIMIT; + while !text.is_char_boundary(end) { + end -= 1; + } + Some(text[..end].to_owned()) + } +} + +fn first_non_empty_line(text: Option<&str>) -> Option { + text.and_then(|text| { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_owned) + }) +} + +/// Input for subscribing to public plugin events. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEventSubscribeInput { + /// Project id whose public events should be observed. + pub project_id: String, + /// Event types to keep. Empty means every supported public plugin event. + #[serde(default)] + pub event_types: Vec, + /// Per-subscription retained event capacity. Defaults to 100, capped at 1000. + #[serde(default)] + pub capacity: Option, +} + +/// Input for polling one public plugin event subscription. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEventPollInput { + /// Subscription id returned by subscribe. + pub subscription_id: String, + /// Maximum number of events to drain. Defaults to 100, capped at 1000. + #[serde(default)] + pub max_events: Option, +} + +/// Input for unsubscribing from public plugin events. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEventUnsubscribeInput { + /// Subscription id returned by subscribe. + pub subscription_id: String, +} + +/// Active public plugin event subscription. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEventSubscription { + /// Opaque subscription id. + pub subscription_id: String, + /// Project id observed by this subscription. + pub project_id: String, + /// Event types retained by this subscription. + pub event_types: Vec, + /// Per-subscription retained event capacity. + pub capacity: usize, + /// Delivery guarantee label. + pub retention: String, +} + +/// Batch drained from one public plugin event subscription. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEventBatch { + /// Subscription id. + pub subscription_id: String, + /// Drained events, oldest first. + pub events: Vec, + /// Number of older retained events dropped since the previous poll. + pub dropped: usize, +} + +/// Public event visible to plugins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PluginPublicEvent { + /// A workspace file changed through the public plugin workspace API. + #[serde(rename_all = "camelCase")] + WorkspaceFileChanged { + /// Monotonic sequence allocated by the public plugin event facade. + sequence: u64, + /// Event observation time, epoch milliseconds. + occurred_at_ms: i64, + /// Project id. + project_id: String, + /// Normalized relative workspace path. + path: String, + /// Public operation label. + operation: String, + }, + /// A background task lifecycle event occurred. + #[serde(rename_all = "camelCase")] + BackgroundTaskChanged { + /// Monotonic sequence allocated by the public plugin event facade. + sequence: u64, + /// Event observation time, epoch milliseconds. + occurred_at_ms: i64, + /// Project id. + project_id: String, + /// Task id. + task_id: String, + /// Owner agent id. + owner_agent_id: String, + /// Public task state/event label. + state: String, + }, +} + +impl PluginPublicEvent { + fn event_type(&self) -> &'static str { + match self { + Self::WorkspaceFileChanged { .. } => "workspaceFileChanged", + Self::BackgroundTaskChanged { .. } => "backgroundTaskChanged", + } + } + + fn project_id(&self) -> &str { + match self { + Self::WorkspaceFileChanged { project_id, .. } + | Self::BackgroundTaskChanged { project_id, .. } => project_id, + } + } +} + +struct PluginEventSubscriptionState { + project_id: String, + event_types: HashSet, + capacity: usize, + queue: VecDeque, + dropped: usize, +} + +/// Public plugin event subscription facade. +pub struct PluginEventSubscriptions { + projects: Arc, + ids: Arc, + clock: Arc, + subscriptions: Mutex>, + sequence: Mutex, +} + +impl PluginEventSubscriptions { + /// Builds the facade. + #[must_use] + pub fn new( + projects: Arc, + ids: Arc, + clock: Arc, + ) -> Self { + Self { + projects, + ids, + clock, + subscriptions: Mutex::new(HashMap::new()), + sequence: Mutex::new(0), + } + } + + /// Creates a disposable public event subscription. + /// + /// # Errors + /// [`AppError`] for malformed projects, unknown projects, or unsupported event types. + pub async fn subscribe( + &self, + input: PluginEventSubscribeInput, + ) -> Result { + let project_id = parse_project_id(&input.project_id)?; + self.projects.load_project(project_id).await?; + let event_types = normalize_event_types(input.event_types)?; + let capacity = input.capacity.unwrap_or(100).clamp(1, 1000); + let subscription_id = self.ids.new_uuid().to_string(); + self.subscriptions.lock().unwrap().insert( + subscription_id.clone(), + PluginEventSubscriptionState { + project_id: input.project_id.clone(), + event_types: event_types.iter().cloned().collect(), + capacity, + queue: VecDeque::new(), + dropped: 0, + }, + ); + Ok(PluginEventSubscription { + subscription_id, + project_id: input.project_id, + event_types: if event_types.is_empty() { + supported_plugin_event_types() + } else { + event_types + }, + capacity, + retention: "bestEffortBounded".to_owned(), + }) + } + + /// Drains retained events for one subscription. + /// + /// # Errors + /// [`AppError::NotFound`] when the subscription does not exist. + pub fn poll(&self, input: PluginEventPollInput) -> Result { + let max_events = input.max_events.unwrap_or(100).clamp(1, 1000); + let mut subscriptions = self.subscriptions.lock().unwrap(); + let subscription = subscriptions + .get_mut(&input.subscription_id) + .ok_or_else(|| AppError::NotFound("plugin event subscription".to_owned()))?; + let take = max_events.min(subscription.queue.len()); + let events = subscription.queue.drain(..take).collect(); + let dropped = std::mem::take(&mut subscription.dropped); + Ok(PluginEventBatch { + subscription_id: input.subscription_id, + events, + dropped, + }) + } + + /// Disposes one subscription. Unknown subscriptions are treated as already disposed. + pub fn unsubscribe(&self, input: PluginEventUnsubscribeInput) -> PluginEventSubscription { + let existed = self + .subscriptions + .lock() + .unwrap() + .remove(&input.subscription_id) + .map(|state| PluginEventSubscription { + subscription_id: input.subscription_id.clone(), + project_id: state.project_id, + event_types: if state.event_types.is_empty() { + supported_plugin_event_types() + } else { + sorted_event_types(state.event_types) + }, + capacity: state.capacity, + retention: "disposed".to_owned(), + }); + existed.unwrap_or(PluginEventSubscription { + subscription_id: input.subscription_id, + project_id: String::new(), + event_types: Vec::new(), + capacity: 0, + retention: "disposed".to_owned(), + }) + } + + /// Records a domain event after projecting it to the stable public plugin contract. + pub fn record_domain_event(&self, event: &DomainEvent) { + let Some(public) = self.public_event_from_domain(event) else { + return; + }; + let mut subscriptions = self.subscriptions.lock().unwrap(); + for subscription in subscriptions.values_mut() { + if subscription.accepts(&public) { + if subscription.queue.len() >= subscription.capacity { + subscription.queue.pop_front(); + subscription.dropped += 1; + } + subscription.queue.push_back(public.clone()); + } + } + } + + fn public_event_from_domain(&self, event: &DomainEvent) -> Option { + if !is_supported_domain_event(event) { + return None; + } + let mut sequence = self.sequence.lock().unwrap(); + *sequence += 1; + let sequence = *sequence; + let occurred_at_ms = self.clock.now_millis(); + match event { + DomainEvent::PluginWorkspaceFileChanged { + project_id, + path, + operation, + } => Some(PluginPublicEvent::WorkspaceFileChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + path: path.clone(), + operation: operation.clone(), + }), + DomainEvent::BackgroundTaskStarted { + project_id, + task_id, + owner_agent_id, + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "started".to_owned(), + }), + DomainEvent::BackgroundTaskStateChanged { + project_id, + task_id, + owner_agent_id, + state, + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: background_task_state_label(*state).to_owned(), + }), + DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id, + .. + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "completed".to_owned(), + }), + DomainEvent::BackgroundTaskFailed { + project_id, + task_id, + owner_agent_id, + .. + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "failed".to_owned(), + }), + DomainEvent::BackgroundTaskCancelled { + project_id, + task_id, + owner_agent_id, + .. + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "cancelled".to_owned(), + }), + DomainEvent::BackgroundTaskCompletionDeliveryPending { + project_id, + task_id, + owner_agent_id, + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "deliveryPending".to_owned(), + }), + DomainEvent::BackgroundTaskCompletionDelivered { + project_id, + task_id, + owner_agent_id, + } => Some(PluginPublicEvent::BackgroundTaskChanged { + sequence, + occurred_at_ms, + project_id: project_id.to_string(), + task_id: task_id.to_string(), + owner_agent_id: owner_agent_id.to_string(), + state: "delivered".to_owned(), + }), + _ => None, + } + } +} + +impl PluginEventSubscriptionState { + fn accepts(&self, event: &PluginPublicEvent) -> bool { + self.project_id == event.project_id() + && (self.event_types.is_empty() || self.event_types.contains(event.event_type())) + } +} + +fn normalize_event_types(event_types: Vec) -> Result, AppError> { + let supported: HashSet = supported_plugin_event_types().into_iter().collect(); + let mut normalized = Vec::new(); + for event_type in event_types { + let event_type = event_type.trim().to_owned(); + if event_type.is_empty() { + continue; + } + if !supported.contains(&event_type) { + return Err(AppError::Invalid(format!( + "unsupported plugin event type: {event_type}" + ))); + } + if !normalized.contains(&event_type) { + normalized.push(event_type); + } + } + Ok(normalized) +} + +fn supported_plugin_event_types() -> Vec { + vec![ + "workspaceFileChanged".to_owned(), + "backgroundTaskChanged".to_owned(), + ] +} + +fn is_supported_domain_event(event: &DomainEvent) -> bool { + matches!( + event, + DomainEvent::PluginWorkspaceFileChanged { .. } + | DomainEvent::BackgroundTaskStarted { .. } + | DomainEvent::BackgroundTaskStateChanged { .. } + | DomainEvent::BackgroundTaskCompleted { .. } + | DomainEvent::BackgroundTaskFailed { .. } + | DomainEvent::BackgroundTaskCancelled { .. } + | DomainEvent::BackgroundTaskCompletionDeliveryPending { .. } + | DomainEvent::BackgroundTaskCompletionDelivered { .. } + ) +} + +fn sorted_event_types(types: HashSet) -> Vec { + let mut types: Vec<_> = types.into_iter().collect(); + types.sort(); + types +} + +fn background_task_state_label(state: BackgroundTaskState) -> &'static str { + match state { + BackgroundTaskState::Queued => "queued", + BackgroundTaskState::Running => "running", + BackgroundTaskState::Waiting => "waiting", + BackgroundTaskState::Completed => "completed", + BackgroundTaskState::Failed => "failed", + BackgroundTaskState::Cancelled => "cancelled", + BackgroundTaskState::Expired => "expired", + } +} + +/// Public plugin facade for command-backed background tasks. +pub struct PluginCommandTasks { + projects: Arc, + tasks: Arc, + spawn: Arc, +} + +impl PluginCommandTasks { + /// Builds the facade. + #[must_use] + pub fn new( + projects: Arc, + tasks: Arc, + spawn: Arc, + ) -> Self { + Self { + projects, + tasks, + spawn, + } + } + + /// Launches a command as a first-class background task. + /// + /// # Errors + /// [`AppError`] for malformed ids, unsafe paths, unknown projects, or runner failures. + pub async fn run_command( + &self, + input: PluginRunCommandInput, + ) -> Result { + if input.command.trim().is_empty() { + return Err(AppError::Invalid("command must not be empty".to_owned())); + } + let project_id = parse_project_id(&input.project_id)?; + let owner_agent_id = parse_agent_id(&input.owner_agent_id)?; + let project = self.projects.load_project(project_id).await?; + let cwd_relative = input.cwd.as_deref().unwrap_or("."); + let cwd = resolve_workspace_path(&project, cwd_relative)?; + let cwd = ProjectPath::new(cwd.absolute.as_str().to_owned()) + .map_err(|err| AppError::Invalid(err.to_string()))?; + let command = SpawnSpec { + command: input.command, + args: input.args, + cwd, + env: input.env, + context_plan: None, + sandbox: None, + }; + let wake_policy = if input.record_only { + BackgroundTaskWakePolicy::RecordOnly + } else { + BackgroundTaskWakePolicy::WakeOwner + }; + + self.spawn + .execute(SpawnBackgroundCommandInput { + project_id, + owner_agent_id, + label: input.label, + command, + wake_policy, + rendezvous: None, + deadline_ms: input.deadline_ms, + }) + .await + .map(|out| out.task) + } + + /// Reads one task status by id. + /// + /// # Errors + /// [`AppError`] for malformed task ids or store failures. + pub async fn get_status( + &self, + input: PluginTaskStatusInput, + ) -> Result, AppError> { + let task_id = parse_task_id(&input.task_id)?; + self.tasks + .get(task_id) + .await + .map_err(map_background_task_err) + } +} + +fn parse_agent_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(AgentId::from_uuid) + .map_err(|_| AppError::Invalid(format!("invalid agent id: {raw}"))) +} + +fn parse_task_id(raw: &str) -> Result { + Uuid::parse_str(raw) + .map(TaskId::from_uuid) + .map_err(|_| AppError::Invalid(format!("invalid task id: {raw}"))) +} + +fn map_background_task_err(err: domain::ports::BackgroundTaskPortError) -> AppError { + match err { + domain::ports::BackgroundTaskPortError::NotFound => { + AppError::NotFound("background task".to_owned()) + } + domain::ports::BackgroundTaskPortError::AlreadyExists => { + AppError::Invalid("background task already exists".to_owned()) + } + domain::ports::BackgroundTaskPortError::Invalid(message) => AppError::Invalid(message), + domain::ports::BackgroundTaskPortError::Runner(message) => AppError::Process(message), + domain::ports::BackgroundTaskPortError::Store(message) => AppError::Store(message), + } +} + fn map_store(e: PluginStoreError) -> AppError { match e { PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()), @@ -1363,7 +3211,13 @@ fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> { #[cfg(test)] mod tests { use super::*; - use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError}; + use domain::ports::{ + BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError, + BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator, + PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError, + }; + use domain::remote::RemoteRef; + use domain::{BackgroundTaskState, ProjectPath}; use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; @@ -2029,4 +3883,871 @@ mod tests { assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]); assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); } + + #[derive(Default)] + struct FakeProjectStore { + projects: Mutex>, + } + + #[async_trait::async_trait] + impl ProjectStore for FakeProjectStore { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.projects.lock().unwrap().values().cloned().collect()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.projects + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.projects + .lock() + .unwrap() + .insert(project.id, project.clone()); + Ok(()) + } + + async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(domain::Workspace::default()) + } + } + + #[derive(Default)] + struct FakeWorkspaceFs { + files: Mutex>>, + } + + impl FakeWorkspaceFs { + fn seed_file(&self, path: &str, bytes: impl Into>) { + self.files + .lock() + .unwrap() + .insert(path.to_owned(), bytes.into()); + } + } + + #[derive(Default)] + struct FakeBackgroundTaskStore { + tasks: Mutex>, + } + + impl FakeBackgroundTaskStore { + fn task(&self, task_id: TaskId) -> Option { + self.tasks.lock().unwrap().get(&task_id).cloned() + } + } + + #[async_trait::async_trait] + impl BackgroundTaskStore for FakeBackgroundTaskStore { + async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + let mut tasks = self.tasks.lock().unwrap(); + if tasks.contains_key(&task.id) { + return Err(BackgroundTaskPortError::AlreadyExists); + } + tasks.insert(task.id, task.clone()); + Ok(()) + } + + async fn get(&self, id: TaskId) -> Result, BackgroundTaskPortError> { + Ok(self.task(id)) + } + + async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + self.tasks.lock().unwrap().insert(task.id, task.clone()); + Ok(()) + } + + async fn list_open_for_agent( + &self, + agent_id: AgentId, + ) -> Result, BackgroundTaskPortError> { + Ok(self + .tasks + .lock() + .unwrap() + .values() + .filter(|task| task.owner_agent_id == agent_id && !task.is_terminal()) + .cloned() + .collect()) + } + + async fn list_undelivered_completions( + &self, + ) -> Result, BackgroundTaskPortError> { + Ok(self + .tasks + .lock() + .unwrap() + .values() + .filter(|task| task.has_pending_completion_delivery()) + .cloned() + .collect()) + } + + async fn mark_completion_delivered( + &self, + task_id: TaskId, + ) -> Result<(), BackgroundTaskPortError> { + let task = self + .task(task_id) + .ok_or(BackgroundTaskPortError::NotFound)? + .mark_completion_delivered() + .map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?; + self.save(&task).await + } + } + + #[derive(Default)] + struct FakeBackgroundTaskRunner { + specs: Mutex>, + } + + impl FakeBackgroundTaskRunner { + fn specs(&self) -> Vec { + self.specs.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl BackgroundTaskRunner for FakeBackgroundTaskRunner { + async fn spawn( + &self, + spec: BackgroundTaskSpec, + ) -> Result { + let task_id = spec.task_id; + self.specs.lock().unwrap().push(spec); + Ok(BackgroundTaskHandle { task_id }) + } + + async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> { + Ok(()) + } + + fn subscribe_completions(&self) -> BackgroundCompletionStream { + Box::new(std::iter::empty()) + } + } + + struct FixedClock(i64); + + impl domain::ports::Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } + } + + struct FixedIds(Uuid); + + impl IdGenerator for FixedIds { + fn new_uuid(&self) -> Uuid { + self.0 + } + } + + #[derive(Default)] + struct FakeProcessSpawner { + outputs: Mutex>>, + specs: Mutex>, + } + + impl FakeProcessSpawner { + fn seed(&self, command: &str, output: Result) { + self.outputs + .lock() + .unwrap() + .insert(command.to_owned(), output); + } + + fn specs(&self) -> Vec { + self.specs.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl ProcessSpawner for FakeProcessSpawner { + async fn run(&self, spec: SpawnSpec) -> Result { + self.specs.lock().unwrap().push(spec.clone()); + self.outputs + .lock() + .unwrap() + .get(&spec.command) + .cloned() + .unwrap_or_else(|| Err(ProcessError::Spawn(format!("{} not found", spec.command)))) + } + } + + #[derive(Default)] + struct FakeEnvironmentReader { + values: Mutex>, + } + + impl FakeEnvironmentReader { + fn set(&self, name: &str, value: &str) { + self.values + .lock() + .unwrap() + .insert(name.to_owned(), value.to_owned()); + } + } + + impl EnvironmentReader for FakeEnvironmentReader { + fn get(&self, name: &str) -> Option { + self.values.lock().unwrap().get(name).cloned() + } + } + + #[async_trait::async_trait] + impl FileSystem for FakeWorkspaceFs { + async fn read(&self, path: &RemotePath) -> Result, domain::ports::FsError> { + self.files + .lock() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| domain::ports::FsError::NotFound(path.as_str().to_owned())) + } + + async fn write( + &self, + path: &RemotePath, + data: &[u8], + ) -> Result<(), domain::ports::FsError> { + self.files + .lock() + .unwrap() + .insert(path.as_str().to_owned(), data.to_vec()); + Ok(()) + } + + async fn exists(&self, path: &RemotePath) -> Result { + let files = self.files.lock().unwrap(); + Ok(files.contains_key(path.as_str()) + || files + .keys() + .any(|p| p.starts_with(&format!("{}/", path.as_str())))) + } + + async fn metadata( + &self, + path: &RemotePath, + ) -> Result { + let files = self.files.lock().unwrap(); + if let Some(bytes) = files.get(path.as_str()) { + return Ok(FileMetadata { + is_file: true, + is_dir: false, + len: Some(bytes.len() as u64), + }); + } + if files + .keys() + .any(|p| p.starts_with(&format!("{}/", path.as_str().trim_end_matches('/')))) + { + return Ok(FileMetadata { + is_file: false, + is_dir: true, + len: None, + }); + } + Err(domain::ports::FsError::NotFound(path.as_str().to_owned())) + } + + async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), domain::ports::FsError> { + Ok(()) + } + + async fn list(&self, path: &RemotePath) -> Result, domain::ports::FsError> { + let prefix = path.as_str().trim_end_matches('/'); + let prefix = if prefix.is_empty() { + String::new() + } else { + format!("{prefix}/") + }; + let mut seen = HashMap::::new(); + for file in self.files.lock().unwrap().keys() { + let Some(rest) = file.strip_prefix(&prefix) else { + continue; + }; + if rest.is_empty() { + continue; + } + let (name, is_dir) = match rest.split_once('/') { + Some((name, _)) => (name.to_owned(), true), + None => (rest.to_owned(), false), + }; + seen.entry(name) + .and_modify(|existing| *existing |= is_dir) + .or_insert(is_dir); + } + if seen.is_empty() && !self.exists(path).await? { + return Err(domain::ports::FsError::NotFound(path.as_str().to_owned())); + } + Ok(seen + .into_iter() + .map(|(name, is_dir)| DirEntry { name, is_dir }) + .collect()) + } + + async fn symlink( + &self, + _src: &RemotePath, + _dst: &RemotePath, + ) -> Result<(), domain::ports::FsError> { + Ok(()) + } + } + + fn workspace_fixture() -> (ProjectId, Arc, Arc) { + let project_id = ProjectId::new_random(); + let project = Project::new( + project_id, + "Example", + ProjectPath::new("/workspace/example").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); + let store = Arc::new(FakeProjectStore::default()); + store.projects.lock().unwrap().insert(project_id, project); + (project_id, store, Arc::new(FakeWorkspaceFs::default())) + } + + #[tokio::test] + async fn plugin_workspace_rejects_paths_outside_project_root() { + let (project_id, projects, fs) = workspace_fixture(); + let access = PluginWorkspaceAccess::new(projects, fs); + + let err = access + .read_text(PluginWorkspacePathInput { + project_id: project_id.to_string(), + path: "../secret.txt".to_owned(), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + } + + #[tokio::test] + async fn plugin_workspace_reads_writes_lists_and_stats_files() { + let (project_id, projects, fs) = workspace_fixture(); + let access = PluginWorkspaceAccess::new(projects, fs.clone()); + + access + .write_text(PluginWorkspaceWriteTextInput { + project_id: project_id.to_string(), + path: "src/main.rs".to_owned(), + content: "fn main() {}\n".to_owned(), + }) + .await + .unwrap(); + + let read = access + .read_text(PluginWorkspacePathInput { + project_id: project_id.to_string(), + path: "src/main.rs".to_owned(), + }) + .await + .unwrap(); + assert_eq!(read.content, "fn main() {}\n"); + + let listing = access + .list_dir(PluginWorkspacePathInput { + project_id: project_id.to_string(), + path: "src".to_owned(), + }) + .await + .unwrap(); + assert_eq!(listing.entries[0].path, "src/main.rs"); + + let stat = access + .stat(PluginWorkspacePathInput { + project_id: project_id.to_string(), + path: "src/main.rs".to_owned(), + }) + .await + .unwrap(); + assert!(stat.exists); + assert!(stat.is_file); + assert_eq!(stat.len, Some(13)); + } + + #[tokio::test] + async fn plugin_event_subscriptions_receive_workspace_changes_without_raw_event_leak() { + let (project_id, projects, fs) = workspace_fixture(); + let events = Arc::new(FakeEvents::default()); + let access = PluginWorkspaceAccess::new(projects.clone(), fs).with_events(events.clone()); + let subscriptions = PluginEventSubscriptions::new( + projects, + Arc::new(FixedIds(Uuid::from_u128(127))) as Arc, + Arc::new(FixedClock(1_700_000_000_000)) as Arc, + ); + let subscription = subscriptions + .subscribe(PluginEventSubscribeInput { + project_id: project_id.to_string(), + event_types: vec!["workspaceFileChanged".to_owned()], + capacity: Some(10), + }) + .await + .unwrap(); + + access + .write_text(PluginWorkspaceWriteTextInput { + project_id: project_id.to_string(), + path: "generated.txt".to_owned(), + content: "hello\n".to_owned(), + }) + .await + .unwrap(); + for event in events.events.lock().unwrap().iter() { + subscriptions.record_domain_event(event); + } + subscriptions.record_domain_event(&DomainEvent::PluginInstalled { + plugin_id: plugin_id(), + version: domain::PluginVersion::new("1.0.0").unwrap(), + }); + + let batch = subscriptions + .poll(PluginEventPollInput { + subscription_id: subscription.subscription_id.clone(), + max_events: Some(10), + }) + .unwrap(); + + assert_eq!(batch.dropped, 0); + assert_eq!(batch.events.len(), 1); + match &batch.events[0] { + PluginPublicEvent::WorkspaceFileChanged { + project_id: observed, + path, + operation, + occurred_at_ms, + .. + } => { + assert_eq!(observed, &project_id.to_string()); + assert_eq!(path, "generated.txt"); + assert_eq!(operation, "changed"); + assert_eq!(*occurred_at_ms, 1_700_000_000_000); + } + other => panic!("unexpected public event: {other:?}"), + } + + subscriptions.unsubscribe(PluginEventUnsubscribeInput { + subscription_id: subscription.subscription_id.clone(), + }); + let err = subscriptions + .poll(PluginEventPollInput { + subscription_id: subscription.subscription_id, + max_events: None, + }) + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); + } + + #[tokio::test] + async fn plugin_event_subscriptions_project_task_events_with_bounded_retention() { + let (project_id, projects, _fs) = workspace_fixture(); + let subscriptions = PluginEventSubscriptions::new( + projects, + Arc::new(FixedIds(Uuid::from_u128(128))) as Arc, + Arc::new(FixedClock(1_700_000_000_100)) as Arc, + ); + let subscription = subscriptions + .subscribe(PluginEventSubscribeInput { + project_id: project_id.to_string(), + event_types: vec!["backgroundTaskChanged".to_owned()], + capacity: Some(1), + }) + .await + .unwrap(); + let owner_agent_id = AgentId::from_uuid(Uuid::from_u128(88)); + let task_id = TaskId::from_uuid(Uuid::from_u128(99)); + + subscriptions.record_domain_event(&DomainEvent::BackgroundTaskStarted { + project_id, + task_id, + owner_agent_id, + }); + subscriptions.record_domain_event(&DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id, + rendezvous: None, + }); + + let batch = subscriptions + .poll(PluginEventPollInput { + subscription_id: subscription.subscription_id, + max_events: None, + }) + .unwrap(); + + assert_eq!(batch.dropped, 1); + assert_eq!(batch.events.len(), 1); + match &batch.events[0] { + PluginPublicEvent::BackgroundTaskChanged { + project_id: observed, + task_id: observed_task, + owner_agent_id: observed_owner, + state, + .. + } => { + assert_eq!(observed, &project_id.to_string()); + assert_eq!(observed_task, &task_id.to_string()); + assert_eq!(observed_owner, &owner_agent_id.to_string()); + assert_eq!(state, "completed"); + } + other => panic!("unexpected public event: {other:?}"), + } + } + + #[tokio::test] + async fn plugin_config_documents_read_and_merge_patch_json_under_project_root() { + let (project_id, projects, fs) = workspace_fixture(); + fs.seed_file( + "/workspace/example/config/settings.json", + br#"{"name":"demo","enabled":false,"removeMe":true,"nested":{"keep":1}}"#.to_vec(), + ); + let events = Arc::new(FakeEvents::default()); + let documents = + PluginConfigDocuments::new(projects, fs.clone()).with_events(events.clone()); + + let read = documents + .read(PluginConfigDocumentReadInput { + project_id: project_id.to_string(), + path: "config/settings.json".to_owned(), + format: None, + }) + .await + .unwrap(); + assert_eq!(read.format, "json"); + assert_eq!(read.value["name"], "demo"); + + let result = documents + .update(PluginConfigDocumentUpdateInput { + project_id: project_id.to_string(), + path: "config/settings.json".to_owned(), + format: None, + mode: Some("mergePatch".to_owned()), + value: serde_json::json!({ + "enabled": true, + "removeMe": null, + "nested": {"added": 2} + }), + }) + .await + .unwrap(); + assert_eq!(result.mode, "mergePatch"); + assert!(result.bytes_written > 0); + + let updated: serde_json::Value = serde_json::from_slice( + &fs.read(&RemotePath::new("/workspace/example/config/settings.json")) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(updated["enabled"], true); + assert!(updated.get("removeMe").is_none()); + assert_eq!(updated["nested"]["keep"], 1); + assert_eq!(updated["nested"]["added"], 2); + assert!(events.events.lock().unwrap().iter().any(|event| matches!( + event, + DomainEvent::PluginWorkspaceFileChanged { path, .. } + if path == "config/settings.json" + ))); + } + + #[tokio::test] + async fn plugin_config_documents_replace_json_and_reject_unsupported_formats() { + let (project_id, projects, fs) = workspace_fixture(); + fs.seed_file( + "/workspace/example/config.json", + br#"{"old":true}"#.to_vec(), + ); + let documents = PluginConfigDocuments::new(projects, fs.clone()); + + documents + .update(PluginConfigDocumentUpdateInput { + project_id: project_id.to_string(), + path: "config.json".to_owned(), + format: Some("json".to_owned()), + mode: Some("replace".to_owned()), + value: serde_json::json!({"new": true}), + }) + .await + .unwrap(); + let updated = documents + .read(PluginConfigDocumentReadInput { + project_id: project_id.to_string(), + path: "config.json".to_owned(), + format: Some("json".to_owned()), + }) + .await + .unwrap(); + assert_eq!(updated.value, serde_json::json!({"new": true})); + + let err = documents + .read(PluginConfigDocumentReadInput { + project_id: project_id.to_string(), + path: "Cargo.toml".to_owned(), + format: Some("toml".to_owned()), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); + assert!(err.to_string().contains("supported formats: json")); + } + + #[tokio::test] + async fn query_project_structure_detects_generic_markers_and_modules() { + let (project_id, projects, fs) = workspace_fixture(); + fs.seed_file("/workspace/example/Cargo.toml", b"[package]\n".to_vec()); + fs.seed_file( + "/workspace/example/crates/app/Cargo.toml", + b"[package]\n".to_vec(), + ); + fs.seed_file("/workspace/example/crates/app/src/lib.rs", b"".to_vec()); + fs.seed_file( + "/workspace/example/node_modules/skip/package.json", + b"{}".to_vec(), + ); + let query = QueryProjectStructure::new(projects, fs); + + let result = query + .execute(QueryProjectStructureInput { + project_id: project_id.to_string(), + path: None, + max_depth: Some(4), + max_entries: Some(100), + }) + .await + .unwrap(); + + assert!(result + .conventions + .iter() + .any(|c| c.id == "rust-cargo" && c.marker_path == "Cargo.toml")); + assert!(result + .modules + .iter() + .any(|m| m.path == "crates/app" && m.convention_id == "rust-cargo")); + assert!(!result + .entries + .iter() + .any(|entry| entry.path == "node_modules/skip/package.json")); + } + + #[tokio::test] + async fn plugin_toolchain_diagnostics_detects_tool_env_and_file_prerequisites() { + let (project_id, projects, fs) = workspace_fixture(); + fs.seed_file("/workspace/example/Cargo.toml", b"[package]\n".to_vec()); + let processes = Arc::new(FakeProcessSpawner::default()); + processes.seed( + "cargo", + Ok(Output { + status: domain::ports::ExitStatus { code: Some(0) }, + stdout: b"cargo 1.80.0\n".to_vec(), + stderr: Vec::new(), + }), + ); + let env = Arc::new(FakeEnvironmentReader::default()); + env.set("RUSTUP_HOME", "/rustup"); + let diagnostics = PluginToolchainDiagnostics::new(projects, fs, processes.clone(), env); + + let result = diagnostics + .diagnose(PluginToolchainDiagnosticInput { + project_id: project_id.to_string(), + cwd: Some(".".to_owned()), + tools: vec![PluginToolRequirement { + 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![PluginEnvRequirement { + name: "RUSTUP_HOME".to_owned(), + required: true, + equals: None, + }], + files: vec![PluginFileRequirement { + path: "Cargo.toml".to_owned(), + required: true, + kind: Some("file".to_owned()), + }], + }) + .await + .unwrap(); + + assert!(result.ok); + assert_eq!(result.cwd, ""); + assert_eq!(result.tools[0].version.as_deref(), Some("cargo 1.80.0")); + assert_eq!(result.env[0].value.as_deref(), Some("/rustup")); + assert_eq!(result.files[0].kind, "file"); + let spec = processes.specs().pop().expect("process probe captured"); + assert_eq!(spec.command, "cargo"); + assert_eq!(spec.cwd.as_str(), "/workspace/example"); + assert_eq!( + spec.env, + vec![("CARGO_TERM_COLOR".to_owned(), "never".to_owned())] + ); + } + + #[tokio::test] + async fn plugin_toolchain_diagnostics_reports_missing_required_tool_without_failing_usecase() { + let (project_id, projects, fs) = workspace_fixture(); + let processes = Arc::new(FakeProcessSpawner::default()); + processes.seed( + "missing-tool", + Err(ProcessError::Spawn("missing-tool: not found".to_owned())), + ); + let diagnostics = PluginToolchainDiagnostics::new( + projects, + fs, + processes, + Arc::new(FakeEnvironmentReader::default()), + ); + + let result = diagnostics + .diagnose(PluginToolchainDiagnosticInput { + project_id: project_id.to_string(), + cwd: None, + tools: vec![PluginToolRequirement { + id: "required-cli".to_owned(), + executable: "missing-tool".to_owned(), + version_args: Vec::new(), + required: true, + env: Vec::new(), + }], + env: Vec::new(), + files: Vec::new(), + }) + .await + .unwrap(); + + assert!(!result.ok); + assert_eq!(result.tools[0].status, "missing"); + assert!(!result.tools[0].present); + assert!(result + .messages + .iter() + .any(|message| message.level == "error" + && message.message.contains("missing-tool: not found"))); + } + + fn plugin_tasks_fixture() -> ( + ProjectId, + Arc, + Arc, + Arc, + PluginCommandTasks, + ) { + let (project_id, projects, _fs) = workspace_fixture(); + let tasks = Arc::new(FakeBackgroundTaskStore::default()); + let runner = Arc::new(FakeBackgroundTaskRunner::default()); + let spawn = Arc::new(SpawnBackgroundCommand::new( + Arc::clone(&tasks) as Arc, + Arc::clone(&runner) as Arc, + Arc::new(FixedClock(1_700_000_000_000)) as Arc, + Arc::new(FixedIds(Uuid::from_u128(125))) as Arc, + )); + let facade = PluginCommandTasks::new( + Arc::clone(&projects) as Arc, + Arc::clone(&tasks) as Arc, + spawn, + ); + (project_id, projects, tasks, runner, facade) + } + + #[tokio::test] + async fn plugin_command_tasks_launches_tracked_command_under_project_root() { + let (project_id, _projects, tasks, runner, facade) = plugin_tasks_fixture(); + let owner = AgentId::from_uuid(Uuid::from_u128(77)); + + let task = facade + .run_command(PluginRunCommandInput { + project_id: project_id.to_string(), + owner_agent_id: owner.to_string(), + label: "cargo test".to_owned(), + command: "cargo".to_owned(), + args: vec!["test".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), + }) + .await + .unwrap(); + + assert_eq!(task.id, TaskId::from_uuid(Uuid::from_u128(125))); + assert_eq!(task.project_id, project_id); + assert_eq!(task.owner_agent_id, owner); + assert_eq!(task.state, BackgroundTaskState::Running); + assert_eq!(task.wake_policy, BackgroundTaskWakePolicy::RecordOnly); + + let persisted = facade + .get_status(PluginTaskStatusInput { + task_id: task.id.to_string(), + }) + .await + .unwrap() + .expect("task persisted"); + assert_eq!(persisted.state, BackgroundTaskState::Running); + assert_eq!( + tasks.task(task.id).unwrap().state, + BackgroundTaskState::Running + ); + + let spec = runner.specs().pop().expect("runner invoked"); + assert_eq!(spec.task_id, task.id); + assert_eq!(spec.project_id, project_id); + assert_eq!(spec.owner_agent_id, owner); + assert_eq!(spec.wake_policy, BackgroundTaskWakePolicy::RecordOnly); + assert_eq!(spec.deadline_ms, Some(1_800_000_000_000)); + let command = spec.command.expect("command spec"); + assert_eq!(command.command, "cargo"); + assert_eq!(command.args, vec!["test"]); + assert_eq!( + command.cwd.as_str(), + "/workspace/example/crates/application" + ); + assert_eq!( + command.env, + vec![("RUST_LOG".to_owned(), "debug".to_owned())] + ); + } + + #[tokio::test] + async fn plugin_command_tasks_rejects_cwd_outside_project_root() { + let (project_id, _projects, tasks, runner, facade) = plugin_tasks_fixture(); + let owner = AgentId::from_uuid(Uuid::from_u128(77)); + + let err = facade + .run_command(PluginRunCommandInput { + project_id: project_id.to_string(), + owner_agent_id: owner.to_string(), + label: "escape".to_owned(), + command: "sh".to_owned(), + args: vec!["-c".to_owned(), "pwd".to_owned()], + cwd: Some("../outside".to_owned()), + env: Vec::new(), + record_only: false, + deadline_ms: None, + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(tasks.tasks.lock().unwrap().is_empty()); + assert!(runner.specs().is_empty()); + } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 5751677..0f47846 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -7,6 +7,7 @@ //! JSON convention already used in the domain (`agents.json` etc.). use serde::{Deserialize, Serialize}; +use serde_json::Value; use application::{ AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, @@ -273,6 +274,408 @@ impl From 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 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 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, +} + +impl From 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, +} + +impl From 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, + /// Update mode: `mergePatch` (default) or `replace`. + #[serde(default)] + pub mode: Option, + /// JSON replacement or merge patch. + pub value: Value, +} + +impl From 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, + /// Optional traversal depth. + #[serde(default)] + pub max_depth: Option, + /// Optional entry cap. + #[serde(default)] + pub max_entries: Option, +} + +impl From 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, + /// Relative working directory under project root. Empty/omitted means root. + #[serde(default)] + pub cwd: Option, + /// 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, +} + +impl From 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 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, + /// Executable probes to run. + #[serde(default)] + pub tools: Vec, + /// Environment variable prerequisites. + #[serde(default)] + pub env: Vec, + /// Workspace file prerequisites. + #[serde(default)] + pub files: Vec, +} + +impl From 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, + /// 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 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, +} + +impl From 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, +} + +impl From 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, + /// Per-subscription retained event capacity. + #[serde(default)] + pub capacity: Option, +} + +impl From 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, +} + +impl From 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 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. #[derive(Debug, Clone, Default, Deserialize)] #[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] fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() { let project_id = ProjectId::from_uuid(Uuid::from_u128(1)); diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 68864bd..1444c72 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -401,6 +401,16 @@ pub enum DomainEventDto { /// Project id. 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. #[serde(rename_all = "camelCase")] IssueCreated { @@ -1058,6 +1068,15 @@ impl From<&DomainEvent> for DomainEventDto { DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged { 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 { issue_id, issue_ref, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 9bd1d3b..4b87189 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -35,37 +35,39 @@ use application::{ MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, - ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, - ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, - RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, - ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, - ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, - SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, - UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, - UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, + PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, + ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, + SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, + SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, - EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, - ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, - PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, - SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, - ToolInvoker, WakeError, WakeReason, WindowStateStore, + EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort, + IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, + MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator, + PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, + ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, + SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, + SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -93,13 +95,14 @@ use infrastructure::{ FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, - LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, - PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, - TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, + LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, + ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, + SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, + VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, + VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -1135,6 +1138,18 @@ pub struct BackendCore { pub list_plugin_runtime_contributions: Arc, /// Reconcile external MCP plugin servers. pub reconcile_plugin_mcp_servers: Arc, + /// Public plugin workspace/file access facade. + pub plugin_workspace_access: Arc, + /// Public plugin structured config document facade. + pub plugin_config_documents: Arc, + /// Public plugin project-structure query use case. + pub query_project_structure: Arc, + /// Public plugin command/task facade. + pub plugin_command_tasks: Arc, + /// Public plugin external-toolchain diagnostic facade. + pub plugin_toolchain_diagnostics: Arc, + /// Public plugin event subscription facade. + pub plugin_event_subscriptions: Arc, /// Package store exposed for the Tauri asset protocol adapter. pub plugin_package_store: Arc, /// Registry store exposed for the Tauri asset protocol adapter. @@ -1553,6 +1568,8 @@ impl BackendCore { // registry. let spawner = Arc::new(LocalProcessSpawner::new()); let spawner_port = Arc::clone(&spawner) as Arc; + let environment_reader = Arc::new(LocalEnvironmentReader::new()); + let environment_reader_port = Arc::clone(&environment_reader) as Arc; let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port))); let runtime_port = Arc::clone(&runtime) as Arc; @@ -2358,12 +2375,15 @@ impl BackendCore { let _ = drain.await; }); } - let spawn_background_command = Arc::new(SpawnBackgroundCommand::new( - Arc::clone(&background_tasks_port), - Arc::clone(&background_runner_port), - Arc::clone(&clock) as Arc, - Arc::clone(&ids) as Arc, - )); + let spawn_background_command = Arc::new( + SpawnBackgroundCommand::new( + Arc::clone(&background_tasks_port), + Arc::clone(&background_runner_port), + Arc::clone(&clock) as Arc, + Arc::clone(&ids) as Arc, + ) + .with_events(Arc::clone(&events_port)), + ); let cancel_background_task = Arc::new(CancelBackgroundTask::new( Arc::clone(&background_tasks_port), Arc::clone(&background_runner_port), @@ -2420,6 +2440,47 @@ impl BackendCore { Arc::clone(&plugin_manifest_validator), 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, + Arc::clone(&clock) as Arc, + )); + { + 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( Arc::clone(&mediated_inbox) as Arc, Arc::clone(&input_mediator), @@ -2982,6 +3043,12 @@ impl BackendCore { uninstall_plugin, list_plugin_runtime_contributions, 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_registry_store: Arc::clone(&plugin_registry_store), plugin_manifest_validator: Arc::clone(&plugin_manifest_validator), diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index ce1a283..38fef5c 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -366,6 +366,15 @@ pub enum DomainEvent { /// The project. 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 /// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the /// frontend can surface orchestration activity; the resulting cell/tab opens diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index e10b22c..e2f8ac9 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -480,6 +480,17 @@ pub struct DirEntry { 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, +} + /// An owned, boxed stream of PTY output chunks. /// /// 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; } +/// 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; +} + /// Read a local structured CLI version using only the allowed `--version` probe. #[async_trait] pub trait CliVersionReader: Send + Sync { @@ -1526,6 +1543,25 @@ pub trait FileSystem: Send + Sync { /// [`FsError`] on failure. async fn exists(&self, path: &RemotePath) -> Result; + /// 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 { + 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 /// delete), so this is safe to call best-effort. /// diff --git a/crates/infrastructure/src/fs/mod.rs b/crates/infrastructure/src/fs/mod.rs index 2f3514d..4f7d776 100644 --- a/crates/infrastructure/src/fs/mod.rs +++ b/crates/infrastructure/src/fs/mod.rs @@ -9,7 +9,7 @@ use std::io; use std::path::Path; use async_trait::async_trait; -use domain::ports::{DirEntry, FileSystem, FsError, RemotePath}; +use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath}; use 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 { + 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> { match fs::remove_file(path.as_str()).await { Ok(()) => Ok(()), diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 98019ef..8441c14 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -89,7 +89,7 @@ pub use orchestrator::{ pub use pair_attempt_limiter::InMemoryPairAttemptLimiter; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore}; -pub use process::LocalProcessSpawner; +pub use process::{LocalEnvironmentReader, LocalProcessSpawner}; pub use pty::PortablePtyAdapter; pub use ratelimit::RateLimitParser; pub use remote::{remote_host, LocalHost}; diff --git a/crates/infrastructure/src/process/mod.rs b/crates/infrastructure/src/process/mod.rs index ec62ec2..f0f1bd4 100644 --- a/crates/infrastructure/src/process/mod.rs +++ b/crates/infrastructure/src/process/mod.rs @@ -9,7 +9,9 @@ use async_trait::async_trait; 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`. #[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 { + std::env::var(name).ok() + } +} + #[async_trait] impl ProcessSpawner for LocalProcessSpawner { async fn run(&self, spec: SpawnSpec) -> Result { diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 3d8e536..fe6be6b 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -45,7 +45,12 @@ import { import { WebDesktopServerGateway, WebFocusedProjectGateway, + WebPluginConfigGateway, + WebPluginEventGateway, WebPluginGateway, + WebPluginTaskGateway, + WebPluginToolchainGateway, + WebPluginWorkspaceGateway, WebRemoteGateway, WebWindowGateway, } from "./unsupported"; @@ -141,6 +146,11 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway // Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is. uiPreferences: new LocalStorageUiPreferencesGateway(), plugin: new WebPluginGateway(), + pluginWorkspace: new WebPluginWorkspaceGateway(), + pluginTask: new WebPluginTaskGateway(), + pluginToolchain: new WebPluginToolchainGateway(), + pluginEvents: new WebPluginEventGateway(), + pluginConfig: new WebPluginConfigGateway(), }; } diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index 4ab25f5..b747907 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -13,10 +13,21 @@ import type { EmbeddedServerStatus, GatewayError, PluginAdmin, + PluginCommandTask, + PluginConfigDocument, + PluginConfigDocumentWriteResult, + PluginEventBatch, + PluginEventSubscription, PluginInstallResult, + PluginToolchainDiagnostic, + PluginProjectStructure, PluginReview, PluginRuntimeContributionCatalog, PluginUninstallResult, + PluginWorkspaceBinaryFile, + PluginWorkspaceDirectoryListing, + PluginWorkspaceStat, + PluginWorkspaceTextFile, ServerExposurePreview, ServerExposureSettings, Unsubscribe, @@ -25,7 +36,24 @@ import type { DesktopServerGateway, FocusedProject, FocusedProjectGateway, + PluginConfigDocumentReadInput, + PluginConfigDocumentUpdateInput, + PluginConfigGateway, + PluginEventGateway, + PluginEventPollInput, + PluginEventSubscribeInput, + PluginEventUnsubscribeInput, PluginGateway, + PluginProjectStructureQuery, + PluginRunCommandInput, + PluginTaskGateway, + PluginTaskStatusInput, + PluginToolchainDiagnosticRequest, + PluginToolchainGateway, + PluginWorkspaceGateway, + PluginWorkspacePathInput, + PluginWorkspaceWriteBinaryInput, + PluginWorkspaceWriteTextInput, RemoteGateway, ReviewPluginPackageInput, ViewWindowClosed, @@ -160,3 +188,78 @@ export class WebPluginGateway implements PluginGateway { 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 { + return unsupportedOnWeb("Plugin workspace access"); + } + async readBinary(_input: PluginWorkspacePathInput): Promise { + return unsupportedOnWeb("Plugin workspace access"); + } + async writeText(_input: PluginWorkspaceWriteTextInput): Promise { + return unsupportedOnWeb("Plugin workspace access"); + } + async writeBinary(_input: PluginWorkspaceWriteBinaryInput): Promise { + return unsupportedOnWeb("Plugin workspace access"); + } + async listDir(_input: PluginWorkspacePathInput): Promise { + return unsupportedOnWeb("Plugin workspace access"); + } + async stat(_input: PluginWorkspacePathInput): Promise { + return unsupportedOnWeb("Plugin workspace access"); + } + async queryProjectStructure( + _input: PluginProjectStructureQuery, + ): Promise { + 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 { + return unsupportedOnWeb("Plugin command tasks"); + } + + async getStatus(_input: PluginTaskStatusInput): Promise { + 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 { + 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 { + return unsupportedOnWeb("Plugin public events"); + } + + async poll(_input: PluginEventPollInput): Promise { + return unsupportedOnWeb("Plugin public events"); + } + + async unsubscribe(_input: PluginEventUnsubscribeInput): Promise { + 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 { + return unsupportedOnWeb("Plugin structured config documents"); + } + + async updateDocument( + _input: PluginConfigDocumentUpdateInput, + ): Promise { + return unsupportedOnWeb("Plugin structured config documents"); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 5be61a9..6bc8e3d 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -35,6 +35,11 @@ import { TauriWindowGateway } from "./window"; import { TauriFocusedProjectGateway } from "./focusedProject"; import { LocalStorageUiPreferencesGateway } from "./uiPreferences"; 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 { const err: GatewayError = { @@ -77,6 +82,11 @@ export function createTauriGateways(): Gateways { focusedProject: new TauriFocusedProjectGateway(), uiPreferences: new LocalStorageUiPreferencesGateway(), plugin: new TauriPluginGateway(), + pluginWorkspace: new TauriPluginWorkspaceGateway(), + pluginTask: new TauriPluginTaskGateway(), + pluginToolchain: new TauriPluginToolchainGateway(), + pluginEvents: new TauriPluginEventGateway(), + pluginConfig: new TauriPluginConfigGateway(), }; } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 0df1c28..3faa9e1 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -42,13 +42,30 @@ import type { PairingCode, PermissionSet, PluginAdmin, + PluginCommandTask, + PluginConfigDocument, + PluginConfigDocumentWriteResult, PluginContributionSummary, + PluginEventBatch, + PluginEventSubscription, PluginInstallResult, PluginLifecycleState, + PluginProjectConvention, + PluginProjectModule, + PluginProjectStructure, + PluginProjectStructureEntry, PluginReview, PluginRuntimeContributionCatalog, + PluginToolchainDiagnostic, PluginUninstallResult, + PluginPublicEvent, + PluginWorkspaceBinaryFile, + PluginWorkspaceDirectoryListing, + PluginWorkspaceDirEntry, + PluginWorkspaceStat, + PluginWorkspaceTextFile, Project, + JsonValue, ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, @@ -104,7 +121,24 @@ import type { ProfileGateway, ProjectGateway, PermissionGateway, + PluginConfigDocumentReadInput, + PluginConfigDocumentUpdateInput, + PluginConfigGateway, + PluginEventGateway, + PluginEventPollInput, + PluginEventSubscribeInput, + PluginEventUnsubscribeInput, PluginGateway, + PluginProjectStructureQuery, + PluginRunCommandInput, + PluginTaskGateway, + PluginTaskStatusInput, + PluginToolchainDiagnosticRequest, + PluginToolchainGateway, + PluginWorkspaceGateway, + PluginWorkspacePathInput, + PluginWorkspaceWriteBinaryInput, + PluginWorkspaceWriteTextInput, ReattachResult, RemoteGateway, ReviewPluginPackageInput, @@ -3540,6 +3574,448 @@ export class MockPluginGateway implements PluginGateway { } } +const PROJECT_MARKERS: Record = { + "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>(); + + private bucket(projectId: string): Map { + 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 { + const file = await this.readBinary(input); + return { path: file.path, content: new TextDecoder().decode(file.bytes) }; + } + + async readBinary(input: PluginWorkspacePathInput): Promise { + 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 { + const path = normalizeWorkspacePath(input.path); + this.bucket(input.projectId).set(path, new TextEncoder().encode(input.content)); + } + + async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise { + const path = normalizeWorkspacePath(input.path); + this.bucket(input.projectId).set(path, new Uint8Array(input.bytes)); + } + + async listDir(input: PluginWorkspacePathInput): Promise { + const path = normalizeWorkspacePath(input.path); + const prefix = path === "" ? "" : `${path}/`; + const entries = new Map(); + 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 { + 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 { + 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(); + const conventions = new Map(); + const modules = new Map(); + + 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(); + private nextId = 1; + + async runCommand(input: PluginRunCommandInput): Promise { + 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 { + 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 { + 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(); + private nextId = 1; + + async subscribe(input: PluginEventSubscribeInput): Promise { + 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 { + 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 { + 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(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(); + + _seed(projectId: string, path: string, value: JsonValue): void { + this.documents.set(`${projectId}:${normalizeWorkspacePath(path)}`, cloneJson(value)); + } + + async readDocument(input: PluginConfigDocumentReadInput): Promise { + 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 { + 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. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); @@ -3569,6 +4045,11 @@ export function createMockGateways(): Gateways { focusedProject: new MockFocusedProjectGateway(), uiPreferences: new MockUiPreferencesGateway(), plugin: new MockPluginGateway(), + pluginWorkspace: new MockPluginWorkspaceGateway(), + pluginTask: new MockPluginTaskGateway(), + pluginToolchain: new MockPluginToolchainGateway(), + pluginEvents: new MockPluginEventGateway(), + pluginConfig: new MockPluginConfigGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 3d7a5a7..3e4ce4d 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -27,6 +27,11 @@ describe("createMockGateways", () => { "modelServer", "permission", "plugin", + "pluginConfig", + "pluginEvents", + "pluginTask", + "pluginToolchain", + "pluginWorkspace", "profile", "project", "remote", diff --git a/frontend/src/adapters/pluginConfig.ts b/frontend/src/adapters/pluginConfig.ts new file mode 100644 index 0000000..c4495ba --- /dev/null +++ b/frontend/src/adapters/pluginConfig.ts @@ -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 { + return invoke("plugin_config_read_document", { input }); + } + + updateDocument( + input: PluginConfigDocumentUpdateInput, + ): Promise { + return invoke("plugin_config_update_document", { input }); + } +} diff --git a/frontend/src/adapters/pluginEvents.ts b/frontend/src/adapters/pluginEvents.ts new file mode 100644 index 0000000..161408c --- /dev/null +++ b/frontend/src/adapters/pluginEvents.ts @@ -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 { + return invoke("plugin_events_subscribe", { input }); + } + + poll(input: PluginEventPollInput): Promise { + return invoke("plugin_events_poll", { input }); + } + + unsubscribe(input: PluginEventUnsubscribeInput): Promise { + return invoke("plugin_events_unsubscribe", { input }); + } +} diff --git a/frontend/src/adapters/pluginTask.ts b/frontend/src/adapters/pluginTask.ts new file mode 100644 index 0000000..97a8ba5 --- /dev/null +++ b/frontend/src/adapters/pluginTask.ts @@ -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 { + const task = await invoke("plugin_task_run_command", { input }); + return normalizeTask(task); + } + + async getStatus(input: PluginTaskStatusInput): Promise { + const task = await invoke("plugin_task_get_status", { input }); + return task ? normalizeTask(task) : null; + } +} diff --git a/frontend/src/adapters/pluginToolchain.ts b/frontend/src/adapters/pluginToolchain.ts new file mode 100644 index 0000000..7d4ef68 --- /dev/null +++ b/frontend/src/adapters/pluginToolchain.ts @@ -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 { + return invoke("plugin_toolchain_diagnose", { input }); + } +} diff --git a/frontend/src/adapters/pluginWorkspace.ts b/frontend/src/adapters/pluginWorkspace.ts new file mode 100644 index 0000000..1e043a4 --- /dev/null +++ b/frontend/src/adapters/pluginWorkspace.ts @@ -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 & { + 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 { + return invoke("plugin_workspace_read_text", { input }); + } + + async readBinary(input: PluginWorkspacePathInput): Promise { + const file = await invoke("plugin_workspace_read_binary", { input }); + return normalizeBinaryFile(file); + } + + async writeText(input: PluginWorkspaceWriteTextInput): Promise { + await invoke("plugin_workspace_write_text", { input }); + } + + async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise { + await invoke("plugin_workspace_write_binary", { input: binaryInput(input) }); + } + + listDir(input: PluginWorkspacePathInput): Promise { + return invoke("plugin_workspace_list_dir", { input }); + } + + stat(input: PluginWorkspacePathInput): Promise { + return invoke("plugin_workspace_stat", { input }); + } + + queryProjectStructure(input: PluginProjectStructureQuery): Promise { + return invoke("plugin_query_project_structure", { input }); + } +} diff --git a/sdk/IdeaSDK/README.md b/sdk/IdeaSDK/README.md index 1761e2f..40276e8 100644 --- a/sdk/IdeaSDK/README.md +++ b/sdk/IdeaSDK/README.md @@ -7,6 +7,12 @@ This first version intentionally stays small: - public manifest types for `idea-plugin.json`; - public runtime types for plugin modules exposing `activate(ctx)`; - 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 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 Plugins declaring the `tooling` capability receive `ctx.services`. Plugins @@ -91,12 +150,215 @@ export async function activate(ctx: ActivateContext): Promise { } ``` +### 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 { + 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 -PTY, writes bytes, resizes, detaches and closes. The background task service is -observation/control only in this SDK version: `list`, `getStatus`, `attachOutput`, -`cancel` and `retry` operate on existing tasks visible through IdeA's Work read -model. Starting new background tasks is not part of the public plugin API in this -lot. +PTY, writes bytes, resizes, detaches and closes. + +### Command Tasks + +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 { + 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 { + 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 { + 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 { + 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: diff --git a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts index a5b691e..e9ab78f 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts +++ b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts @@ -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 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; -function HelloWorldLayout(props: HelloPluginLayoutProps): string { +function HelloWorldLayout(props: PluginLayoutProps): string { if (!hasLoggedFirstLayoutRender) { hasLoggedFirstLayoutRender = true; console.info("[hello-plugin] layout first render", { @@ -38,14 +20,13 @@ function HelloWorldLayout(props: HelloPluginLayoutProps): string { } export function activate(ctx: ActivateContext): void { - const pluginContext = ctx as HelloPluginContext; ctx.logger.info("activating hello-plugin", { pluginId: ctx.pluginId, - hasCommands: Boolean(pluginContext.commands), - hasLayouts: Boolean(pluginContext.layouts) + hasCommands: Boolean(ctx.commands), + 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 }); return "hello-world"; }); @@ -57,7 +38,7 @@ export function activate(ctx: ActivateContext): void { ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID }); } - const layoutDisposable = pluginContext.layouts?.register({ + const layoutDisposable = ctx.layouts?.register({ type: LAYOUT_TYPE, component: HelloWorldLayout }); @@ -72,12 +53,130 @@ export function activate(ctx: ActivateContext): void { ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); } - void ctx.services?.workspace.getCurrentProject().then((project) => { - ctx.logger.info("workspace service available", { - projectId: project?.id ?? null, - hasProjectRoot: Boolean(project?.root) - }); + void useWorkspaceSdk(ctx); +} + +async function useWorkspaceSdk(ctx: ActivateContext): Promise { + 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("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 = { diff --git a/sdk/IdeaSDK/src/index.ts b/sdk/IdeaSDK/src/index.ts index 16eba6c..3defaa2 100644 --- a/sdk/IdeaSDK/src/index.ts +++ b/sdk/IdeaSDK/src/index.ts @@ -14,22 +14,72 @@ export { } from "./manifest.js"; export type { ActivateContext, + BackgroundTaskChangedEvent, CommandDisposable, CommandHandler, CommandRegistry, + CommandTaskStatus, + ConfigDocument, + ConfigDocumentFormat, + ConfigDocumentReadOptions, + ConfigDocumentService, + ConfigDocumentUpdateOptions, + ConfigDocumentWriteResult, + ConfigUpdateMode, + DiagnosticMessage, + EnvDiagnostic, + EnvRequirement, + EventHandler, + EventService, + EventSubscribeOptions, + EventSubscription, + FileDiagnostic, + FileRequirement, BackgroundTaskOutputAttachment, BackgroundTaskRetryResult, BackgroundTaskService, BackgroundTaskStatus, IdeAPluginModule, + JsonValue, + LayoutRegistry, PluginLogger, + PluginLayoutAvailability, + PluginLayoutComponent, + PluginLayoutDefinition, + PluginLayoutProps, + PluginLayoutRenderResult, + PluginLayoutState, PluginServices, PluginStorage, + ProjectConvention, + ProjectModule, + ProjectStructure, + ProjectStructureEntry, + ProjectStructureEntryKind, + PublicEvent, + PublicEventType, + RunCommandTaskOptions, TerminalOpenOptions, TerminalReattachOptions, TerminalReattachResult, TerminalService, TerminalSession, + ToolchainDiagnostic, + ToolchainDiagnosticRequest, + ToolDiagnostic, + ToolingService, + ToolRequirement, + WorkspaceBinaryFile, + WorkspaceDirEntry, + WorkspaceDirectoryListing, + WorkspaceFileChangedEvent, WorkspaceProject, - WorkspaceService + WorkspaceResolvedPath, + WorkspaceService, + WorkspaceStat, + WorkspaceStructureQuery, + WorkspaceTextFile, + WorkspaceWatch, + WorkspaceWatchEvent, + WorkspaceWatchHandler } from "./runtime.js"; diff --git a/sdk/IdeaSDK/src/runtime.ts b/sdk/IdeaSDK/src/runtime.ts index 09d98ad..f5edfa3 100644 --- a/sdk/IdeaSDK/src/runtime.ts +++ b/sdk/IdeaSDK/src/runtime.ts @@ -3,6 +3,7 @@ export interface ActivateContext { logger: PluginLogger; subscriptions: CommandDisposable[]; commands?: CommandRegistry; + layouts?: LayoutRegistry; storage?: PluginStorage; /** * Stable public service facade for plugins that need workspace, background @@ -40,9 +41,47 @@ export interface PluginStorage { delete(key: string): Promise; } +export type PluginLayoutState = JsonValue | undefined; +export type PluginLayoutAvailability = "available"; +export type PluginLayoutRenderResult = unknown; + +export interface PluginLayoutProps { + /** 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 = ( + props: PluginLayoutProps, +) => PluginLayoutRenderResult; + +export interface PluginLayoutDefinition { + /** Must match a layout `type` declared in this plugin's manifest. */ + type: string; + component: PluginLayoutComponent; +} + +export interface LayoutRegistry { + register( + definition: PluginLayoutDefinition, + ): CommandDisposable; +} + export interface PluginServices { workspace: WorkspaceService; tasks: BackgroundTaskService; + tooling: ToolingService; + events: EventService; + config: ConfigDocumentService; terminal: TerminalService; } @@ -61,6 +100,117 @@ export interface WorkspaceService { readProjectContext(projectId?: string): Promise; /** Updates IdeA's shared project context for the given or current project. */ updateProjectContext(content: string, projectId?: string): Promise; + /** + * 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; + /** Reads a UTF-8 text file under the project root. */ + readTextFile(path: string, projectId?: string): Promise; + /** Reads raw bytes from a file under the project root. */ + readBinaryFile(path: string, projectId?: string): Promise; + /** Writes UTF-8 text under the project root using the host's controlled write path. */ + writeTextFile(path: string, content: string, projectId?: string): Promise; + /** Writes raw bytes under the project root using the host's controlled write path. */ + writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise; + /** Lists one directory under the project root. Defaults to the workspace root. */ + listDirectory(path?: string, projectId?: string): Promise; + /** + * Returns basic metadata. Missing paths resolve to `{ exists: false }`; invalid + * paths and permission errors reject. + */ + stat(path: string, projectId?: string): Promise; + /** + * 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; + /** Queries a bounded, generic project structure read model. */ + queryStructure(query?: WorkspaceStructureQuery): Promise; +} + +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 { @@ -88,7 +238,247 @@ export interface BackgroundTaskRetryResult { 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 | 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 | 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; +} + +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; +} + +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 { + 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( + options: ConfigDocumentReadOptions, + ): Promise>; + /** Writes a full replacement or JSON merge patch. First lot supports JSON only. */ + updateDocument(options: ConfigDocumentUpdateOptions): Promise; +} + export interface BackgroundTaskService { + /** Launches a non-interactive command as a first-class IdeA background task. */ + runCommand(options: RunCommandTaskOptions): Promise; + /** Reads one command task directly from the host task store. */ + getCommandStatus(taskId: string): Promise; /** Lists background tasks visible in the project work-state read model. */ list(projectId?: string): Promise; /** Reads one task status from the project work-state read model. */