diff --git a/.gitignore b/.gitignore index 5a5c37c..50cc9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,7 @@ Thumbs.db .ideai/agents.json .ideai/background-tasks/ .ideai/mcp-tool-permissions.json + +# QA isolated cargo home/target (never commit) +.qa-cargo-home/ +.qa-cargo-target/ diff --git a/Cargo.lock b/Cargo.lock index d492f13..e4ce67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,12 +102,22 @@ dependencies = [ "domain", "serde", "serde_json", + "sha2", "subtle", "thiserror 2.0.18", "tokio", "uuid", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -744,6 +754,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1996,6 +2017,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "uuid", + "zip", ] [[package]] @@ -6236,8 +6258,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index ac2b2bb..075a264 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -25,9 +25,9 @@ use application::{ ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, - UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, - UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, - UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, + UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; @@ -47,28 +47,28 @@ use crate::dto::{ CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, - ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, - GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, - LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, - ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, - OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, - ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, + FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, + GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, + HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, + ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, + ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, + ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, + RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, - UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, - UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, @@ -538,6 +538,29 @@ pub async fn update_agent_permissions( .map_err(ErrorDto::from) } +/// `update_agent_effort` — set or clear one agent's per-agent effort override. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid ids or store failure. +#[tauri::command] +pub async fn update_agent_effort( + request: UpdateAgentEffortRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .update_agent_effort + .execute(UpdateAgentEffortInput { + project, + agent_id, + effort: request.effort, + }) + .await + .map(|out| AgentDto::from_agent(out.agent)) + .map_err(ErrorDto::from) +} + /// `resolve_agent_permissions` — resolve project defaults plus agent override. /// /// # Errors @@ -546,14 +569,14 @@ pub async fn update_agent_permissions( pub async fn resolve_agent_permissions( request: ResolveAgentPermissionsRequestDto, state: State<'_, AppState>, -) -> Result, ErrorDto> { +) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .resolve_agent_permissions .execute(ResolveAgentPermissionsInput { project, agent_id }) .await - .map(|out| out.effective.map(EffectivePermissionsDto)) + .map(ResolveAgentPermissionsResponseDto::from) .map_err(ErrorDto::from) } @@ -3308,12 +3331,10 @@ pub async fn create_skill( .create_skill .execute(CreateSkillInput { name: request.name, - // Description is set via the dedicated frontend field (T6); the create - // path stays None for now so the affordance falls back to the body's - // first line (see `Skill::effective_description`). - description: None, + description: request.description, content: request.content, scope: request.scope, + kind: request.kind, project_root: project.root, }) .await diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index ca1600a..72c9f3a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -257,6 +257,7 @@ pub fn run() { commands::get_project_permissions, commands::update_project_permissions, commands::update_agent_permissions, + commands::update_agent_effort, commands::resolve_agent_permissions, commands::get_project_system_permissions, commands::update_project_system_permissions, @@ -401,6 +402,9 @@ pub fn run() { plugins::plugin_workspace_read_binary, plugins::plugin_workspace_write_text, plugins::plugin_workspace_write_binary, + plugins::plugin_storage_get, + plugins::plugin_storage_set, + plugins::plugin_storage_delete, plugins::plugin_workspace_list_dir, plugins::plugin_workspace_stat, plugins::plugin_query_project_structure, @@ -438,6 +442,9 @@ fn plugin_workspace_invoke_handler( plugins::plugin_workspace_read_binary, plugins::plugin_workspace_write_text, plugins::plugin_workspace_write_binary, + plugins::plugin_storage_get, + plugins::plugin_storage_set, + plugins::plugin_storage_delete, plugins::plugin_workspace_list_dir, plugins::plugin_workspace_stat, plugins::plugin_query_project_structure, @@ -1030,6 +1037,61 @@ mod tests { std::fs::remove_dir_all(app_data).ok(); } + #[test] + fn dto_plugins_storage_commands_are_registered_in_tauri_invoke_handler() { + let app_data = test_app_data_dir("plugin-storage-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 get_err = invoke_plugin_command( + &webview, + "plugin_storage_get", + json!({ + "input": { + "pluginId": "dev.acme.missing", + "key": "helloPlugin.launches" + } + }), + ) + .expect_err("missing plugin must surface through the registered command"); + assert_eq!(get_err["code"], "NOT_FOUND"); + + let set_err = invoke_plugin_command( + &webview, + "plugin_storage_set", + json!({ + "input": { + "pluginId": "dev.acme.missing", + "key": "helloPlugin.launches", + "value": 1 + } + }), + ) + .expect_err("missing plugin must surface through the registered command"); + assert_eq!(set_err["code"], "NOT_FOUND"); + + let delete_err = invoke_plugin_command( + &webview, + "plugin_storage_delete", + json!({ + "input": { + "pluginId": "dev.acme.missing", + "key": "helloPlugin.launches" + } + }), + ) + .expect_err("missing plugin must surface through the registered command"); + assert_eq!(delete_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"); diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 096194c..c1e3f37 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -10,11 +10,11 @@ use backend::dto::{ PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto, PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto, PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto, - PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto, - PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto, - PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto, - PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto, - PluginWorkspaceWriteTextDto, ReviewPluginPackageDto, + PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto, + PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, + PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, + PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, + PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto, }; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::{PluginId, RelativePath}; @@ -216,6 +216,45 @@ pub async fn plugin_workspace_write_binary( .map_err(ErrorDto::from) } +/// Reads a plugin-owned JSON storage value. +#[tauri::command] +pub async fn plugin_storage_get( + input: PluginStorageGetDto, + state: State<'_, AppState>, +) -> Result, ErrorDto> { + state + .plugin_storage_access + .get(input.into()) + .await + .map_err(ErrorDto::from) +} + +/// Writes a plugin-owned JSON storage value. +#[tauri::command] +pub async fn plugin_storage_set( + input: PluginStorageSetDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .plugin_storage_access + .set(input.into()) + .await + .map_err(ErrorDto::from) +} + +/// Deletes a plugin-owned JSON storage value. +#[tauri::command] +pub async fn plugin_storage_delete( + input: PluginStorageGetDto, + state: State<'_, AppState>, +) -> Result { + state + .plugin_storage_access + .delete(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( diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index f71dc41..b001fd7 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -5,7 +5,7 @@ use app_tauri_lib::dto::{ parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto, - UpdateAgentContextRequestDto, + UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, }; use application::AppError; use application::{ @@ -17,7 +17,7 @@ use application::{ use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ports::ConversationDetails; use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; -use domain::{Agent, AgentOrigin, ProjectPath, SkillKind}; +use domain::{Agent, AgentOrigin, EffortSelection, ProjectPath, SkillKind}; use serde_json::json; use uuid::Uuid; @@ -60,6 +60,15 @@ fn agent_dto_serialises_camelcase() { assert!(v.get("profile_id").is_none()); } +#[test] +fn agent_dto_serialises_effort_tagged_shape_when_present() { + let agent = make_agent(1, 2).with_effort(Some(EffortSelection::Preset("medium".to_owned()))); + let dto = AgentDto::from_agent(agent); + let v = serde_json::to_value(&dto).unwrap(); + + assert_eq!(v["effort"], json!({"kind":"preset","value":"medium"})); +} + #[test] fn agent_list_dto_is_transparent_array() { let first = make_agent(1, 2); @@ -73,6 +82,7 @@ fn agent_list_dto_is_transparent_array() { kind: SkillKind::Reference, }], }], + effective_orchestrator: Some(first.id), }; let dto = AgentListDto::from(out); let v = serde_json::to_value(&dto).unwrap(); @@ -82,7 +92,9 @@ fn agent_list_dto_is_transparent_array() { assert_eq!(arr[0]["capabilities"][0]["name"], "review"); assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes"); assert_eq!(arr[0]["capabilities"][0]["kind"], "reference"); + assert_eq!(arr[0]["isOrchestrator"], true); assert_eq!(arr[1]["capabilities"], json!([])); + assert_eq!(arr[1]["isOrchestrator"], false); } #[test] @@ -139,6 +151,28 @@ fn update_agent_context_request_deserialises_camelcase() { assert_eq!(dto.content, "# Updated"); } +#[test] +fn update_agent_effort_request_deserialises_and_null_clears() { + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string(), + "effort": {"kind": "custom", "value": "x-deep"} + }); + let dto: UpdateAgentEffortRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!( + dto.effort, + Some(EffortSelection::Custom("x-deep".to_owned())) + ); + + let clear: UpdateAgentEffortRequestDto = serde_json::from_value(json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string(), + "effort": null + })) + .unwrap(); + assert_eq!(clear.effort, None); +} + #[test] fn launch_agent_request_deserialises_camelcase() { let project_id = Uuid::from_u128(1).to_string(); diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 55c9888..bac78c9 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -12,6 +12,7 @@ thiserror = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } subtle = { workspace = true } # Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the # dynamic provider catalogue (ticket #92 follow-up). See diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index dad2a93..3f83c40 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -21,8 +21,8 @@ use domain::ports::{ SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore, }; use domain::profile::{ - McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE, - CODEX_CODE_MODE_FEATURES_TOML, + resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, + CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML, }; use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use domain::{ @@ -123,8 +123,9 @@ pub struct InjectedLiveRow { pub struct ResolvedAssignedSkill { /// Agent-facing snapshot metadata. pub snapshot: domain::AssignedSkillSnapshot, - /// Full Markdown body, used only on non-MCP profiles that cannot call - /// `idea_skill_read`. + /// Full Markdown body kept in the effective snapshot so authorized lazy-read + /// paths can use the same resolution; convention-file rendering exposes only + /// bounded affordances. pub content: MarkdownDoc, } @@ -140,7 +141,9 @@ pub struct EffectiveAgentContext { pub capabilities: OrchestrationCapabilitySnapshot, /// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`]. pub agent_capabilities: Vec, - /// Resolved assigned skill bodies for fallback non-MCP injection. + /// Resolved assigned skill bodies. These are not dumped into provider context + /// by default; agents receive compact affordances and load details through the + /// active IdeA surface. pub assigned_skills: Vec, /// Project-memory recall selected for this launch. pub memory: Vec, @@ -281,6 +284,8 @@ pub struct ListAgentsOutput { pub agents: Vec, /// Resolved discoverable capabilities per agent. pub capabilities: Vec, + /// The manifest's resolved orchestrator. + pub effective_orchestrator: Option, } /// Resolved capabilities for one listed agent. @@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry { pub agent: Agent, /// Resolved capability affordances. pub capabilities: Vec, + /// Whether this agent is the project's current orchestrator. + pub is_orchestrator: bool, } impl ListAgentsOutput { @@ -318,6 +325,7 @@ impl ListAgentsOutput { .map(|entry| entry.capabilities.clone()) .unwrap_or_default(); AgentDiscoveryEntry { + is_orchestrator: self.effective_orchestrator == Some(agent.id), agent, capabilities, } @@ -390,6 +398,7 @@ impl ListAgents { Ok(ListAgentsOutput { agents, capabilities, + effective_orchestrator: manifest.effective_orchestrator(), }) } } @@ -480,6 +489,74 @@ impl UpdateAgentContext { } } +// --------------------------------------------------------------------------- +// UpdateAgentEffort +// --------------------------------------------------------------------------- + +/// Input for [`UpdateAgentEffort::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateAgentEffortInput { + /// The owning project. + pub project: Project, + /// The agent whose effort override changes. + pub agent_id: AgentId, + /// `None` clears the override, falling back to the profile default. + pub effort: Option, +} + +/// Output of [`UpdateAgentEffort::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateAgentEffortOutput { + /// The updated agent. + pub agent: Agent, +} + +/// Sets or clears an agent's per-agent effort override in the manifest. +/// +/// The change applies at the agent's next launch; live sessions are not mutated. +pub struct UpdateAgentEffort { + contexts: Arc, +} + +impl UpdateAgentEffort { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc) -> Self { + Self { contexts } + } + + /// Executes the update. + /// + /// # Errors + /// - [`AppError::NotFound`] if the agent is unknown to the project, + /// - [`AppError::Invalid`] if the resulting manifest is invalid, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: UpdateAgentEffortInput, + ) -> Result { + let mut manifest = self.contexts.load_manifest(&input.project).await?; + let entry = manifest + .entries + .iter_mut() + .find(|entry| entry.agent_id == input.agent_id) + .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + + let agent = entry + .to_agent() + .map_err(|err| AppError::Invalid(err.to_string()))? + .with_effort(input.effort); + *entry = ManifestEntry::from_agent(&agent); + + let manifest = AgentManifest::new(manifest.version, manifest.entries) + .map_err(|err| AppError::Invalid(err.to_string()))?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; + Ok(UpdateAgentEffortOutput { agent }) + } +} + // --------------------------------------------------------------------------- // ChangeAgentProfile // --------------------------------------------------------------------------- @@ -2017,6 +2094,12 @@ impl LaunchAgent { network_permission, &input.project.root, ); + let resolved_effort = resolve_effort( + profile.model_reasoning_effort.as_deref(), + agent.effort.as_ref(), + ); + let mut launch_profile = profile.clone(); + launch_profile.model_reasoning_effort = resolved_effort; // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // L'intention est explicite sur le launcher : les cellules humaines peuvent @@ -2053,7 +2136,7 @@ impl LaunchAgent { factory.as_ref(), structured, &agent, - &profile, + &launch_profile, &prepared, &run_dir, &session_plan, @@ -2083,7 +2166,7 @@ impl LaunchAgent { // CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le // lancement interactif respecte l'édition IdeA, comme le chemin structuré // le fait déjà dans `CodexExecSession`. - append_codex_pty_model_overrides(&profile, &mut spec); + append_codex_pty_model_overrides(&launch_profile, &mut spec); // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; @@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String { /// Composes the convention file IdeA writes into an agent's run directory: an /// absolute project-root header (the agent's cwd is the run dir, *not* the root, /// so it must be told where to work), the IdeA orchestration contract, the -/// agent's persona `.md`, then the bodies of its assigned `skills` under a -/// `# Skills` section (ARCHITECTURE §14.2). +/// agent's persona `.md` (ARCHITECTURE §14.2). /// /// A short skill-awareness paragraph is always injected in the orchestration /// block (followed by the auto-memory harvest directive, Lot E1): it explains that @@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String { /// protocol). This awareness deliberately does not inject unassigned skill bodies; /// assignment remains the context boundary. /// -/// On top of that awareness, the assigned skills surface in one of two ways -/// depending on the agent's **surface** (feature « skills à la MCP »), always in -/// the given (manifest) order — making the output deterministic: -/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section, -/// right after the orchestration block, listing each as -/// `**** — ()` (affordances only, *no body*), with -/// prose pointing to `idea_skill_read` to load a body on demand. Respects the -/// altitude: the capability is exposed, never the skill content. -/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full -/// under a `##` header carrying its name (unchanged — zero regression). -/// When `skills` is empty both sections are omitted entirely, so an agent with no -/// skills gets exactly the previous document. +/// On top of that awareness, assigned skills surface as a high-altitude +/// `# Skills disponibles` section right after the orchestration block, listing +/// each as `**** — ()` (affordances only, +/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only +/// the instruction for loading details is adapted to the active runtime surface. +/// When `skills` is empty the section is omitted entirely. /// /// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as /// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per @@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file( out.push_str(memory_awareness()); out.push_str("---\n\n"); - // Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après - // le bloc d'orchestration. On expose les skills assignés comme des **affordances - // nommées+décrites** (et NON leur corps complet), à la manière des outils MCP, - // pour que l'agent sache qu'ils existent et charge le détail à la demande via - // `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump - // du corps complet en fin de fichier, plus bas). Omis si zéro skill. - if mcp_enabled && !effective.agent_capabilities.is_empty() { + // Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc + // d'orchestration. On expose les skills assignés comme des affordances + // nommées+décrites (et NON leur corps complet), à la manière des outils MCP. + if !effective.agent_capabilities.is_empty() { out.push_str("# Skills disponibles\n\n"); out.push_str("Snapshot version: "); out.push_str(&effective.capabilities.version.to_string()); out.push_str("\n\n"); - out.push_str( - "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \ - détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \ - workflow déjà couvert par un skill, charge-le.\n\n", - ); + if mcp_enabled { + out.push_str( + "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \ + détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \ + workflow déjà couvert par un skill, charge-le.\n\n", + ); + } else { + out.push_str( + "Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \ + détail, lis le fichier `.ideai/skills/md/.md` correspondant au \ + `skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \ + couvert par un skill, charge-le.\n\n", + ); + } for skill in &effective.agent_capabilities { out.push_str("**"); out.push_str(&skill.name); @@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file( out.push_str(effective.persona.as_str()); - // MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve - // l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le - // corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`, - // cf. la section « # Skills disponibles » à haute altitude plus haut). - if !effective.assigned_skills.is_empty() && !mcp_enabled { - out.push_str("\n\n---\n\n# Skills\n"); - out.push_str("\nSnapshot version: "); - out.push_str(&effective.capabilities.version.to_string()); - out.push('\n'); - for skill in &effective.assigned_skills { - out.push_str("\n## "); - out.push_str(&skill.snapshot.name); - out.push_str("\n\n"); - out.push_str(skill.content.as_str()); - out.push('\n'); - } - } - if !effective.memory.is_empty() { out.push_str("\n\n---\n\n# Mémoire projet\n\n"); for entry in &effective.memory { @@ -3891,7 +3955,7 @@ mod tests { } #[test] - fn compose_convention_file_appends_assigned_skills_in_order() { + fn compose_convention_file_appends_assigned_skill_affordances_in_order() { let s = |n: u128, name: &str, body: &str| { Skill::new( domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)), @@ -3915,28 +3979,28 @@ mod tests { false, ); - // Both skill bodies present, after the persona. - assert!(doc.contains("REFAC_BODY")); - assert!(doc.contains("REVIEW_BODY")); + // Both skill affordances are present, but bodies are not dumped. + assert!(doc.contains("**refactor** — REFAC_BODY (workflow)")); + assert!(doc.contains("**review** — REVIEW_BODY (workflow)")); + assert!(!doc.contains("\n\nREFAC_BODY")); + assert!(!doc.contains("\n\nREVIEW_BODY")); let awareness_at = doc.find("**Skills IdeA**").unwrap(); + let skills_at = doc.find("# Skills disponibles").unwrap(); let persona_at = doc.find("# Persona").unwrap(); - let skills_at = doc.find("\n# Skills\n").unwrap(); - let refac_at = doc.find("REFAC_BODY").unwrap(); - let review_at = doc.find("REVIEW_BODY").unwrap(); + let refac_at = doc.find("**refactor**").unwrap(); + let review_at = doc.find("**review**").unwrap(); assert!( awareness_at < persona_at, "skill awareness belongs to orchestration, before persona" ); assert!( - persona_at < skills_at && skills_at < refac_at, - "assigned skill bodies come under the Skills section after persona" + awareness_at < skills_at && skills_at < persona_at, + "assigned skill affordances come before persona" ); - assert!(persona_at < refac_at, "skills come after the persona"); // Deterministic order: first assigned skill precedes the second. assert!(refac_at < review_at, "skills emitted in the given order"); - // Skill names surface as sub-headers. - assert!(doc.contains("## refactor")); - assert!(doc.contains("## review")); + assert!(!doc.contains("## refactor")); + assert!(!doc.contains("## review")); } #[test] @@ -4048,10 +4112,10 @@ mod tests { } #[test] - fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() { - // MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles` + fn compose_convention_file_exposes_skill_affordances_not_bodies() { + // Both surfaces: a high-altitude `# Skills disponibles` // section after the Orchestration block, listing `**name** — description` - // affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies. + // affordances, WITHOUT dumping the bodies. let s = |n: u128, name: &str, desc: Option<&str>, body: &str| { Skill::new( domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)), @@ -4062,44 +4126,47 @@ mod tests { .unwrap() .with_description(desc.map(str::to_owned)) }; - let doc = compose_convention_file( - "/root", - "", - "# Persona", - &[ - s(1, "refactor", Some("Refactors code"), "REFAC_BODY"), - // No explicit description ⇒ effective_description falls back to the - // body's first line (heading marker stripped). - s(2, "review", None, "# Review skill\n\nREVIEW_BODY"), - ], - &[], - None, - &[], - true, // mcp_enabled - ); + for mcp_enabled in [true, false] { + let doc = compose_convention_file( + "/root", + "", + "# Persona", + &[ + s(1, "refactor", Some("Refactors code"), "REFAC_BODY"), + // No explicit description ⇒ effective_description falls back to the + // body's first line (heading marker stripped). + s(2, "review", None, "# Review skill\n\nREVIEW_BODY"), + ], + &[], + None, + &[], + mcp_enabled, + ); - // The affordance section is present. - assert!( - doc.contains("# Skills disponibles"), - "MCP skills section present" - ); - assert!(doc.contains("idea_skill_read"), "points to the read tool"); - // Affordance lines: `**name** — ()`. - assert!(doc.contains("**refactor** — Refactors code (workflow)")); - assert!(doc.contains("**review** — Review skill (workflow)")); - // The full bodies are NOT injected in MCP mode (loaded on demand instead). - assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode"); - assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode"); - // The legacy `## ` body dump headers are absent too. - assert!(!doc.contains("## refactor")); + assert!( + doc.contains("# Skills disponibles"), + "skills section present (mcp_enabled={mcp_enabled})" + ); + if mcp_enabled { + assert!(doc.contains("idea_skill_read"), "points to the read tool"); + } else { + assert!( + doc.contains(".ideai/skills/md/.md"), + "points to the file-protocol read path" + ); + } + assert!(doc.contains("**refactor** — Refactors code (workflow)")); + assert!(doc.contains("**review** — Review skill (workflow)")); + assert!(!doc.contains("REFAC_BODY"), "no full body is injected"); + assert!(!doc.contains("REVIEW_BODY"), "no full body is injected"); + assert!(!doc.contains("## refactor")); - // The section sits at high altitude: after the Orchestration block, before - // the persona. - let orch_at = doc.find("# Orchestration IdeA").unwrap(); - let skills_at = doc.find("# Skills disponibles").unwrap(); - let persona_at = doc.find("# Persona").unwrap(); - assert!(orch_at < skills_at, "skills come after orchestration"); - assert!(skills_at < persona_at, "skills come before the persona"); + let orch_at = doc.find("# Orchestration IdeA").unwrap(); + let skills_at = doc.find("# Skills disponibles").unwrap(); + let persona_at = doc.find("# Persona").unwrap(); + assert!(orch_at < skills_at, "skills come after orchestration"); + assert!(skills_at < persona_at, "skills come before the persona"); + } } #[test] diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index b470ca7..77a478f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -37,8 +37,9 @@ pub use lifecycle::{ LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode, - StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, - AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, + StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, + UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, + DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, }; pub use model_catalogue::{ claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 7fcdc09..0534ced 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -26,6 +26,11 @@ pub enum AppError { #[error("invalid input: {0}")] Invalid(String), + /// An optimistic-concurrency `if_match` did not match the resource's current + /// version. Carries the current version so the caller can retry. + #[error("concurrency conflict: {0}")] + Conflict(String), + /// A filesystem operation failed. #[error("filesystem error: {0}")] FileSystem(String), @@ -107,6 +112,7 @@ impl AppError { match self { Self::NotFound(_) => "NOT_FOUND", Self::Invalid(_) => "INVALID", + Self::Conflict(_) => "CONFLICT", Self::FileSystem(_) => "FILESYSTEM", Self::Store(_) => "STORE", Self::Process(_) => "PROCESS", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 754ddce..615e36f 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -61,7 +61,8 @@ pub use agent::{ ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, + UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, + UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ @@ -162,16 +163,16 @@ pub use plugin::{ 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, + PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess, + PluginStorageGetInput, PluginStorageSetInput, 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/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index dfff2a0..f3c9dd4 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -29,8 +29,9 @@ use domain::conversation::ConversationParty; use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource}; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; -use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath}; -use domain::{AgentId, Project}; +use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath}; +use domain::{AgentId, DomainEvent, Project}; +use sha2::{Digest, Sha256}; use crate::error::AppError; @@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath { RemotePath::new(format!("{base}/{rel}")) } +pub(crate) fn hex_sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex_encode(&hasher.finalize()) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// Resolves an agent display name to its [`AgentId`] via the project manifest /// (case-insensitive), or [`AppError::NotFound`]. async fn resolve_agent( @@ -80,6 +91,14 @@ pub struct ReadContextInput { pub requester: ConversationParty, } +/// Output of [`ReadContext`]. +pub struct ReadContextOutput { + /// The context Markdown. + pub content: MarkdownDoc, + /// sha256 hex digest of `content`, only for the global project context. + pub version: Option, +} + impl ReadContext { /// Builds the use case from its ports. #[must_use] @@ -99,7 +118,7 @@ impl ReadContext { /// /// # Errors /// [`AppError`] when the agent/context does not exist or the store/fs fails. - pub async fn execute(&self, input: ReadContextInput) -> Result { + pub async fn execute(&self, input: ReadContextInput) -> Result { let ReadContextInput { project, target, @@ -115,9 +134,13 @@ impl ReadContext { .map_err(map_guard_err)?; let path = join_root(&project, PROJECT_CONTEXT_FILE); let bytes = self.fs.read(&path).await?; + let version = hex_sha256(&bytes); let text = String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?; - Ok(MarkdownDoc::new(text)) + Ok(ReadContextOutput { + content: MarkdownDoc::new(text), + version: Some(version), + }) } Some(name) => { let agent = resolve_agent(&self.contexts, &project, &name).await?; @@ -126,12 +149,114 @@ impl ReadContext { .acquire_read(requester, GuardedResource::AgentContext(agent)) .await .map_err(map_guard_err)?; - Ok(self.contexts.read_context(&project, &agent).await?) + Ok(ReadContextOutput { + content: self.contexts.read_context(&project, &agent).await?, + version: None, + }) } } } } +/// Directly updates the global project context. This is the strict, fail-loud +/// counterpart to [`ProposeContext`]'s soft-degrading global branch. +pub struct UpdateProjectContext { + guard: Arc, + contexts: Arc, + fs: Arc, + events: Arc, + clock: Arc, +} + +/// Input for [`UpdateProjectContext`]. +pub struct UpdateProjectContextInput { + /// The project to write within. + pub project: Project, + /// New global project context Markdown. + pub content: String, + /// Optional expected current version. + pub if_match: Option, + /// The writing party. + pub requester: ConversationParty, +} + +/// Output of [`UpdateProjectContext`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateProjectContextOutput { + /// sha256 hex digest of the newly written content. + pub new_version: String, +} + +impl UpdateProjectContext { + /// Builds the use case from its ports. + #[must_use] + pub fn new( + guard: Arc, + contexts: Arc, + fs: Arc, + events: Arc, + clock: Arc, + ) -> Self { + Self { + guard, + contexts, + fs, + events, + clock, + } + } + + /// Executes the direct global-context update. + /// + /// # Errors + /// - [`AppError::Invalid`] when the requester is not allowed to write directly, + /// - [`AppError::Conflict`] when `if_match` does not match current content, + /// - [`AppError`] on store/fs failure. + pub async fn execute( + &self, + input: UpdateProjectContextInput, + ) -> Result { + let UpdateProjectContextInput { + project, + content, + if_match, + requester, + } = input; + + let manifest = self.contexts.load_manifest(&project).await?; + let designation = manifest.orchestrator_designation(); + let resource = GuardedResource::ProjectContext; + if !may_write_directly(requester, &resource, &designation) { + return Err(map_guard_err(GuardError::Forbidden)); + } + + let _lease = self + .guard + .acquire_write(requester, resource) + .await + .map_err(map_guard_err)?; + + let path = join_root(&project, PROJECT_CONTEXT_FILE); + let current_bytes = self.fs.read(&path).await?; + let current_version = hex_sha256(¤t_bytes); + if let Some(expected) = if_match { + if expected != current_version { + return Err(AppError::Conflict(current_version)); + } + } + + self.fs.write(&path, content.as_bytes()).await?; + let new_version = hex_sha256(content.as_bytes()); + self.events.publish(DomainEvent::ProjectContextUpdated { + project_id: project.id, + by: requester, + at_ms: self.clock.now_millis(), + }); + + Ok(UpdateProjectContextOutput { new_version }) + } +} + /// Proposes new content for an IdeA-owned context under the [`FileGuard`]. /// /// For an **agent** context: a direct write under an exclusive write-lease. For the @@ -393,7 +518,7 @@ mod tests { use domain::agent::{AgentManifest, ManifestEntry}; use domain::conversation::ConversationParty; use domain::fileguard::{ReadLease, WriteLease}; - use domain::ports::{FsError, MemoryError, StoreError}; + use domain::ports::{EventStream, FsError, MemoryError, StoreError}; use domain::project::ProjectPath; use domain::{ProfileId, ProjectId, RemoteRef}; use std::collections::HashMap; @@ -599,6 +724,25 @@ mod tests { } } + #[derive(Default)] + struct SpyBus(Mutex>); + + impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } + } + + impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } + } + fn guard() -> Arc { Arc::new(TestGuard::default()) } @@ -619,6 +763,7 @@ mod tests { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }], }, contexts: Mutex::new(contexts), @@ -635,7 +780,7 @@ mod tests { contexts_with("Dev", agent, "# hello"), Arc::new(FakeFs::default()), ); - let md = uc + let out = uc .execute(ReadContextInput { project: project(), target: Some("dev".to_owned()), // case-insensitive @@ -643,7 +788,8 @@ mod tests { }) .await .unwrap(); - assert_eq!(md.as_str(), "# hello"); + assert_eq!(out.content.as_str(), "# hello"); + assert_eq!(out.version, None); } #[tokio::test] @@ -658,7 +804,7 @@ mod tests { contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), fs, ); - let md = uc + let out = uc .execute(ReadContextInput { project: project(), target: None, @@ -666,7 +812,8 @@ mod tests { }) .await .unwrap(); - assert_eq!(md.as_str(), "# project"); + assert_eq!(out.content.as_str(), "# project"); + assert_eq!(out.version, Some(hex_sha256(b"# project"))); } #[tokio::test] @@ -749,6 +896,262 @@ mod tests { ); } + #[tokio::test] + async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + let bus = Arc::new(SpyBus::default()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", agent, "x"), + Arc::clone(&fs) as Arc, + Arc::clone(&bus) as Arc, + Arc::new(FixedClock), + ); + + let out = uc + .execute(UpdateProjectContextInput { + project: project(), + content: "# new".to_owned(), + if_match: None, + requester: ConversationParty::agent(agent), + }) + .await + .unwrap(); + + assert_eq!(out.new_version, hex_sha256(b"# new")); + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# new" + ); + } + + #[tokio::test] + async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() { + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), + Arc::clone(&fs) as Arc, + Arc::new(SpyBus::default()), + Arc::new(FixedClock), + ); + + let err = uc + .execute(UpdateProjectContextInput { + project: project(), + content: "# rejected".to_owned(), + if_match: None, + requester: agent_party(8), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + let files = fs.files.lock().unwrap(); + assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old"); + assert!( + !files.keys().any(|path| path.contains("/.ideai/proposals/")), + "strict update must fail loud, not file a proposal" + ); + } + + #[tokio::test] + async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", agent, "x"), + Arc::clone(&fs) as Arc, + Arc::new(SpyBus::default()), + Arc::new(FixedClock), + ); + + let err = uc + .execute(UpdateProjectContextInput { + project: project(), + content: "# new".to_owned(), + if_match: Some("stale".to_owned()), + requester: ConversationParty::agent(agent), + }) + .await + .unwrap_err(); + + assert_eq!(err, AppError::Conflict(hex_sha256(b"# current"))); + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# current" + ); + } + + #[tokio::test] + async fn update_project_context_if_match_matching_succeeds() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", agent, "x"), + Arc::clone(&fs) as Arc, + Arc::new(SpyBus::default()), + Arc::new(FixedClock), + ); + + let out = uc + .execute(UpdateProjectContextInput { + project: project(), + content: "# new".to_owned(), + if_match: Some(hex_sha256(b"# old")), + requester: ConversationParty::agent(agent), + }) + .await + .unwrap(); + + assert_eq!(out.new_version, hex_sha256(b"# new")); + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# new" + ); + } + + #[tokio::test] + async fn update_project_context_no_if_match_is_last_write_wins() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", agent, "x"), + Arc::clone(&fs) as Arc, + Arc::new(SpyBus::default()), + Arc::new(FixedClock), + ); + + uc.execute(UpdateProjectContextInput { + project: project(), + content: "# latest".to_owned(), + if_match: None, + requester: ConversationParty::agent(agent), + }) + .await + .unwrap(); + + assert_eq!( + fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), + b"# latest" + ); + } + + #[tokio::test] + async fn update_project_context_publishes_project_context_updated_event() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec()); + let bus = Arc::new(SpyBus::default()); + let uc = UpdateProjectContext::new( + guard(), + contexts_with("Dev", agent, "x"), + Arc::clone(&fs) as Arc, + Arc::clone(&bus) as Arc, + Arc::new(FixedClock), + ); + + uc.execute(UpdateProjectContextInput { + project: project(), + content: "# new".to_owned(), + if_match: None, + requester: ConversationParty::agent(agent), + }) + .await + .unwrap(); + + assert_eq!( + bus.events(), + vec![DomainEvent::ProjectContextUpdated { + project_id: project().id, + by: ConversationParty::agent(agent), + at_ms: 42, + }] + ); + } + + #[tokio::test] + async fn read_context_and_update_project_context_version_round_trip() { + let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); + let fs = Arc::new(FakeFs::default()); + fs.files + .lock() + .unwrap() + .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec()); + let contexts = contexts_with("Dev", agent, "agent body"); + let reader = ReadContext::new( + guard(), + Arc::clone(&contexts), + Arc::clone(&fs) as Arc, + ); + let updater = UpdateProjectContext::new( + guard(), + contexts, + Arc::clone(&fs) as Arc, + Arc::new(SpyBus::default()), + Arc::new(FixedClock), + ); + + let version = reader + .execute(ReadContextInput { + project: project(), + target: None, + requester: ConversationParty::agent(agent), + }) + .await + .unwrap() + .version + .unwrap(); + + updater + .execute(UpdateProjectContextInput { + project: project(), + content: "# second".to_owned(), + if_match: Some(version.clone()), + requester: ConversationParty::agent(agent), + }) + .await + .unwrap(); + let stale = updater + .execute(UpdateProjectContextInput { + project: project(), + content: "# third".to_owned(), + if_match: Some(version), + requester: ConversationParty::agent(agent), + }) + .await + .unwrap_err(); + + assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second"))); + } + #[tokio::test] async fn propose_agent_context_writes_directly() { let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index bc27242..ce1a8fa 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -10,8 +10,9 @@ mod service; pub mod wake; pub use context_guard::{ - ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, - ReadMemoryInput, WriteMemory, WriteMemoryInput, + ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, + ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext, + UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput, }; pub use rendezvous::{ resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 3ef787d..0cd943c 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -47,7 +47,8 @@ use crate::error::AppError; use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome}; use crate::orchestrator::{ ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory, - ReadMemoryInput, WriteMemory, WriteMemoryInput, + ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory, + WriteMemoryInput, }; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; @@ -534,6 +535,8 @@ pub struct ContextGuardUseCases { pub read_context: Arc, /// Proposition/écriture d'un contexte `.md` IdeA sous le garde. pub propose_context: Arc, + /// Écriture directe stricte du contexte projet global. + pub update_project_context: Arc, /// Lecture mémoire sous read-lease. pub read_memory: Arc, /// Écriture mémoire sous write-lease. @@ -1241,6 +1244,14 @@ impl OrchestratorService { self.propose_context(project, target, content, requester) .await } + OrchestratorCommand::UpdateProjectContext { + content, + if_match, + requester, + } => { + self.update_project_context(project, content, if_match, requester) + .await + } OrchestratorCommand::ReadMemory { slug, requester } => { self.read_memory(project, slug, requester).await } @@ -1363,7 +1374,7 @@ impl OrchestratorService { target: Option, requester: ConversationParty, ) -> Result { - let md = self + let out = self .require_context_guard()? .read_context .execute(ReadContextInput { @@ -1372,9 +1383,13 @@ impl OrchestratorService { requester, }) .await?; + let mut text = out.content.into_string(); + if let Some(version) = &out.version { + text.push_str(&format!("\n\n")); + } Ok(OrchestratorOutcome { detail: format!("read {} context", target.as_deref().unwrap_or("project")), - reply: Some(md.into_string()), + reply: Some(text), }) } @@ -1411,6 +1426,31 @@ impl OrchestratorService { }) } + /// `context.update` → strict direct write of the global project context. + async fn update_project_context( + &self, + project: &Project, + content: String, + if_match: Option, + requester: ConversationParty, + ) -> Result { + let out = self + .require_context_guard()? + .update_project_context + .execute(UpdateProjectContextInput { + project: project.clone(), + content, + if_match, + requester, + }) + .await?; + + Ok(OrchestratorOutcome { + detail: format!("wrote project context (version {})", out.new_version), + reply: None, + }) + } + /// `memory.read` → reads a note (or the index) under a shared read-lease; the /// content is returned inline in the outcome's `reply`. async fn read_memory( @@ -2783,6 +2823,7 @@ impl OrchestratorService { description: None, content, scope, + kind: domain::SkillKind::Workflow, project_root: project.root.clone(), }) .await?; diff --git a/crates/application/src/permission.rs b/crates/application/src/permission.rs index 6b621e6..ee1f0f4 100644 --- a/crates/application/src/permission.rs +++ b/crates/application/src/permission.rs @@ -7,7 +7,10 @@ use std::sync::Arc; use domain::ports::PermissionStore; -use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions}; +use domain::{ + AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project, + ProjectPermissions, +}; use crate::error::AppError; @@ -131,6 +134,7 @@ impl ResolveAgentPermissions { let doc = self.store.load_permissions(&input.project).await?; Ok(ResolveAgentPermissionsOutput { effective: doc.resolve_for(input.agent_id), + shadowed: doc.shadow_for(input.agent_id), }) } } @@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput { pub struct ResolveAgentPermissionsOutput { /// Resolved policy, or `None` when neither project nor agent policy exists. pub effective: Option, + /// Diagnostic report for agent-level allows shadowed by project defaults. + pub shadowed: PermissionShadowReport, } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 767be89..6035ba2 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -8,8 +8,8 @@ use domain::ports::{ BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator, LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, - PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath, - SpawnSpec, + PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError, + ProcessSpawner, ProjectStore, RemotePath, SpawnSpec, }; use domain::{ AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash, @@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin { pub contributes: PluginContributionSet, } +/// Input for plugin-owned storage reads/deletes. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStorageGetInput { + /// Plugin id owning the value. + pub plugin_id: String, + /// Plugin-owned key. + pub key: String, +} + +/// Input for plugin-owned storage writes. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStorageSetInput { + /// Plugin id owning the value. + pub plugin_id: String, + /// Plugin-owned key. + pub key: String, + /// JSON value to persist. + pub value: serde_json::Value, +} + /// Input for reviewing a package. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReviewPluginPackageInput { @@ -2023,6 +2045,14 @@ fn map_store(e: PluginStoreError) -> AppError { } } +fn map_storage(e: PluginStorageError) -> AppError { + match e { + PluginStorageError::Invalid(m) => AppError::Invalid(m), + PluginStorageError::Io(m) => AppError::FileSystem(m), + PluginStorageError::Serialization(m) => AppError::Store(m), + } +} + fn map_registry(e: PluginRegistryError) -> AppError { match e { PluginRegistryError::Io(m) => AppError::Store(m), @@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput { pub plugin_id: String, } +/// Plugin-owned key/value storage facade. +pub struct PluginStorageAccess { + storage: Arc, + registry: Arc, +} + +impl PluginStorageAccess { + /// Builds the facade. + #[must_use] + pub fn new( + storage: Arc, + registry: Arc, + ) -> Self { + Self { storage, registry } + } + + /// Reads one plugin-owned JSON value. + pub async fn get( + &self, + input: PluginStorageGetInput, + ) -> Result, AppError> { + let plugin_id = self.active_plugin_id(input.plugin_id).await?; + validate_storage_key(&input.key)?; + self.storage + .get(&plugin_id, &input.key) + .await + .map_err(map_storage) + } + + /// Writes one plugin-owned JSON value. + pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> { + let plugin_id = self.active_plugin_id(input.plugin_id).await?; + validate_storage_key(&input.key)?; + self.storage + .set(&plugin_id, &input.key, input.value) + .await + .map_err(map_storage) + } + + /// Deletes one plugin-owned JSON value. + pub async fn delete(&self, input: PluginStorageGetInput) -> Result { + let plugin_id = self.active_plugin_id(input.plugin_id).await?; + validate_storage_key(&input.key)?; + self.storage + .delete(&plugin_id, &input.key) + .await + .map_err(map_storage) + } + + async fn active_plugin_id(&self, raw: String) -> Result { + let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?; + let registry = self.registry.load_registry().await.map_err(map_registry)?; + let entry = registry + .find(&plugin_id) + .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?; + if !entry.lifecycle_state.is_runtime_active() { + return Err(AppError::Invalid("plugin is not runtime-active".to_owned())); + } + Ok(plugin_id) + } +} + +fn validate_storage_key(key: &str) -> Result<(), AppError> { + if key.trim().is_empty() { + return Err(AppError::Invalid( + "plugin storage key must not be empty".to_owned(), + )); + } + if key.len() > 512 { + return Err(AppError::Invalid( + "plugin storage key must not exceed 512 bytes".to_owned(), + )); + } + if key.contains('\0') { + return Err(AppError::Invalid( + "plugin storage key must not contain NUL bytes".to_owned(), + )); + } + Ok(()) +} + /// Uninstalls a plugin. pub struct UninstallPlugin { packages: Arc, + storage: Arc, registry: Arc, events: Arc, mcp: Arc, @@ -2529,12 +2641,14 @@ impl UninstallPlugin { #[must_use] pub fn new( packages: Arc, + storage: Arc, registry: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, + storage, registry, events, mcp, @@ -2562,6 +2676,10 @@ impl UninstallPlugin { .remove_package(&plugin_id) .await .map_err(map_store)?; + self.storage + .purge_plugin(&plugin_id) + .await + .map_err(map_storage)?; self.events.publish(DomainEvent::PluginUninstalled { plugin_id: plugin_id.clone(), restart_required: true, @@ -3214,7 +3332,8 @@ mod tests { use domain::ports::{ BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator, - PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError, + PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore, + PluginStoreError, StoreError, }; use domain::remote::RemoteRef; use domain::{BackgroundTaskState, ProjectPath}; @@ -3411,6 +3530,69 @@ mod tests { } } + #[derive(Default)] + struct FakeStorage { + values: Mutex>, + purged: Mutex>, + } + + #[async_trait::async_trait] + impl PluginStorageStore for FakeStorage { + async fn get( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result, PluginStorageError> { + Ok(self + .values + .lock() + .unwrap() + .get(&(plugin_id.as_str().to_owned(), key.to_owned())) + .cloned()) + } + + async fn set( + &self, + plugin_id: &PluginId, + key: &str, + value: serde_json::Value, + ) -> Result<(), PluginStorageError> { + self.values + .lock() + .unwrap() + .insert((plugin_id.as_str().to_owned(), key.to_owned()), value); + Ok(()) + } + + async fn delete( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result { + Ok(self + .values + .lock() + .unwrap() + .remove(&(plugin_id.as_str().to_owned(), key.to_owned())) + .is_some()) + } + + async fn purge_plugin( + &self, + plugin_id: &PluginId, + ) -> Result { + self.purged + .lock() + .unwrap() + .push(plugin_id.as_str().to_owned()); + self.values + .lock() + .unwrap() + .retain(|(id, _), _| id != plugin_id.as_str()); + Ok(RemovalOutcome::Removed) + } + } + #[derive(Default)] struct FakeEvents { events: Mutex>, @@ -3802,6 +3984,7 @@ mod tests { #[tokio::test] async fn uninstall_removes_registry_package_and_stops_mcp() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let storage = Arc::new(FakeStorage::default()); let registry = Arc::new(FakeRegistry { registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), }); @@ -3809,6 +3992,7 @@ mod tests { let mcp = Arc::new(FakeMcp::default()); let uninstall = UninstallPlugin::new( packages.clone(), + storage.clone(), registry.clone(), events.clone(), mcp.clone(), @@ -3825,6 +4009,7 @@ mod tests { assert!(result.restart_required); assert!(registry.load_registry().await.unwrap().plugins.is_empty()); assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]); + assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]); assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); assert!(events.events.lock().unwrap().iter().any(|event| matches!( event, @@ -3835,6 +4020,89 @@ mod tests { ))); } + #[tokio::test] + async fn plugin_storage_round_trips_json_for_runtime_active_plugin() { + let storage = Arc::new(FakeStorage::default()); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let access = PluginStorageAccess::new(storage, registry); + + access + .set(PluginStorageSetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + value: serde_json::json!({"count": 2}), + }) + .await + .unwrap(); + let value = access + .get(PluginStorageGetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(value, Some(serde_json::json!({"count": 2}))); + assert!(access + .delete(PluginStorageGetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + }) + .await + .unwrap()); + assert_eq!( + access + .get(PluginStorageGetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + }) + .await + .unwrap(), + None + ); + } + + #[tokio::test] + async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() { + let storage = Arc::new(FakeStorage::default()); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)), + }); + let access = PluginStorageAccess::new(storage, registry); + + let inactive = access + .set(PluginStorageSetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + value: serde_json::json!(1), + }) + .await + .unwrap_err(); + assert_eq!( + inactive, + AppError::Invalid("plugin is not runtime-active".to_owned()) + ); + + let storage = Arc::new(FakeStorage::default()); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let access = PluginStorageAccess::new(storage, registry); + let invalid = access + .get(PluginStorageGetInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: " ".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!( + invalid, + AppError::Invalid("plugin storage key must not be empty".to_owned()) + ); + } + #[tokio::test] async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() { let packages = Arc::new(FakePackages::with_manifest_and_staged_count( @@ -3844,6 +4112,7 @@ mod tests { let registry = Arc::new(FakeRegistry::default()); let events = Arc::new(FakeEvents::default()); let mcp = Arc::new(FakeMcp::default()); + let storage = Arc::new(FakeStorage::default()); let install = InstallPluginFromDirectory::new( packages.clone(), registry.clone(), @@ -3851,8 +4120,13 @@ mod tests { events.clone(), mcp.clone(), ); - let uninstall = - UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone()); + let uninstall = UninstallPlugin::new( + packages.clone(), + storage.clone(), + registry.clone(), + events, + mcp.clone(), + ); install.execute("/source/plugin".to_owned()).await.unwrap(); uninstall diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index c5315f2..67d2266 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -30,6 +30,9 @@ pub struct CreateSkillInput { /// `None`/empty ⇒ the skill falls back to the first line of its body when /// surfaced (see [`domain::Skill::effective_description`]). pub description: Option, + /// Capability nature to expose for this skill. Defaults to workflow when the + /// caller does not specify it. + pub kind: SkillKind, /// Initial Markdown body. pub content: String, /// Scope the skill is created in (selects its backing store). @@ -67,7 +70,8 @@ impl CreateSkill { let id = SkillId::from_uuid(self.ids.new_uuid()); let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope) .map_err(|e| AppError::Invalid(e.to_string()))? - .with_description(input.description); + .with_description(input.description) + .with_kind(input.kind); self.skills.save(&skill, &input.project_root).await?; Ok(CreateSkillOutput { skill }) } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index b618d4b..d3fb802 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -35,8 +35,8 @@ use domain::ports::{ RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore, }; use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, - SessionStrategy, StructuredAdapter, + AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy, + McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter, }; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; @@ -50,7 +50,7 @@ use application::{ CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext, ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions, - UpdateAgentContext, UpdateAgentContextInput, + UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, }; // --------------------------------------------------------------------------- @@ -604,6 +604,7 @@ impl AgentSession for FakeSession { struct FakeStructuredFactory { trace: Trace, starts: Arc>>, + efforts: Arc>>>, envs: Arc>>>, policies: Arc>>>, next_session: SessionId, @@ -614,6 +615,7 @@ impl FakeStructuredFactory { Self { trace, starts: Arc::new(Mutex::new(Vec::new())), + efforts: Arc::new(Mutex::new(Vec::new())), envs: Arc::new(Mutex::new(Vec::new())), policies: Arc::new(Mutex::new(Vec::new())), next_session, @@ -624,6 +626,10 @@ impl FakeStructuredFactory { self.starts.lock().unwrap().clone() } + fn efforts(&self) -> Vec> { + self.efforts.lock().unwrap().clone() + } + fn envs(&self) -> Vec> { self.envs.lock().unwrap().clone() } @@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory { .unwrap() .push("structured.start".to_owned()); self.starts.lock().unwrap().push(profile.id); + self.efforts + .lock() + .unwrap() + .push(profile.model_reasoning_effort.clone()); self.envs.lock().unwrap().push(_env.to_vec()); self.policies .lock() @@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() { ); } +#[tokio::test] +async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() { + let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a1, "ctx1"); + { + let mut inner = contexts.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry::from_agent(&a2)); + inner.manifest.designate(a2.id).unwrap(); + } + let list = ListAgents::new(Arc::new(contexts)); + + let out = list + .execute(ListAgentsInput { project: project() }) + .await + .unwrap(); + let entries = out.discovery_entries(); + + assert_eq!(out.effective_orchestrator, Some(a2.id)); + assert_eq!(entries[0].is_orchestrator, false); + assert_eq!(entries[1].is_orchestrator, true); +} + +#[tokio::test] +async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() { + let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a1, "ctx1"); + contexts + .0 + .lock() + .unwrap() + .manifest + .entries + .push(ManifestEntry::from_agent(&a2)); + let list = ListAgents::new(Arc::new(contexts)); + + let out = list + .execute(ListAgentsInput { project: project() }) + .await + .unwrap(); + let entries = out.discovery_entries(); + + assert_eq!(out.effective_orchestrator, Some(a1.id)); + assert_eq!(entries[0].is_orchestrator, true); + assert_eq!(entries[1].is_orchestrator, false); +} + #[tokio::test] async fn read_then_update_context_roundtrips() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); @@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() { ); } +#[tokio::test] +async fn update_agent_effort_sets_preset_selection_and_persists_manifest() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a, "ctx"); + let update = UpdateAgentEffort::new(Arc::new(contexts.clone())); + + let out = update + .execute(UpdateAgentEffortInput { + project: project(), + agent_id: a.id, + effort: Some(EffortSelection::Preset("high".to_owned())), + }) + .await + .unwrap(); + + assert_eq!( + out.agent.effort, + Some(EffortSelection::Preset("high".to_owned())) + ); + assert_eq!( + contexts.manifest().entries[0].effort, + Some(EffortSelection::Preset("high".to_owned())) + ); +} + +#[tokio::test] +async fn update_agent_effort_none_clears_existing_override() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)) + .with_effort(Some(EffortSelection::Custom("x-deep".to_owned()))); + let contexts = FakeContexts::with_agent(&a, "ctx"); + let update = UpdateAgentEffort::new(Arc::new(contexts.clone())); + + let out = update + .execute(UpdateAgentEffortInput { + project: project(), + agent_id: a.id, + effort: None, + }) + .await + .unwrap(); + + assert_eq!(out.agent.effort, None); + assert_eq!(contexts.manifest().entries[0].effort, None); +} + +#[tokio::test] +async fn update_agent_effort_not_found_for_unknown_agent_id() { + let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&a, "ctx"); + let update = UpdateAgentEffort::new(Arc::new(contexts)); + + let err = update + .execute(UpdateAgentEffortInput { + project: project(), + agent_id: aid(404), + effort: Some(EffortSelection::Preset("medium".to_owned())), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); +} + #[tokio::test] async fn delete_removes_entry_then_unknown_is_not_found() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); @@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall( recall: FakeRecall, ) -> LaunchFixture { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall) +} + +fn launch_fixture_with_profile_agent_and_recall( + profile: AgentProfile, + agent: Agent, + plan: Option, + recall: FakeRecall, +) -> LaunchFixture { let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile]); let tr = trace(); @@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt ); } +#[tokio::test] +async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Codex) + .with_model_reasoning_effort("low"); + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id) + .with_effort(Some(EffortSelection::Preset("high".to_owned()))); + let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = + launch_fixture_with_profile_agent_and_recall( + profile, + agent, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + FakeRecall::default(), + ); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), structured); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + assert!(pty.spawns().is_empty()); + assert_eq!(factory.efforts(), vec![Some("high".to_owned())]); +} + +#[tokio::test] +async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Codex) + .with_model_reasoning_effort("low"); + let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), structured); + + launch.execute(launch_input(agent.id)).await.unwrap(); + + assert!(pty.spawns().is_empty()); + assert_eq!(factory.efforts(), vec![Some("low".to_owned())]); +} + #[tokio::test] async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() { let profile = profile( @@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env( env: Vec<(String, String)>, ) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc) { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env) +} + +fn launch_with_projection_agent_and_env( + profile: AgentProfile, + agent: Agent, + plan: Option, + registry: Option>, + perm_doc: Option, + env: Vec<(String, String)>, +) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc) { let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile]); let tr = trace(); @@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() { ); } +#[tokio::test] +async fn launch_agent_pty_codex_overrides_use_resolved_effort() { + let profile = codex_profile() + .with_projector(ProjectorKey::Codex) + .with_model_reasoning_effort("low"); + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id) + .with_effort(Some(EffortSelection::Custom("x-deep".to_owned()))); + let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env( + profile, + agent, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + None, + Vec::new(), + ); + + launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); + + let args = &pty.spawns()[0].args; + assert!( + args.windows(2).any(|w| w + == [ + "-c".to_owned(), + "model_reasoning_effort=\"x-deep\"".to_owned() + ]), + "PTY Codex launch must forward the resolved per-agent effort, got {args:?}" + ); +} + // ---- (5) MCP decoupling — THE key case of the lot --------------------------- /// (5) A Codex profile with **no MCP capability** still gets its sandbox projected diff --git a/crates/application/tests/permission_usecases.rs b/crates/application/tests/permission_usecases.rs index 6949c60..84b49e0 100644 --- a/crates/application/tests/permission_usecases.rs +++ b/crates/application/tests/permission_usecases.rs @@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId}; use domain::ports::{PermissionStore, StoreError}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; -use domain::{PermissionSet, Posture, ProjectPermissions}; +use domain::{ + Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture, + ProjectPermissions, +}; #[derive(Default)] struct FakePermissionStore { @@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() { .unwrap(); assert_eq!(out.effective.unwrap().fallback(), Posture::Ask); + assert_eq!(out.shadowed, PermissionShadowReport::default()); +} + +#[tokio::test] +async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() { + let agent = AgentId::new_random(); + let store = Arc::new(FakePermissionStore { + doc: Mutex::new(ProjectPermissions::new( + Some(PermissionSet::new( + vec![PermissionRule::bash(Effect::Deny, vec![])], + Posture::Ask, + )), + vec![domain::AgentPermissionOverride::new( + agent, + PermissionSet::new( + vec![PermissionRule::bash(Effect::Allow, vec![])], + Posture::Ask, + ), + )], + )), + saves: Mutex::new(0), + }); + let use_case = ResolveAgentPermissions::new(store); + + let out = use_case + .execute(ResolveAgentPermissionsInput { + project: project(), + agent_id: agent, + }) + .await + .unwrap(); + + assert!(out.shadowed.execute_bash); + assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny); +} + +#[tokio::test] +async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() { + let agent = AgentId::new_random(); + let store = Arc::new(FakePermissionStore { + doc: Mutex::new(ProjectPermissions::new( + Some(PermissionSet::new( + vec![PermissionRule::file( + Capability::Read, + Effect::Deny, + domain::PathScope::new(["**".to_owned()]).unwrap(), + ) + .unwrap()], + Posture::Deny, + )), + vec![], + )), + saves: Mutex::new(0), + }); + let use_case = ResolveAgentPermissions::new(store); + + let out = use_case + .execute(ResolveAgentPermissionsInput { + project: project(), + agent_id: agent, + }) + .await + .unwrap(); + + assert_eq!(out.shadowed, PermissionShadowReport::default()); } diff --git a/crates/application/tests/skill_usecases.rs b/crates/application/tests/skill_usecases.rs index 4cee8a8..ee877b4 100644 --- a/crates/application/tests/skill_usecases.rs +++ b/crates/application/tests/skill_usecases.rs @@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError, }; -use domain::skill::{Skill, SkillScope}; +use domain::skill::{Skill, SkillKind, SkillScope}; use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef}; use uuid::Uuid; @@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() { .execute(CreateSkillInput { name: "refactor".to_owned(), description: Some("Refactors code".to_owned()), + kind: SkillKind::Reference, content: "# body".to_owned(), scope: SkillScope::Project, project_root: root(), @@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() { .unwrap(); assert_eq!(out.skill.scope, SkillScope::Project); + assert_eq!(out.skill.kind, SkillKind::Reference); // The one-line affordance description flows through the use case onto the skill. assert_eq!(out.skill.description.as_deref(), Some("Refactors code")); assert_eq!( @@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() { .execute(CreateSkillInput { name: "k".to_owned(), description: None, + kind: SkillKind::Workflow, content: String::new(), scope: SkillScope::Global, project_root: root(), diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 0f47846..7755f13 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -337,6 +337,47 @@ impl From for application::PluginWorkspaceWriteBi } } +/// Plugin-owned storage read/delete request DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStorageGetDto { + /// Plugin id owning the value. + pub plugin_id: String, + /// Plugin-owned key. + pub key: String, +} + +impl From for application::PluginStorageGetInput { + fn from(value: PluginStorageGetDto) -> Self { + Self { + plugin_id: value.plugin_id, + key: value.key, + } + } +} + +/// Plugin-owned storage write request DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStorageSetDto { + /// Plugin id owning the value. + pub plugin_id: String, + /// Plugin-owned key. + pub key: String, + /// JSON value to persist. + pub value: Value, +} + +impl From for application::PluginStorageSetInput { + fn from(value: PluginStorageSetDto) -> Self { + Self { + plugin_id: value.plugin_id, + key: value.key, + value: value.value, + } + } +} + /// Plugin structured config document read request DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2377,8 +2418,8 @@ use application::{ LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput, }; use domain::{ - Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet, - ProjectPermissions, SkillKind, TerminalSession, + Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy, + PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession, }; /// One discoverable capability carried by an agent. @@ -2413,6 +2454,8 @@ pub struct AgentDto { pub agent: Agent, /// Resolved discoverable capabilities. pub capabilities: Vec, + /// Whether this agent is the effective project orchestrator. + pub is_orchestrator: bool, } impl AgentDto { @@ -2422,6 +2465,7 @@ impl AgentDto { Self { agent, capabilities: Vec::new(), + is_orchestrator: false, } } } @@ -2443,6 +2487,7 @@ impl From for AgentListDto { .into_iter() .map(AgentCapabilityDto::from) .collect(), + is_orchestrator: entry.is_orchestrator, }) .collect(), ) @@ -2512,6 +2557,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions); #[serde(transparent)] pub struct EffectivePermissionsDto(pub EffectivePermissions); +/// Response for resolving one agent's file/bash permissions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveAgentPermissionsResponseDto { + /// Resolved policy, or `null` when neither project nor agent policy exists. + pub effective: Option, + /// Diagnostic report for agent-level allows shadowed by project defaults. + pub shadowed: PermissionShadowReport, +} + +impl From for ResolveAgentPermissionsResponseDto { + fn from(out: application::ResolveAgentPermissionsOutput) -> Self { + Self { + effective: out.effective, + shadowed: out.shadowed, + } + } +} + /// Full project system permission document crossing the wire. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] @@ -2767,6 +2831,18 @@ pub struct ChangeAgentProfileRequestDto { pub cols: u16, } +/// Request DTO for `update_agent_effort`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentEffortRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose effort override changes. + pub agent_id: String, + /// `null` clears the override. + pub effort: Option, +} + /// Response DTO for `change_agent_profile`: the mutated agent plus the freshly /// relaunched session when a live session was hot-swapped (absent otherwise). #[derive(Debug, Clone, Serialize)] @@ -3997,10 +4073,16 @@ pub struct CreateSkillRequestDto { pub project_id: String, /// Display name. pub name: String, + /// Optional one-line affordance description. + #[serde(default)] + pub description: Option, /// Initial Markdown content. pub content: String, /// Scope the skill is created in. pub scope: SkillScope, + /// Capability nature. Missing legacy clients create workflow skills. + #[serde(default)] + pub kind: SkillKind, } /// Request DTO for `update_skill`. @@ -4703,7 +4785,7 @@ pub struct SpawnBackgroundCommandRequestDto { mod tests { use application::McpToolPermissionCatalogue; use domain::mailbox::TicketId; - use domain::{AgentId, ConversationId, ProjectMcpToolPermissions}; + use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions}; use serde_json::json; use uuid::Uuid; @@ -4755,6 +4837,34 @@ mod tests { ); } + #[test] + fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() { + let dto = ResolveAgentPermissionsResponseDto { + effective: None, + shadowed: PermissionShadowReport { + read: false, + write: false, + delete: false, + execute_bash: true, + fallback: true, + }, + }; + + assert_eq!( + serde_json::to_value(dto).unwrap(), + json!({ + "effective": null, + "shadowed": { + "read": false, + "write": false, + "delete": false, + "executeBash": true, + "fallback": true + } + }) + ); + } + #[test] fn dto_plugins_workspace_requests_use_stable_camel_case_contract() { let path = PluginWorkspacePathDto { @@ -4868,6 +4978,42 @@ mod tests { assert_eq!(input.value["enabled"], true); } + #[test] + fn dto_plugins_storage_requests_use_stable_camel_case_contract() { + let get = PluginStorageGetDto { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.launches".to_owned(), + }; + assert_eq!( + serde_json::to_value(&get).unwrap(), + json!({ + "pluginId": "dev.acme.gitgraph", + "key": "helloPlugin.launches" + }) + ); + let input: application::PluginStorageGetInput = get.into(); + assert_eq!(input.plugin_id, "dev.acme.gitgraph"); + assert_eq!(input.key, "helloPlugin.launches"); + + let set = PluginStorageSetDto { + plugin_id: "dev.acme.gitgraph".to_owned(), + key: "helloPlugin.enabled".to_owned(), + value: json!({"enabled": true}), + }; + assert_eq!( + serde_json::to_value(&set).unwrap(), + json!({ + "pluginId": "dev.acme.gitgraph", + "key": "helloPlugin.enabled", + "value": {"enabled": true} + }) + ); + let input: application::PluginStorageSetInput = set.into(); + assert_eq!(input.plugin_id, "dev.acme.gitgraph"); + assert_eq!(input.key, "helloPlugin.enabled"); + assert_eq!(input.value, json!({"enabled": true})); + } + #[test] fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() { let listing = PluginWorkspaceDirectoryListingDto { diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 1444c72..8d645e1 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -579,6 +579,16 @@ pub enum DomainEventDto { #[serde(skip_serializing_if = "Option::is_none")] orchestrator: Option, }, + /// The project's global context was written directly. + #[serde(rename_all = "camelCase")] + ProjectContextUpdated { + /// The project whose global context changed. + project_id: String, + /// Writer party (`"user"` or agent id). + by: String, + /// Epoch-milliseconds of the write. + at_ms: i64, + }, /// A memory note was created or updated. #[serde(rename_all = "camelCase")] MemorySaved { @@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto { project_id: project_id.to_string(), orchestrator: orchestrator.as_ref().map(|a| a.to_string()), }, + DomainEvent::ProjectContextUpdated { + project_id, + by, + at_ms, + } => Self::ProjectContextUpdated { + project_id: project_id.to_string(), + by: conversation_party_wire(*by), + at_ms: *at_ms, + }, DomainEvent::MemorySaved { slug } => Self::MemorySaved { slug: slug.as_str().to_string(), }, @@ -1426,6 +1445,28 @@ mod tests { ); } + #[test] + fn project_context_updated_relays_writer_to_wire() { + let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); + let writer = agent(2); + + let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated { + project_id, + by: ConversationParty::agent(writer), + at_ms: 987_654, + }); + + assert_eq!( + serde_json::to_value(&dto).unwrap(), + json!({ + "type": "projectContextUpdated", + "projectId": project_id.to_string(), + "by": writer.to_string(), + "atMs": 987654, + }) + ); + } + #[test] fn background_completion_relays_rendezvous_context_to_wire() { let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 4b87189..415b2b1 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -11,6 +11,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext; use application::{ AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, @@ -35,7 +36,7 @@ use application::{ MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, - PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, + PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, @@ -49,7 +50,7 @@ use application::{ SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, @@ -64,10 +65,11 @@ use domain::ports::{ 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, + PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore, + 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, @@ -91,15 +93,15 @@ use infrastructure::{ FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, - FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, - FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, - HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, - HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, - LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, - ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, - SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, + FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, + Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, 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, @@ -1093,6 +1095,8 @@ pub struct BackendCore { pub update_project_permissions: Arc, /// Update one agent permission override. pub update_agent_permissions: Arc, + /// Update one agent effort override. + pub update_agent_effort: Arc, /// Resolve effective permissions for one agent. pub resolve_agent_permissions: Arc, /// Read the project system permission document. @@ -1142,6 +1146,8 @@ pub struct BackendCore { pub plugin_workspace_access: Arc, /// Public plugin structured config document facade. pub plugin_config_documents: Arc, + /// Public plugin-owned storage facade. + pub plugin_storage_access: Arc, /// Public plugin project-structure query use case. pub query_project_structure: Arc, /// Public plugin command/task facade. @@ -1416,11 +1422,13 @@ impl BackendCore { let events_port = Arc::clone(&event_bus) as Arc; let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone())); let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone())); + let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone())); let plugin_validator = Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION"))); let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new()); let plugin_package_store = Arc::clone(&plugin_packages) as Arc; let plugin_registry_store = Arc::clone(&plugin_registry) as Arc; + let plugin_storage_store = Arc::clone(&plugin_storage) as Arc; let plugin_manifest_validator = Arc::clone(&plugin_validator) as Arc; let plugin_mcp_supervisor_port = @@ -2178,6 +2186,7 @@ impl BackendCore { let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone( &permission_store_port, ))); + let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port))); let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( &permission_store_port, ))); @@ -2425,6 +2434,7 @@ impl BackendCore { )); let uninstall_plugin = Arc::new(UninstallPlugin::new( Arc::clone(&plugin_package_store), + Arc::clone(&plugin_storage_store), Arc::clone(&plugin_registry_store), Arc::clone(&events_port), Arc::clone(&plugin_mcp_supervisor_port), @@ -2448,6 +2458,10 @@ impl BackendCore { PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port)) .with_events(Arc::clone(&events_port)), ); + let plugin_storage_access = Arc::new(PluginStorageAccess::new( + Arc::clone(&plugin_storage_store), + Arc::clone(&plugin_registry_store), + )); let query_project_structure = Arc::new(QueryProjectStructure::new( Arc::clone(&store_port), Arc::clone(&fs_port), @@ -2718,6 +2732,13 @@ impl BackendCore { Arc::clone(&fs_port), Arc::clone(&clock) as Arc, )), + update_project_context: Arc::new(GuardedUpdateProjectContext::new( + Arc::clone(&file_guard), + Arc::clone(&contexts_port), + Arc::clone(&fs_port), + Arc::clone(&events_port), + Arc::clone(&clock) as Arc, + )), read_memory: Arc::new(ReadMemory::new( Arc::clone(&file_guard), Arc::clone(&memory_store_port), @@ -2976,6 +2997,7 @@ impl BackendCore { get_project_permissions, update_project_permissions, update_agent_permissions, + update_agent_effort, resolve_agent_permissions, get_project_system_permissions, update_project_system_permissions, @@ -3045,6 +3067,7 @@ impl BackendCore { reconcile_plugin_mcp_servers, plugin_workspace_access, plugin_config_documents, + plugin_storage_access, query_project_structure, plugin_command_tasks, plugin_toolchain_diagnostics, @@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } @@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests { // traite ces commandes sans cette erreur — et le réfute sans le câblage. // ----------------------------------------------------------------------- + use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext; use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory}; use domain::conversation::ConversationParty; use domain::memory::{ @@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests { Arc::new(FakeFs), Arc::new(FixedClock), )), + update_project_context: Arc::new(GuardedUpdateProjectContext::new( + Arc::clone(&file_guard), + Arc::new(contexts.clone()), + Arc::new(FakeFs), + Arc::new(NoopBus), + Arc::new(FixedClock), + )), read_memory: Arc::new(ReadMemory::new( Arc::clone(&file_guard), Arc::clone(&memory) as Arc, @@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index a63b593..278551d 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::{AgentId, ProfileId, TemplateId}; +use crate::profile::EffortSelection; use crate::skill::SkillRef; use crate::template::TemplateVersion; @@ -70,6 +71,10 @@ pub struct Agent { /// activation (ARCHITECTURE §14.2). Empty by default. #[serde(default)] pub skills: Vec, + /// Per-agent effort selection. `None` falls back to the profile's + /// `model_reasoning_effort` at launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } impl Agent { @@ -104,6 +109,7 @@ impl Agent { origin, synchronized, skills: Vec::new(), + effort: None, }) } @@ -128,6 +134,13 @@ impl Agent { self } + /// Returns a copy of this agent carrying the given per-agent effort override. + #[must_use] + pub fn with_effort(mut self, effort: Option) -> Self { + self.effort = effort; + self + } + /// Assigns a skill to this agent. Idempotent: re-assigning the same /// `skill_id` is a no-op (returns `false`); a new assignment returns `true`. pub fn assign_skill(&mut self, skill: SkillRef) -> bool { @@ -183,6 +196,9 @@ pub struct ManifestEntry { /// backward-compatible deserialisation of pre-L12 manifests. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, + /// Per-agent effort selection. Missing in older manifests means no override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } impl ManifestEntry { @@ -221,6 +237,7 @@ impl ManifestEntry { synchronized, synced_template_version, skills: Vec::new(), + effort: None, }) } @@ -246,6 +263,7 @@ impl ManifestEntry { synchronized: agent.synchronized, synced_template_version, skills: agent.skills.clone(), + effort: agent.effort.clone(), } } @@ -271,7 +289,8 @@ impl ManifestEntry { origin, self.synchronized, )? - .with_skills(self.skills.clone())) + .with_skills(self.skills.clone()) + .with_effort(self.effort.clone())) } } @@ -530,4 +549,41 @@ mod orchestrator_tests { &d, )); } + + #[test] + fn agent_with_effort_round_trips_through_manifest_entry() { + let agent = Agent::new( + agent_id(1), + "agent-1", + "agents/agent-1.md", + ProfileId::from_uuid(uuid::Uuid::from_u128(1001)), + AgentOrigin::Scratch, + false, + ) + .unwrap() + .with_effort(Some(EffortSelection::Preset("medium".to_owned()))); + + let entry = ManifestEntry::from_agent(&agent); + let back = entry.to_agent().unwrap(); + + assert_eq!( + back.effort, + Some(EffortSelection::Preset("medium".to_owned())) + ); + } + + #[test] + fn manifest_entry_effort_absent_deserialises_as_none() { + let json = r#"{ + "agentId":"00000000-0000-0000-0000-000000000001", + "name":"agent-1", + "mdPath":"agents/agent-1.md", + "profileId":"00000000-0000-0000-0000-0000000003e9", + "synchronized":false + }"#; + + let entry: ManifestEntry = serde_json::from_str(json).expect("legacy entry deserialises"); + assert_eq!(entry.effort, None); + assert_eq!(entry.to_agent().unwrap().effort, None); + } } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 38fef5c..3e3a5f1 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -637,6 +637,16 @@ pub enum DomainEvent { /// (the oldest agent orchestrates). orchestrator: Option, }, + /// The project's global context was written directly, as opposed to a + /// non-orchestrator's change being filed as a proposal. + ProjectContextUpdated { + /// The project whose global context changed. + project_id: ProjectId, + /// The party that performed the write. + by: ConversationParty, + /// Epoch-milliseconds of the write. + at_ms: i64, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5ddfa40..f35b976 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -106,8 +106,8 @@ pub use skill::{Skill, SkillKind, SkillRef, SkillScope}; pub use template::{AgentTemplate, TemplateVersion}; pub use profile::{ - AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy, - McpServerWiring, RateLimitPattern, SessionStrategy, + resolve_effort, AgentProfile, ContextInjection, EffortOption, EffortSelection, EmbedderProfile, + EmbedderStrategy, LivenessStrategy, McpServerWiring, RateLimitPattern, SessionStrategy, }; pub use mailbox::{ @@ -205,8 +205,8 @@ pub use permission::{ opencode_permission_block, render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule, - PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, - PERMISSIONS_VERSION, + PermissionSet, PermissionShadowReport, Posture, ProjectPermissions, ProjectedFile, + ProjectionContext, ProjectorKey, PERMISSIONS_VERSION, }; pub use system_permissions::{ @@ -248,10 +248,10 @@ pub use ports::{ MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, - PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, - ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, - RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, - SprintStoreError, StoreError, StructuredSessionEnvironment, + PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, + PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, + ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, + RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, + SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 74f6172..ea1568b 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -194,6 +194,9 @@ pub struct OrchestratorRequest { /// `memory.write`, cadrage C7). Required by those actions, ignored otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, + /// Optional optimistic-concurrency version for `context.update`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub if_match: Option, /// Target memory note slug for the memory tools (`memory.read`/`memory.write`, /// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒ /// the aggregated index). Ignored by the other actions. @@ -342,6 +345,16 @@ pub enum OrchestratorCommand { /// The proposing party (handshake identity). requester: ConversationParty, }, + /// Directly update the global project context. Strict, orchestrator-only + /// counterpart to [`Self::ProposeContext`]. + UpdateProjectContext { + /// The new Markdown body. + content: String, + /// Optional expected current version. + if_match: Option, + /// The writing party (handshake identity). + requester: ConversationParty, + }, /// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7). /// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note. ReadMemory { @@ -500,6 +513,11 @@ impl OrchestratorRequest { content: self.require("content", action, self.content.as_deref())?, requester: self.requester_party(), }), + "context.update" => Ok(OrchestratorCommand::UpdateProjectContext { + content: self.require("content", action, self.content.as_deref())?, + if_match: self.if_match.clone(), + requester: self.requester_party(), + }), "memory.read" => Ok(OrchestratorCommand::ReadMemory { slug: self.optional_slug(), requester: self.requester_party(), @@ -1089,6 +1107,34 @@ mod tests { ); } + #[test] + fn context_update_command_parses_content_and_if_match() { + let uid = uuid::Uuid::from_u128(42); + let r = req(&format!( + r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"## + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::UpdateProjectContext { + content: "# body".to_owned(), + if_match: Some("abc123".to_owned()), + requester: ConversationParty::agent(AgentId::from_uuid(uid)), + } + ); + } + + #[test] + fn context_update_requires_content() { + let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#); + assert_eq!( + missing.validate(), + Err(OrchestratorError::MissingField { + action: "context.update".to_owned(), + field: "content".to_owned(), + }) + ); + } + #[test] fn memory_read_optional_slug() { assert_eq!( diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index fa35ab0..5ebeb56 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -542,6 +542,36 @@ impl ProjectPermissions { self.agent_permissions(agent_id), ) } + + /// Reports agent-level blanket allows that are shadowed by project-level + /// blanket denies for `agent_id`. + /// + /// This is a diagnostic companion to [`Self::resolve_for`]. It does not + /// participate in permission resolution and does not change deny-wins. + #[must_use] + pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport { + shadow_report( + self.project_defaults.as_ref(), + self.agent_permissions(agent_id), + ) + } +} + +/// Diagnostic report for agent overrides that cannot loosen the project policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionShadowReport { + /// Agent blanket read allow is shadowed by a project blanket read deny. + pub read: bool, + /// Agent blanket write allow is shadowed by a project blanket write deny. + pub write: bool, + /// Agent blanket delete allow is shadowed by a project blanket delete deny. + pub delete: bool, + /// Agent blanket bash allow is shadowed by a project blanket bash deny. + pub execute_bash: bool, + /// Agent fallback choice is looser than the resolved project-tightened + /// fallback. + pub fallback: bool, } /// The normalised, flattened output of [`resolve`] — the **sole input** of the @@ -713,6 +743,72 @@ pub fn resolve( Some(EffectivePermissions { rules, fallback }) } +/// Reports agent-level blanket allows shadowed by project-level blanket denies. +/// +/// This is deliberately **not** a general glob-overlap solver. It is shaped to +/// the current UI contract: file capabilities are considered blanket only when +/// the rule has exactly one glob, `"**"`; bash is considered blanket only when +/// the rule has no command matchers. Scoped rules are ignored by this diagnostic +/// even though normal [`resolve`] and decision methods still honour them. +#[must_use] +pub fn shadow_report( + project: Option<&PermissionSet>, + agent: Option<&PermissionSet>, +) -> PermissionShadowReport { + let Some(agent) = agent else { + return PermissionShadowReport::default(); + }; + + let shadowed = |capability| { + blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny) + && blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins) + == Some(Effect::Allow) + }; + let fallback = resolve(project, Some(agent)) + .is_some_and(|resolved| agent.fallback() != resolved.fallback()); + + PermissionShadowReport { + read: shadowed(Capability::Read), + write: shadowed(Capability::Write), + delete: shadowed(Capability::Delete), + execute_bash: shadowed(Capability::ExecuteBash), + fallback, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlanketLookupMode { + DenyWins, + AllowWins, +} + +fn blanket_effect( + set: Option<&PermissionSet>, + capability: Capability, + mode: BlanketLookupMode, +) -> Option { + let mut found = None; + for rule in set?.rules() { + if rule.capability() != capability || !is_blanket_rule(rule) { + continue; + } + match (mode, rule.effect()) { + (BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny), + (BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow), + _ => found = Some(rule.effect()), + } + } + found +} + +fn is_blanket_rule(rule: &PermissionRule) -> bool { + if rule.capability().is_bash() { + return rule.commands().is_empty(); + } + let globs = rule.paths().globs(); + globs.len() == 1 && globs[0].pattern() == "**" +} + /// Renders a human-readable Markdown **summary** of the resolved policy, suitable /// for injection into an agent's context (lot LP4-0). /// @@ -1118,6 +1214,10 @@ mod tests { PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap() } + fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule { + PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap() + } + // ---- VO construction & invariants ----------------------------------- #[test] @@ -1398,6 +1498,155 @@ mod tests { ); } + // ---- shadow diagnostics (ticket #122) ------------------------------- + + #[test] + fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow( + ) { + let project = PermissionSet::new( + vec![ + blanket_file(Capability::Read, Effect::Deny), + blanket_file(Capability::Write, Effect::Deny), + blanket_file(Capability::Delete, Effect::Deny), + PermissionRule::bash(Effect::Deny, vec![]), + ], + Posture::Ask, + ); + let agent = PermissionSet::new( + vec![ + blanket_file(Capability::Read, Effect::Allow), + blanket_file(Capability::Write, Effect::Allow), + blanket_file(Capability::Delete, Effect::Allow), + PermissionRule::bash(Effect::Allow, vec![]), + ], + Posture::Ask, + ); + + assert_eq!( + shadow_report(Some(&project), Some(&agent)), + PermissionShadowReport { + read: true, + write: true, + delete: true, + execute_bash: true, + fallback: false, + } + ); + } + + #[test] + fn shadow_report_false_when_project_has_no_deny_rule_for_capability() { + let project = PermissionSet::new( + vec![blanket_file(Capability::Read, Effect::Allow)], + Posture::Ask, + ); + let agent = PermissionSet::new( + vec![blanket_file(Capability::Read, Effect::Allow)], + Posture::Ask, + ); + + assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false); + } + + #[test] + fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() { + let project = PermissionSet::new( + vec![blanket_file(Capability::Write, Effect::Deny)], + Posture::Ask, + ); + let agent = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"])) + .unwrap(), + ], + Posture::Ask, + ); + + assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false); + } + + #[test] + fn shadow_report_false_when_agent_also_denies() { + let project = PermissionSet::new( + vec![PermissionRule::bash(Effect::Deny, vec![])], + Posture::Ask, + ); + let agent = PermissionSet::new( + vec![PermissionRule::bash(Effect::Deny, vec![])], + Posture::Ask, + ); + + assert_eq!( + shadow_report(Some(&project), Some(&agent)).execute_bash, + false + ); + } + + #[test] + fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() { + let project = PermissionSet::new(vec![], Posture::Deny); + let agent = PermissionSet::new(vec![], Posture::Allow); + + assert!(shadow_report(Some(&project), Some(&agent)).fallback); + } + + #[test] + fn shadow_report_all_false_when_agent_is_none() { + let project = PermissionSet::new( + vec![PermissionRule::bash(Effect::Deny, vec![])], + Posture::Deny, + ); + + assert_eq!( + shadow_report(Some(&project), None), + PermissionShadowReport::default() + ); + } + + #[test] + fn shadow_for_matches_free_function_via_project_permissions() { + let agent = AgentId::new_random(); + let project = PermissionSet::new( + vec![PermissionRule::bash(Effect::Deny, vec![])], + Posture::Ask, + ); + let custom = PermissionSet::new( + vec![PermissionRule::bash(Effect::Allow, vec![])], + Posture::Ask, + ); + let doc = ProjectPermissions::new( + Some(project.clone()), + vec![AgentPermissionOverride::new(agent, custom.clone())], + ); + + assert_eq!( + doc.shadow_for(agent), + shadow_report(Some(&project), Some(&custom)) + ); + } + + #[test] + fn permission_shadow_report_serialises_camel_case() { + let report = PermissionShadowReport { + read: false, + write: false, + delete: false, + execute_bash: true, + fallback: true, + }; + + assert_eq!( + serde_json::to_value(report).unwrap(), + serde_json::json!({ + "read": false, + "write": false, + "delete": false, + "executeBash": true, + "fallback": true + }) + ); + } + // ---- LP3: ProjectorKey serde + PermissionProjection invariant ------ #[test] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index e2f8ac9..9b0ffc4 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -386,6 +386,48 @@ pub enum PluginMcpError { Process(String), } +/// Plugin-owned storage errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginStorageError { + /// Invalid key or value. + #[error("plugin storage invalid input: {0}")] + Invalid(String), + /// Filesystem failure. + #[error("plugin storage I/O error: {0}")] + Io(String), + /// Serialization failure. + #[error("plugin storage serialization error: {0}")] + Serialization(String), +} + +/// Store for plugin-owned key/value JSON data under app-data `plugins/data//`. +#[async_trait] +pub trait PluginStorageStore: Send + Sync { + /// Reads one plugin-owned value. + async fn get( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result, PluginStorageError>; + + /// Writes one plugin-owned value. + async fn set( + &self, + plugin_id: &PluginId, + key: &str, + value: Value, + ) -> Result<(), PluginStorageError>; + + /// Deletes one plugin-owned value. + async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result; + + /// Purges every plugin-owned value for one plugin. + async fn purge_plugin( + &self, + plugin_id: &PluginId, + ) -> Result; +} + /// Store for installed plugin packages under the global app data directory. #[async_trait] pub trait PluginPackageStore: Send + Sync { diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 89dc887..6f55a72 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -846,6 +846,53 @@ fn toml_string(s: &str) -> String { json_string(s) } +/// One effort/reasoning preset a profile natively exposes to the UI. +/// Declaration order is light to deep and is never reordered by consumers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffortOption { + /// Raw value forwarded to the CLI/session (e.g. Codex's `"medium"`). + pub value: String, + /// Human-readable label for the UI droplist. + pub label: String, + /// Optional short description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +/// A per-agent effort choice, preserving whether the value came from a profile +/// preset or free text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum EffortSelection { + /// Picked from the profile's declared `effort_options`. + Preset(String), + /// Freehand value. + Custom(String), +} + +impl EffortSelection { + /// Raw effort value forwarded to the session, regardless of provenance. + #[must_use] + pub fn value(&self) -> &str { + match self { + Self::Preset(value) | Self::Custom(value) => value, + } + } +} + +/// Resolves the raw effort value forwarded to a session launch: the agent's +/// explicit selection wins; otherwise the profile's static default. +#[must_use] +pub fn resolve_effort( + profile_default: Option<&str>, + agent_selection: Option<&EffortSelection>, +) -> Option { + agent_selection + .map(|selection| selection.value().to_owned()) + .or_else(|| profile_default.map(str::to_owned)) +} + /// Declarative runtime configuration for one AI CLI. /// /// Invariants: @@ -914,6 +961,10 @@ pub struct AgentProfile { /// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_reasoning_effort: Option, + /// Effort/reasoning presets this profile natively exposes (ticket #131). + /// Empty means the provider declares none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effort_options: Vec, /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et @@ -1122,6 +1173,7 @@ impl AgentProfile { opencode_provider: None, model: None, model_reasoning_effort: None, + effort_options: Vec::new(), mcp: None, liveness: None, rate_limit_pattern: None, @@ -1186,6 +1238,13 @@ impl AgentProfile { self } + /// Builder : fixe les presets d'effort exposés par ce profil. + #[must_use] + pub fn with_effort_options(mut self, options: Vec) -> Self { + self.effort_options = options; + self + } + /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans MCP ne l'appellent simplement pas. @@ -1519,6 +1578,88 @@ mod mcp_tests { assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium")); } + #[test] + fn effort_option_round_trips_camel_case() { + let option = EffortOption { + value: "medium".to_owned(), + label: "Medium".to_owned(), + hint: Some("Balanced".to_owned()), + }; + + let json = serde_json::to_string(&option).expect("serialise"); + assert_eq!( + json, + r#"{"value":"medium","label":"Medium","hint":"Balanced"}"# + ); + let back: EffortOption = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back, option); + } + + #[test] + fn effort_selection_preset_and_custom_value_accessor() { + assert_eq!(EffortSelection::Preset("high".to_owned()).value(), "high"); + assert_eq!( + EffortSelection::Custom("provider-x".to_owned()).value(), + "provider-x" + ); + } + + #[test] + fn effort_selection_tagged_serde_shape() { + let preset = serde_json::to_string(&EffortSelection::Preset("medium".to_owned())) + .expect("serialise"); + assert_eq!(preset, r#"{"kind":"preset","value":"medium"}"#); + + let custom = serde_json::to_string(&EffortSelection::Custom("x-deep".to_owned())) + .expect("serialise"); + assert_eq!(custom, r#"{"kind":"custom","value":"x-deep"}"#); + } + + #[test] + fn resolve_effort_prefers_agent_selection_over_profile_default() { + let selection = EffortSelection::Preset("high".to_owned()); + assert_eq!( + resolve_effort(Some("low"), Some(&selection)).as_deref(), + Some("high") + ); + } + + #[test] + fn resolve_effort_falls_back_to_profile_default_when_agent_selection_absent() { + assert_eq!(resolve_effort(Some("low"), None).as_deref(), Some("low")); + } + + #[test] + fn resolve_effort_none_when_neither_present() { + assert_eq!(resolve_effort(None, None), None); + } + + #[test] + fn agent_profile_new_effort_options_defaults_empty_and_serialises_omitted() { + let profile = profile_without_mcp(); + assert!(profile.effort_options.is_empty()); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!( + !json.contains("\"effortOptions\""), + "a profile without effort options must keep the legacy JSON shape: {json}" + ); + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert!(back.effort_options.is_empty()); + } + + #[test] + fn with_effort_options_builder_sets_field() { + let option = EffortOption { + value: "high".to_owned(), + label: "High".to_owned(), + hint: None, + }; + let profile = profile_without_mcp().with_effort_options(vec![option.clone()]); + + assert_eq!(profile.effort_options, vec![option]); + } + #[test] fn opencode_backend_consistency_rejects_both_configs_set() { let local = OpenCodeConfig::new( diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index cb8ced4..86fd50e 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -24,6 +24,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } +zip = { version = "2", default-features = false, features = ["deflate"] } # AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2). # Already vendored transitively (rustls/reqwest use it) — made an explicit direct # dependency here rather than adding a new crate to the tree. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 8441c14..1ecb3d9 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -88,7 +88,9 @@ pub use orchestrator::{ }; pub use pair_attempt_limiter::InMemoryPairAttemptLimiter; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; -pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore}; +pub use plugin::{ + ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, +}; pub use process::{LocalEnvironmentReader, LocalProcessSpawner}; pub use pty::PortablePtyAdapter; pub use ratelimit::RateLimitParser; diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index e0a5435..ccf41b0 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_stop_agent", "idea_update_context", "idea_context_propose", + "idea_context_update", "idea_memory_write", "idea_workstate_set", "idea_create_skill", @@ -166,7 +167,7 @@ pub fn catalogue() -> Vec { name: "idea_list_agents", description: "List the IdeA agents declared in the project's manifest. Returns the \ agents inline as a JSON array (id, name, profile, origin, raw skills, \ - and resolved capabilities).", + resolved capabilities, and isOrchestrator).", input_schema: json!({ "type": "object", "properties": {}, @@ -308,6 +309,23 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_context_update", + description: "Directly update the global project context. Strict, orchestrator-only \ + counterpart to idea_context_propose: if you are not the project's \ + current orchestrator (see isOrchestrator on idea_list_agents), this call \ + fails and never silently files a proposal. Pass ifMatch from a prior \ + idea_context_read to reject the write if the context changed.", + input_schema: json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "The new Markdown body." }, + "ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." } + }, + "required": ["content"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_memory_read", description: "Read project memory (under the file guard). Omit `slug` for the aggregated \ @@ -485,6 +503,16 @@ pub fn map_tool_call( content: s("content"), ..base() }, + "idea_context_update" => { + reject_unknown_keys(name, args, &["content", "ifMatch"])?; + OrchestratorRequest { + request_type: Some("context.update".to_owned()), + requested_by: Some(requester.to_owned()), + content: s("content"), + if_match: s("ifMatch"), + ..base() + } + } "idea_memory_read" => OrchestratorRequest { request_type: Some("memory.read".to_owned()), requested_by: Some(requester.to_owned()), @@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest { result: None, ticket: None, content: None, + if_match: None, slug: None, status: None, intent: None, @@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest { } } +fn reject_unknown_keys( + tool: &str, + args: &serde_json::Map, + allowed: &[&str], +) -> Result<(), ToolMapError> { + if args + .keys() + .any(|key| !allowed.iter().any(|allowed_key| allowed_key == key)) + { + return Err(ToolMapError::BadArguments(tool.to_owned())); + } + Ok(()) +} + /// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently /// dropping a malformed/absent one (validation then rejects a `visible` launch /// missing its node, with a precise field error). @@ -880,6 +923,51 @@ mod tests { assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } + #[test] + fn idea_context_update_tool_registered_and_maps_to_context_update_command() { + assert_eq!( + tool_access("idea_context_update"), + Some(McpToolAccess::WriteAction) + ); + assert!(catalogue() + .into_iter() + .any(|tool| tool.name == "idea_context_update")); + + let cmd = map_tool_call( + "idea_context_update", + &json!({ "content": "# body", "ifMatch": "abc123" }), + REQ, + ) + .unwrap(); + + assert_eq!( + cmd, + OrchestratorCommand::UpdateProjectContext { + content: "# body".to_owned(), + if_match: Some("abc123".to_owned()), + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + } + + #[test] + fn idea_context_update_requires_content_and_rejects_unknown_fields() { + let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ); + assert!(matches!(missing, Err(ToolMapError::Invalid(_)))); + + let unknown = map_tool_call( + "idea_context_update", + &json!({ "content": "# body", "target": "Dev" }), + REQ, + ); + assert_eq!( + unknown, + Err(ToolMapError::BadArguments("idea_context_update".to_owned())) + ); + } + #[test] fn memory_read_and_write_map_to_their_commands() { assert_eq!( diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index 6277db8..0f35048 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; -use std::io::Read; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex, MutexGuard}; @@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use domain::ports::{ LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, - PluginRegistryError, PluginRegistryStore, PluginStoreError, + PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore, + PluginStoreError, }; use domain::{ ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec, @@ -23,6 +24,14 @@ use tokio::process::Child; const REGISTRY_FILE: &str = "registry.json"; const MANIFEST_FILE: &str = "idea-plugin.json"; +const STORAGE_ENTRIES_DIR: &str = "entries"; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PluginStorageEntry { + key: String, + value: serde_json::Value, +} /// Filesystem package store under app-data `plugins/`. #[derive(Debug, Clone)] @@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore { archive: &LocalPath, ) -> Result { let stage = self.stage_root()?; - let status = std::process::Command::new("unzip") - .arg("-q") - .arg(archive.as_str()) - .arg("-d") - .arg(&stage) - .status() - .map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?; - if !status.success() { - return Err(PluginStoreError::Format(format!( - "unzip exited with status {status}" - ))); - } + extract_archive_confined(Path::new(archive.as_str()), &stage)?; ensure_manifest(&stage)?; let content_hash = hash_dir(&stage)?; Ok(StagedPluginPackage { @@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> { } } +fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> { + let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?; + let entry_name = entry.name().to_owned(); + let enclosed = entry.enclosed_name().ok_or_else(|| { + PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}")) + })?; + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err(PluginStoreError::Invalid(format!( + "archive entry symlinks are not allowed: {entry_name}" + ))); + } + let destination = stage.join(&enclosed); + if entry.is_dir() { + fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?; + continue; + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?; + } + let mut out = + fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?; + io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?; + out.flush() + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + } + Ok(()) +} + fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> { fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?; for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? { @@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> { copy_dir_all(&entry.path(), &dest)?; } else if ty.is_file() { fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?; + } else if ty.is_symlink() { + return Err(PluginStoreError::Invalid(format!( + "plugin source contains symlink: {}", + entry.path().display() + ))); + } else { + return Err(PluginStoreError::Invalid(format!( + "plugin source contains unsupported entry: {}", + entry.path().display() + ))); } } Ok(()) @@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec) -> Result<(), PluginStor collect_files(&entry.path(), files)?; } else if ty.is_file() { files.push(entry.path()); + } else if ty.is_symlink() { + return Err(PluginStoreError::Invalid(format!( + "plugin package contains symlink: {}", + entry.path().display() + ))); + } else { + return Err(PluginStoreError::Invalid(format!( + "plugin package contains unsupported entry: {}", + entry.path().display() + ))); } } Ok(()) @@ -354,6 +409,97 @@ impl FsPluginRegistryStore { } } +/// Filesystem plugin-owned storage under app-data `plugins/data//`. +#[derive(Debug, Clone)] +pub struct FsPluginStorageStore { + root: PathBuf, +} + +impl FsPluginStorageStore { + /// Builds the store. + #[must_use] + pub fn new(app_data_dir: impl Into) -> Self { + Self { + root: app_data_dir.into().join("plugins").join("data"), + } + } + + fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf { + self.root.join(plugin_id.as_str()) + } + + fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf { + let digest = Sha256::digest(key.as_bytes()); + self.plugin_dir(plugin_id) + .join(STORAGE_ENTRIES_DIR) + .join(format!("{}.json", hex::encode(digest))) + } +} + +#[async_trait] +impl PluginStorageStore for FsPluginStorageStore { + async fn get( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result, PluginStorageError> { + let path = self.entry_path(plugin_id, key); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?; + let entry: PluginStorageEntry = serde_json::from_slice(&bytes) + .map_err(|e| PluginStorageError::Serialization(e.to_string()))?; + if entry.key == key { + Ok(Some(entry.value)) + } else { + Err(PluginStorageError::Invalid( + "plugin storage key hash collision".to_owned(), + )) + } + } + + async fn set( + &self, + plugin_id: &PluginId, + key: &str, + value: serde_json::Value, + ) -> Result<(), PluginStorageError> { + let path = self.entry_path(plugin_id, key); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?; + } + let entry = PluginStorageEntry { + key: key.to_owned(), + value, + }; + let bytes = serde_json::to_vec_pretty(&entry) + .map_err(|e| PluginStorageError::Serialization(e.to_string()))?; + fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string())) + } + + async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result { + let path = self.entry_path(plugin_id, key); + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(PluginStorageError::Io(e.to_string())), + } + } + + async fn purge_plugin( + &self, + plugin_id: &PluginId, + ) -> Result { + let dir = self.plugin_dir(plugin_id); + match fs::remove_dir_all(dir) { + Ok(()) => Ok(RemovalOutcome::Removed), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound), + Err(e) => Err(PluginStorageError::Io(e.to_string())), + } + } +} + #[async_trait] impl PluginRegistryStore for FsPluginRegistryStore { async fn load_registry(&self) -> Result { @@ -574,6 +720,99 @@ mod tests { .unwrap(); } + struct ZipEntrySpec<'a> { + name: &'a str, + contents: &'a [u8], + unix_mode: Option, + } + + fn write_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn write_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc + } + + fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) { + let mut file = fs::File::create(path).unwrap(); + let mut central = Vec::new(); + let mut offset = 0u32; + for entry in entries { + let name = entry.name.as_bytes(); + let data = entry.contents; + let crc = crc32(data); + let local_size = 30u32 + name.len() as u32 + data.len() as u32; + + let mut local = Vec::new(); + write_u32(&mut local, 0x0403_4b50); + write_u16(&mut local, 20); + write_u16(&mut local, 0); + write_u16(&mut local, 0); + write_u16(&mut local, 0); + write_u16(&mut local, 0); + write_u32(&mut local, crc); + write_u32(&mut local, data.len() as u32); + write_u32(&mut local, data.len() as u32); + write_u16(&mut local, name.len() as u16); + write_u16(&mut local, 0); + local.extend_from_slice(name); + local.extend_from_slice(data); + file.write_all(&local).unwrap(); + + let mut header = Vec::new(); + let version_made_by = if entry.unix_mode.is_some() { + (3u16 << 8) | 20 + } else { + 20 + }; + let external_attributes = entry.unix_mode.unwrap_or(0) << 16; + write_u32(&mut header, 0x0201_4b50); + write_u16(&mut header, version_made_by); + write_u16(&mut header, 20); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u32(&mut header, crc); + write_u32(&mut header, data.len() as u32); + write_u32(&mut header, data.len() as u32); + write_u16(&mut header, name.len() as u16); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u16(&mut header, 0); + write_u32(&mut header, external_attributes); + write_u32(&mut header, offset); + header.extend_from_slice(name); + central.extend_from_slice(&header); + offset += local_size; + } + file.write_all(¢ral).unwrap(); + let mut eocd = Vec::new(); + write_u32(&mut eocd, 0x0605_4b50); + write_u16(&mut eocd, 0); + write_u16(&mut eocd, 0); + write_u16(&mut eocd, entries.len() as u16); + write_u16(&mut eocd, entries.len() as u16); + write_u32(&mut eocd, central.len() as u32); + write_u32(&mut eocd, offset); + write_u16(&mut eocd, 0); + file.write_all(&eocd).unwrap(); + } + #[derive(Default)] struct RecordingBridge { started: Mutex>, @@ -671,35 +910,23 @@ mod tests { #[tokio::test] async fn extracts_archive_without_path_escape() { - if std::process::Command::new("zip") - .arg("-h") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - || std::process::Command::new("unzip") - .arg("-h") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - return; - } let app = temp_dir("archive-app"); - let source = temp_dir("archive-source"); - write_plugin(&source, "bundle"); let archive_path = app.join("plugin.ideaplug"); - { - let status = std::process::Command::new("zip") - .arg("-qr") - .arg(&archive_path) - .arg(".") - .current_dir(&source) - .status() - .unwrap(); - assert!(status.success()); - } + write_zip( + &archive_path, + &[ + ZipEntrySpec { + name: "idea-plugin.json", + contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#, + unix_mode: Some(0o100644), + }, + ZipEntrySpec { + name: "dist/index.js", + contents: b"bundle", + unix_mode: Some(0o100644), + }, + ], + ); let store = FsPluginPackageStore::new(app.join("data")); let staged = store .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) @@ -707,9 +934,142 @@ mod tests { .unwrap(); assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists()); let _ = fs::remove_dir_all(app); + } + + #[tokio::test] + async fn install_from_directory_rejects_source_symlink() { + let app = temp_dir("symlink-app"); + let source = temp_dir("symlink-source"); + write_plugin(&source, "bundle"); + let outside = app.join("outside.txt"); + fs::write(&outside, "secret").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap(); + let store = FsPluginPackageStore::new(&app); + + let err = store + .install_from_directory(&LocalPath::new(source.to_string_lossy())) + .await + .unwrap_err(); + + assert!(matches!(err, PluginStoreError::Invalid(_))); + assert!(err.to_string().contains("symlink")); + assert!(!app.join("plugins/installed/dev.acme.test").exists()); + let _ = fs::remove_dir_all(app); let _ = fs::remove_dir_all(source); } + #[tokio::test] + async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() { + let app = temp_dir("traversal-app"); + let archive_path = app.join("plugin.ideaplug"); + write_zip( + &archive_path, + &[ + ZipEntrySpec { + name: "../../../../outside.txt", + contents: b"pwned", + unix_mode: Some(0o100644), + }, + ZipEntrySpec { + name: "idea-plugin.json", + contents: br#"{"ideaPluginManifestVersion":1}"#, + unix_mode: Some(0o100644), + }, + ], + ); + let store = FsPluginPackageStore::new(&app); + + let err = store + .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) + .await + .unwrap_err(); + + assert!(matches!(err, PluginStoreError::Invalid(_))); + assert!(err.to_string().contains("escapes plugin root")); + assert!(!app.join("outside.txt").exists()); + let _ = fs::remove_dir_all(app); + } + + #[tokio::test] + async fn install_from_archive_rejects_symlink_entries() { + let app = temp_dir("archive-symlink-app"); + let archive_path = app.join("plugin.ideaplug"); + write_zip( + &archive_path, + &[ + ZipEntrySpec { + name: "idea-plugin.json", + contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#, + unix_mode: Some(0o100644), + }, + ZipEntrySpec { + name: "dist/link.js", + contents: b"/tmp/outside.js", + unix_mode: Some(0o120777), + }, + ], + ); + let store = FsPluginPackageStore::new(&app); + + let err = store + .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) + .await + .unwrap_err(); + + assert!(matches!(err, PluginStoreError::Invalid(_))); + assert!(err.to_string().contains("symlinks are not allowed")); + assert!(!app.join("plugins/installed/dev.acme.test").exists()); + let _ = fs::remove_dir_all(app); + } + + #[tokio::test] + async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() { + let app = temp_dir("storage-app"); + let store = FsPluginStorageStore::new(&app); + let plugin_id = PluginId::new("dev.acme.test").unwrap(); + + assert_eq!( + store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), + None + ); + store + .set( + &plugin_id, + "helloPlugin.launches", + serde_json::json!({"count": 1}), + ) + .await + .unwrap(); + assert_eq!( + store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), + Some(serde_json::json!({"count": 1})) + ); + assert!(app.join("plugins/data/dev.acme.test/entries").is_dir()); + + assert!(store + .delete(&plugin_id, "helloPlugin.launches") + .await + .unwrap()); + assert_eq!( + store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), + None + ); + store + .set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true)) + .await + .unwrap(); + assert_eq!( + store.purge_plugin(&plugin_id).await.unwrap(), + RemovalOutcome::Removed + ); + assert!(!app.join("plugins/data/dev.acme.test").exists()); + + let _ = fs::remove_dir_all(app); + } + #[tokio::test] async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() { let bridge = Arc::new(RecordingBridge::default()); diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index a30dd30..29f5466 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -108,6 +108,7 @@ impl FakeContexts { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 7833589..a5bae6e 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -97,6 +97,7 @@ impl FakeContexts { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index 49af4a0..01cf70f 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -8,7 +8,7 @@ use application::{ ListPlugins, UninstallPlugin, UninstallPluginInput, }; use infrastructure::{ - ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, + ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, TokioBroadcastEventBus, }; @@ -39,6 +39,48 @@ fn sdk_hello_plugin_path() -> PathBuf { .join("sdk/IdeaSDK/examples/hello-plugin") } +fn materialize_sdk_hello_plugin() -> PathBuf { + let source = sdk_hello_plugin_path(); + let root = temp_dir("hello-plugin-built"); + fs::create_dir_all(root.join("dist")).unwrap(); + fs::write( + root.join("idea-plugin.json"), + fs::read(source.join("idea-plugin.json")).unwrap(), + ) + .unwrap(); + fs::write( + root.join("dist/index.js"), + "export function activate() { return 'hello-plugin'; }", + ) + .unwrap(); + root +} + +fn write_multifile_plugin(root: &Path) { + fs::create_dir_all(root.join("dist/core")).unwrap(); + fs::write( + root.join("idea-plugin.json"), + r#"{ + "ideaPluginManifestVersion": 1, + "id": "dev.acme.multifile", + "displayName": "Multifile Plugin", + "version": "0.1.0", + "main": "dist/index.js", + "trustLevel": "full", + "capabilities": ["ui"], + "contributes": {} + }"#, + ) + .unwrap(); + fs::write( + root.join("dist/index.js"), + "import { answer } from './constants.js'; export default answer;", + ) + .unwrap(); + fs::write(root.join("dist/constants.js"), "export const answer = 42;").unwrap(); + fs::write(root.join("dist/core/util.js"), "export const util = true;").unwrap(); +} + #[tokio::test] async fn installs_reference_fixture_and_loads_runtime_catalog() { let app_data = temp_dir("app-data"); @@ -100,7 +142,7 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() { #[tokio::test] async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { let app_data = temp_dir("hello-app-data"); - let hello_plugin = sdk_hello_plugin_path(); + let hello_plugin = materialize_sdk_hello_plugin(); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); @@ -152,14 +194,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world"); let _ = fs::remove_dir_all(app_data); + let _ = fs::remove_dir_all(hello_plugin); } #[tokio::test] async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { let app_data = temp_dir("hello-reinstall-app-data"); - let hello_plugin = sdk_hello_plugin_path(); + let hello_plugin = materialize_sdk_hello_plugin(); let packages = Arc::new(FsPluginPackageStore::new(&app_data)); let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); + let storage = Arc::new(FsPluginStorageStore::new(&app_data)); let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); let events = Arc::new(TokioBroadcastEventBus::new()); let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); @@ -170,7 +214,7 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { events.clone(), mcp.clone(), ); - let uninstall = UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp); + let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp); install .execute(hello_plugin.to_string_lossy().into_owned()) @@ -225,6 +269,76 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() { assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js")); let _ = fs::remove_dir_all(app_data); + let _ = fs::remove_dir_all(hello_plugin); +} + +#[tokio::test] +async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue() { + let app_data = temp_dir("multifile-uninstall-app-data"); + let source = temp_dir("multifile-source"); + write_multifile_plugin(&source); + let packages = Arc::new(FsPluginPackageStore::new(&app_data)); + let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); + let storage = Arc::new(FsPluginStorageStore::new(&app_data)); + let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); + let events = Arc::new(TokioBroadcastEventBus::new()); + let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); + let install = InstallPluginFromDirectory::new( + packages.clone(), + registry.clone(), + validator.clone(), + events.clone(), + mcp.clone(), + ); + let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp); + + install + .execute(source.to_string_lossy().into_owned()) + .await + .unwrap(); + assert!(app_data + .join("plugins/installed/dev.acme.multifile/dist/constants.js") + .is_file()); + assert!(app_data + .join("plugins/installed/dev.acme.multifile/dist/core/util.js") + .is_file()); + let plugin_data = app_data.join("plugins/data/dev.acme.multifile"); + fs::create_dir_all(&plugin_data).unwrap(); + fs::write(plugin_data.join("state.json"), r#"{"launches":1}"#).unwrap(); + + let uninstall_result = uninstall + .execute(UninstallPluginInput { + plugin_id: "dev.acme.multifile".to_owned(), + }) + .await + .unwrap(); + + assert_eq!( + uninstall_result.removal_outcome, + domain::RemovalOutcome::Removed + ); + assert!(!app_data + .join("plugins/installed/dev.acme.multifile") + .exists()); + assert!(!app_data.join("plugins/data/dev.acme.multifile").exists()); + assert!( + ListPlugins::new(packages.clone(), registry.clone(), validator.clone()) + .execute() + .await + .unwrap() + .is_empty() + ); + assert!( + ListPluginRuntimeContributions::new(packages, registry, validator) + .execute() + .await + .unwrap() + .plugins + .is_empty() + ); + + let _ = fs::remove_dir_all(app_data); + let _ = fs::remove_dir_all(source); } #[tokio::test] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 00901b9..2523c32 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -52,10 +52,11 @@ use application::{ ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, - UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, - UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, - UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, - UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, + UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, + UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, + UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, + WriteToTerminalInput, }; use domain::ports::PtyHandle; use domain::IssueActor; @@ -74,16 +75,16 @@ use backend::dto::{ ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, - GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, - InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, - MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, - ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, - ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, - RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, + EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, + MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, @@ -97,8 +98,9 @@ use backend::dto::{ TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, - UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, + UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, }; @@ -2426,6 +2428,7 @@ async fn invoke( "update_agent_permissions" => { invoke_update_agent_permissions(&request.args, &state.app).await } + "update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await, "resolve_agent_permissions" => { invoke_resolve_agent_permissions(&request.args, &state.app).await } @@ -3482,9 +3485,10 @@ async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result Result { + let request = required_request::("update_agent_effort", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_agent_effort + .execute(UpdateAgentEffortInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + effort: request.effort, + }) + .await + .map(|out| AgentDto::from_agent(out.agent)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_resolve_agent_permissions( args: &Value, state: &BackendCore, @@ -3634,7 +3654,7 @@ async fn invoke_resolve_agent_permissions( agent_id: parse_agent_id(&request.agent_id)?, }) .await - .map(|out| out.effective.map(EffectivePermissionsDto)) + .map(ResolveAgentPermissionsResponseDto::from) .map_err(ErrorDto::from)?; serde_json::to_value(output).map_err(serialization_error) } @@ -7895,6 +7915,7 @@ mod tests { "get_project_permissions", "update_project_permissions", "update_agent_permissions", + "update_agent_effort", "resolve_agent_permissions", "get_project_system_permissions", "update_project_system_permissions", diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index e3ae6a2..4f77b6c 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -139,4 +139,25 @@ describe("TauriAgentGateway invoke payloads", () => { ); expect(out.relaunchedSession).toBeUndefined(); }); + + it("update_agent_effort wraps the nullable effort override in the request DTO", async () => { + invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } }); + await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", { + kind: "preset", + value: "high", + }); + expect(invoke).toHaveBeenCalledWith("update_agent_effort", { + request: { + projectId: "proj-1", + agentId: "agent-2", + effort: { kind: "preset", value: "high" }, + }, + }); + + invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" }); + await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null); + expect(invoke).toHaveBeenCalledWith("update_agent_effort", { + request: { projectId: "proj-1", agentId: "agent-2", effort: null }, + }); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 491607e..4ff3d07 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import type { Agent, + EffortSelection, ResumableAgent, TerminalSession, } from "@/domain"; @@ -105,6 +106,16 @@ export class TauriAgentGateway implements AgentGateway { ); } + updateAgentEffort( + projectId: string, + agentId: string, + effort: EffortSelection | null, + ): Promise { + return invoke("update_agent_effort", { + request: { projectId, agentId, effort }, + }); + } + readContext(projectId: string, agentId: string): Promise { return invoke("read_agent_context", { projectId, agentId }); } diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index fe6be6b..750d56c 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -48,6 +48,7 @@ import { WebPluginConfigGateway, WebPluginEventGateway, WebPluginGateway, + WebPluginStorageGateway, WebPluginTaskGateway, WebPluginToolchainGateway, WebPluginWorkspaceGateway, @@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway pluginToolchain: new WebPluginToolchainGateway(), pluginEvents: new WebPluginEventGateway(), pluginConfig: new WebPluginConfigGateway(), + pluginStorage: new WebPluginStorageGateway(), }; } diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 01c3ef4..0af3231 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -15,7 +15,6 @@ import type { Agent, AgentDrift, AgentProfile, - EffectivePermissions, EmbedderEngines, EmbedderProfile, FirstRunState, @@ -43,6 +42,7 @@ import type { ProjectSystemPermissions, ProfileAvailability, ProfileModelCatalog, + ResolvedAgentPermissions, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway { } createSkill(input: CreateSkillInput): Promise { return this.http.invoke("create_skill", { - request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope }, + request: { + projectId: input.projectId, + name: input.name, + description: input.description, + kind: input.kind, + content: input.content, + scope: input.scope, + }, }); } updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise { @@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway { request: { projectId, agentId, permissions }, }); } - resolveAgentPermissions(projectId: string, agentId: string): Promise { - return this.http.invoke("resolve_agent_permissions", { + resolveAgentPermissions(projectId: string, agentId: string): Promise { + return this.http.invoke("resolve_agent_permissions", { request: { projectId, agentId }, }); } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index fe45103..b1edfd9 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -23,6 +23,7 @@ import type { Agent, AppExitWorkGuardState, DomainEvent, + EffortSelection, HealthReport, ReplyChunk, ResumableAgent, @@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway { request: { projectId, agentId, profileId, rows, cols }, }); } + updateAgentEffort( + projectId: string, + agentId: string, + effort: EffortSelection | null, + ): Promise { + return this.http.invoke("update_agent_effort", { + request: { projectId, agentId, effort }, + }); + } readContext(projectId: string, agentId: string): Promise { return this.http.invoke("read_agent_context", { projectId, agentId }); } diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index b747907..096944f 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -12,6 +12,7 @@ import type { EmbeddedServerStatus, GatewayError, + JsonValue, PluginAdmin, PluginCommandTask, PluginConfigDocument, @@ -44,6 +45,9 @@ import type { PluginEventSubscribeInput, PluginEventUnsubscribeInput, PluginGateway, + PluginStorageGateway, + PluginStorageGetInput, + PluginStorageSetInput, PluginProjectStructureQuery, PluginRunCommandInput, PluginTaskGateway, @@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway { return unsupportedOnWeb("Plugin structured config documents"); } } + +/** Web stub: plugin-owned storage is owned by the desktop host app-data. */ +export class WebPluginStorageGateway implements PluginStorageGateway { + async get(_input: PluginStorageGetInput): Promise { + return unsupportedOnWeb("Plugin storage"); + } + + async set(_input: PluginStorageSetInput): Promise { + return unsupportedOnWeb("Plugin storage"); + } + + async delete(_input: PluginStorageGetInput): Promise { + return unsupportedOnWeb("Plugin storage"); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 6bc8e3d..8f9a7b8 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -40,6 +40,7 @@ import { TauriPluginTaskGateway } from "./pluginTask"; import { TauriPluginToolchainGateway } from "./pluginToolchain"; import { TauriPluginEventGateway } from "./pluginEvents"; import { TauriPluginConfigGateway } from "./pluginConfig"; +import { TauriPluginStorageGateway } from "./pluginStorage"; function notImplemented(what: string): never { const err: GatewayError = { @@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways { pluginToolchain: new TauriPluginToolchainGateway(), pluginEvents: new TauriPluginEventGateway(), pluginConfig: new TauriPluginConfigGateway(), + pluginStorage: new TauriPluginStorageGateway(), }; } @@ -114,4 +116,5 @@ export { TauriFocusedProjectGateway, LocalStorageUiPreferencesGateway, TauriPluginGateway, + TauriPluginStorageGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3faa9e1..d27b47e 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -11,6 +11,7 @@ import type { AgentProfile, DiagnosticWarning, DomainEvent, + EffortSelection, EmbedderEngines, EmbedderProfile, EmbeddedServerStatus, @@ -37,7 +38,6 @@ import type { OpenCodeProviderCatalogEntry, ProfileModelCatalog, ProfileModelCatalogEntry, - EffectivePermissions, PairedDevice, PairingCode, PermissionSet, @@ -72,6 +72,7 @@ import type { ProjectSystemPermissions, ProfileAvailability, ResumableAgent, + ResolvedAgentPermissions, ResolvedAgentSystemPermissions, ServerExposurePreview, ServerExposureSettings, @@ -129,6 +130,9 @@ import type { PluginEventSubscribeInput, PluginEventUnsubscribeInput, PluginGateway, + PluginStorageGateway, + PluginStorageGetInput, + PluginStorageSetInput, PluginProjectStructureQuery, PluginRunCommandInput, PluginTaskGateway, @@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway { }; } + async updateAgentEffort( + projectId: string, + agentId: string, + effort: EffortSelection | null, + ): Promise { + const list = this.getAgents(projectId); + const idx = list.findIndex((a) => a.id === agentId); + if (idx === -1) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + const next = + effort === null + ? (({ effort: _dropped, ...rest }) => rest)(list[idx]) + : { ...list[idx], effort }; + list[idx] = next; + return structuredClone(next); + } + // ── Internal helpers for MockTemplateGateway (same-package use only) ── /** @@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway { const skill: Skill = { id: `mock-skill-${this.seq}`, name: input.name, + description: input.description ?? null, + kind: input.kind ?? "workflow", contentMd: input.content, scope: input.scope, }; @@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway { async resolveAgentPermissions( projectId: string, agentId: string, - ): Promise { + ): Promise { const doc = this.doc(projectId); const project = doc.projectDefaults; const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions; - if (!project && !agent) return null; + const shadowed = permissionShadowReport(project, agent); + if (!project && !agent) return { effective: null, shadowed }; return { - rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])], - fallback: mostRestrictive(project?.fallback, agent?.fallback), + effective: { + rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])], + fallback: mostRestrictive(project?.fallback, agent?.fallback), + }, + shadowed, }; } @@ -3466,6 +3498,47 @@ function mostRestrictive( return rank[agent] >= rank[fallback] ? agent : fallback; } +function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) { + const empty = { + read: false, + write: false, + delete: false, + executeBash: false, + fallback: false, + }; + if (!agent) return empty; + const shadowed = (capability: "read" | "write" | "delete" | "executeBash") => + blanketEffect(project, capability, "deny") === "deny" && + blanketEffect(agent, capability, "allow") === "allow"; + return { + read: shadowed("read"), + write: shadowed("write"), + delete: shadowed("delete"), + executeBash: shadowed("executeBash"), + fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback), + }; +} + +function blanketEffect( + set: PermissionSet | undefined, + capability: "read" | "write" | "delete" | "executeBash", + wins: "allow" | "deny", +) { + let found: "allow" | "deny" | undefined; + for (const rule of set?.rules ?? []) { + if (rule.capability !== capability || !isBlanketRule(rule)) continue; + if (rule.effect === wins) return rule.effect; + found = rule.effect; + } + return found; +} + +function isBlanketRule(rule: PermissionSet["rules"][number]) { + if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0; + const paths = rule.paths ?? []; + return paths.length === 1 && paths[0] === "**"; +} + /** * In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely * enough to develop/test F1-F4 without the backend (B1-B4, landing in @@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway { } } +/** + * In-memory plugin-owned storage gateway for plugin runtime tests/dev. + */ +export class MockPluginStorageGateway implements PluginStorageGateway { + private readonly values = new Map(); + + private storageKey(input: PluginStorageGetInput): string { + if (!input.pluginId.trim()) { + const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" }; + throw err; + } + if (!input.key.trim()) { + const err: GatewayError = { code: "INVALID", message: "key must not be empty" }; + throw err; + } + return `${input.pluginId}:${input.key}`; + } + + async get(input: PluginStorageGetInput): Promise { + const value = this.values.get(this.storageKey(input)); + return value === undefined ? null : cloneJson(value); + } + + async set(input: PluginStorageSetInput): Promise { + this.values.set(this.storageKey(input), cloneJson(input.value)); + } + + async delete(input: PluginStorageGetInput): Promise { + return this.values.delete(this.storageKey(input)); + } +} + /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); @@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways { pluginToolchain: new MockPluginToolchainGateway(), pluginEvents: new MockPluginEventGateway(), pluginConfig: new MockPluginConfigGateway(), + pluginStorage: new MockPluginStorageGateway(), }; } diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts index db8ad1a..8d9d9c2 100644 --- a/frontend/src/adapters/permission.ts +++ b/frontend/src/adapters/permission.ts @@ -1,12 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import type { - EffectivePermissions, McpToolPolicy, PermissionSet, ProjectMcpToolPermissions, ProjectPermissions, ProjectSystemPermissions, + ResolvedAgentPermissions, ResolvedAgentSystemPermissions, SystemPermissionSet, } from "@/domain"; @@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway { resolveAgentPermissions( projectId: string, agentId: string, - ): Promise { - return invoke("resolve_agent_permissions", { + ): Promise { + return invoke("resolve_agent_permissions", { request: { projectId, agentId }, }); } diff --git a/frontend/src/adapters/pluginStorage.ts b/frontend/src/adapters/pluginStorage.ts new file mode 100644 index 0000000..e421e19 --- /dev/null +++ b/frontend/src/adapters/pluginStorage.ts @@ -0,0 +1,26 @@ +/** + * Tauri adapter for plugin-owned JSON storage (#139). + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { JsonValue } from "@/domain"; +import type { + PluginStorageGateway, + PluginStorageGetInput, + PluginStorageSetInput, +} from "@/ports"; + +export class TauriPluginStorageGateway implements PluginStorageGateway { + get(input: PluginStorageGetInput): Promise { + return invoke("plugin_storage_get", { input }); + } + + async set(input: PluginStorageSetInput): Promise { + await invoke("plugin_storage_set", { input }); + } + + delete(input: PluginStorageGetInput): Promise { + return invoke("plugin_storage_delete", { input }); + } +} diff --git a/frontend/src/adapters/skill.ts b/frontend/src/adapters/skill.ts index e4a858f..7187eac 100644 --- a/frontend/src/adapters/skill.ts +++ b/frontend/src/adapters/skill.ts @@ -22,6 +22,8 @@ export class TauriSkillGateway implements SkillGateway { request: { projectId: input.projectId, name: input.name, + description: input.description, + kind: input.kind, content: input.content, scope: input.scope, }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 69f20a2..92382e3 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -767,6 +767,21 @@ export interface EffectivePermissions { fallback: PermissionPosture; } +/** Agent override choices shadowed by stricter project defaults. */ +export interface PermissionShadowReport { + read: boolean; + write: boolean; + delete: boolean; + executeBash: boolean; + fallback: boolean; +} + +/** Resolved file/bash permissions plus non-authoritative diagnostics. */ +export interface ResolvedAgentPermissions { + effective: EffectivePermissions | null; + shadowed: PermissionShadowReport; +} + /** Wanted/effective network policy for system permissions. */ export type NetworkPolicy = "allow" | "deny" | "ask"; @@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog { warnings: string[]; } +/** One native effort/reasoning preset declared by an AI profile. */ +export interface EffortOption { + /** Raw value persisted/forwarded to the provider. */ + value: string; + /** Human-readable label shown in selectors. */ + label: string; + /** Optional short description from the profile declaration. */ + hint?: string; +} + +/** Per-agent effort override. `undefined`/`null` means profile default. */ +export type EffortSelection = + | { kind: "preset"; value: string } + | { kind: "custom"; value: string }; + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. @@ -1192,6 +1222,16 @@ export interface AgentProfile { * CLI's own default. OpenCode keeps its dedicated provider/local model fields. */ model?: string; + /** + * Optional direct CLI reasoning effort configured on the profile. `undefined` + * keeps the CLI/provider default. + */ + modelReasoningEffort?: string; + /** + * Native effort presets exposed by this profile, in declaration order from + * light to deep. Empty/omitted means the provider declares no native options. + */ + effortOptions?: EffortOption[]; } /** Availability of a candidate profile after detection (mirror of the DTO). */ @@ -1233,6 +1273,8 @@ export interface Agent { synchronized: boolean; /** Skills assigned to this agent (injected into its convention file). */ skills: SkillRef[]; + /** Per-agent effort override. Omitted for older manifests/profile default. */ + effort?: EffortSelection; } /** @@ -1284,6 +1326,7 @@ export interface ResumableAgent { * across projects; `project` skills are specific to one project's `.ideai/`. */ export type SkillScope = "global" | "project"; +export type SkillKind = "workflow" | "reference"; /** * A reusable, model-agnostic workflow assignable to agents (mirror of the @@ -1292,6 +1335,8 @@ export type SkillScope = "global" | "project"; export interface Skill { id: string; name: string; + description?: string | null; + kind: SkillKind; contentMd: string; scope: SkillScope; } diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 7a2b245..0e2613c 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -25,7 +25,13 @@ import { useAgents } from "./useAgents"; import { correlateModelServerStatus } from "./modelServerLaunch"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; -import type { ResolvedAgentSystemPermissions } from "@/domain"; +import type { + Agent, + AgentProfile, + EffortOption, + EffortSelection, + ResolvedAgentSystemPermissions, +} from "@/domain"; export interface AgentsPanelProps { /** The project whose agents to manage. */ @@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; - const profileLabel = (profile: import("@/domain").AgentProfile): string => { + const profileLabel = (profile: AgentProfile): string => { const model = profile.model ?? profile.opencode?.model ?? @@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const isSelected = a.id === vm.selectedAgentId; const isRunning = a.id === activeAgentId; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); + const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null; const profileName = - (() => { - const p = vm.profiles.find((p) => p.id === a.profileId); - return p ? profileLabel(p) : null; - })() ?? - a.profileId; + (agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); // Source of this agent's last orchestration delegation (mcp vs // file), if any has been observed. Absent ⇒ no badge. @@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { ]} /> )} + + void vm.updateAgentEffort(a.id, effort) + } + /> {agentDrift && (