From eb09c6f69e5e77820bd5e2a63d004ae0bbb5fa90 Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Wed, 4 Nov 2026 22:45:35 +0800 Subject: [PATCH 1/4] feat(agent): let models look up a directory's workspace selection SessionCreate takes a directory workspace as rootId, segments and directoryIdentity, which no model tool returned, so models asked for a conversation in a directory guessed them. The agent service's directories query, which the workspace picker uses, is now the model tool WorkspaceLookup, and it resolves an absolute path (~ allowed) to the directory with its selection within the configured locations, or to null with the locations listed. The Bot holds it with its other coordination tools, and the SessionCreate description says where a selection comes from. Closes #6630. Generated-by: Claude Code --- packages/plugin-sdk/src/contracts.ts | 92 +++++++++ packages/plugin-sdk/src/generated/runtime.ts | 9 + plugins/agent/api/src/contract.rs | 13 ++ plugins/agent/native/src/owner/bot.rs | 7 + .../agent/native/src/owner/tests/bot_tests.rs | 7 + plugins/workspace/support/src/lib.rs | 195 +++++++++++++++--- products/maka/bundle/src/installation.ts | 7 +- .../maka/workbench/app/WorkspacePicker.tsx | 8 +- 8 files changed, 312 insertions(+), 26 deletions(-) diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index 5b7800b3da2..af35a5b10dd 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -3764,6 +3764,23 @@ export const contracts = { additionalProperties: false, required: ['kind', 'selection', 'after'], }, + { + description: + 'The directory at an absolute path (`~` stands for the home directory),\nfound within the configured workspace locations.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'path', + }, + path: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'path'], + }, ], $defs: { AgentDirectorySelection: { @@ -3810,6 +3827,35 @@ export const contracts = { additionalProperties: false, required: ['kind', 'roots'], }, + { + description: + '`directory` is null when the path is not a directory within any\nconfigured location; `roots` lists those locations either way.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'path', + }, + directory: { + anyOf: [ + { + $ref: '#/$defs/AgentDirectory', + }, + { + type: 'null', + }, + ], + }, + roots: { + type: 'array', + items: { + $ref: '#/$defs/AgentDirectory', + }, + }, + }, + additionalProperties: false, + required: ['kind', 'directory', 'roots'], + }, { type: 'object', properties: { @@ -9267,6 +9313,23 @@ export const contracts = { additionalProperties: false, required: ['kind', 'selection', 'after'], }, + { + description: + 'The directory at an absolute path (`~` stands for the home directory),\nfound within the configured workspace locations.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'path', + }, + path: { + type: 'string', + minLength: 1, + }, + }, + additionalProperties: false, + required: ['kind', 'path'], + }, ], $defs: { AgentDirectorySelection: { @@ -9313,6 +9376,35 @@ export const contracts = { additionalProperties: false, required: ['kind', 'roots'], }, + { + description: + '`directory` is null when the path is not a directory within any\nconfigured location; `roots` lists those locations either way.', + type: 'object', + properties: { + kind: { + type: 'string', + const: 'path', + }, + directory: { + anyOf: [ + { + $ref: '#/$defs/AgentDirectory', + }, + { + type: 'null', + }, + ], + }, + roots: { + type: 'array', + items: { + $ref: '#/$defs/AgentDirectory', + }, + }, + }, + additionalProperties: false, + required: ['kind', 'directory', 'roots'], + }, { type: 'object', properties: { diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 4722caa8221..0180354bc8e 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -3314,12 +3314,21 @@ export type AgentDirectories = kind: 'children'; selection: AgentDirectorySelection; after: string | null; + } + | { + kind: 'path'; + path: string; }; export type AgentDirectoryPage = | { kind: 'roots'; roots: AgentDirectory[]; } + | { + kind: 'path'; + directory: AgentDirectory | null; + roots: AgentDirectory[]; + } | { kind: 'children'; directory: AgentDirectory; diff --git a/plugins/agent/api/src/contract.rs b/plugins/agent/api/src/contract.rs index 73cc4f54827..86404c0cec2 100644 --- a/plugins/agent/api/src/contract.rs +++ b/plugins/agent/api/src/contract.rs @@ -370,6 +370,12 @@ pub enum Directories { #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] after: Option, }, + /// The directory at an absolute path (`~` stands for the home directory), + /// found within the configured workspace locations. + Path { + #[schemars(length(min = 1))] + path: String, + }, } #[derive(Serialize, JsonSchema, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -386,6 +392,13 @@ pub enum DirectoryPage { Roots { roots: Vec, }, + /// `directory` is null when the path is not a directory within any + /// configured location; `roots` lists those locations either way. + Path { + #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] + directory: Option, + roots: Vec, + }, Children { directory: Directory, #[schemars(length(max = 32))] diff --git a/plugins/agent/native/src/owner/bot.rs b/plugins/agent/native/src/owner/bot.rs index c9a9f43377e..fe07e3655c3 100644 --- a/plugins/agent/native/src/owner/bot.rs +++ b/plugins/agent/native/src/owner/bot.rs @@ -64,6 +64,7 @@ pub(super) const COORDINATION_TOOLS: &[&str] = &[ "SessionExecution", // Delegation: the Bot may create workers and send to or stop only the // conversations it created itself (checked against `parentSessionId`). + "WorkspaceLookup", "SessionCreate", "SessionSend", "SessionStop", @@ -1037,6 +1038,12 @@ const ABILITIES: &[Ability] = &[ "{}(尋找可派發的模型及推理檔位)", "{} (find selectable models and thinking levels for delegation)", ), + ( + &["WorkspaceLookup"], + "{}(把目录路径解析成 SessionCreate 可用的工作区选择)", + "{}(把目錄路徑解析成 SessionCreate 可用的工作區選擇)", + "{} (turn a directory path into the workspace selection SessionCreate takes)", + ), ( &["SessionCreate", "SessionSend", "SessionStop"], "{}(新建工作会话并派活;只能给自己创建的会话发消息或停止)", diff --git a/plugins/agent/native/src/owner/tests/bot_tests.rs b/plugins/agent/native/src/owner/tests/bot_tests.rs index be6a0e63e2d..45c1c164b0b 100644 --- a/plugins/agent/native/src/owner/tests/bot_tests.rs +++ b/plugins/agent/native/src/owner/tests/bot_tests.rs @@ -1037,6 +1037,13 @@ fn the_instructions_name_only_the_tools_the_bot_holds() { reads.contains("SessionList / SessionRead"), "{language:?}: {reads}" ); + // The workspace lookup is named on its own, not as a session read. + assert!(!bare.contains("WorkspaceLookup"), "{language:?}: {bare}"); + let lookup = bot_prompt(language, &held(&["WorkspaceLookup"])); + assert!( + lookup.contains("WorkspaceLookup") && lookup.contains("SessionCreate"), + "{language:?}: {lookup}" + ); assert!( !reads.contains("SessionSend") && !reads.contains("SessionHistory"), "{language:?}: {reads}" diff --git a/plugins/workspace/support/src/lib.rs b/plugins/workspace/support/src/lib.rs index 0999cda2192..728410f355b 100644 --- a/plugins/workspace/support/src/lib.rs +++ b/plugins/workspace/support/src/lib.rs @@ -184,8 +184,29 @@ pub async fn query_excluding( excluded: Option, ) -> Result { tokio::task::spawn_blocking(move || { - let Directories::Children { selection, after } = input else { - return root_page(&roots); + let (selection, after) = match input { + Directories::Roots => return root_page(&roots), + Directories::Path { path } => { + let DirectoryPage::Roots { roots: locations } = root_page(&roots)? else { + unreachable!() + }; + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|home| !home.is_empty()) + .map(std::path::PathBuf::from); + let directory = located(&path, home.as_deref()) + .and_then(|path| find(&roots, &path)) + .filter(|directory| { + excluded + .as_ref() + .is_none_or(|path| !Path::new(&directory.path).starts_with(path)) + }); + return Ok(DirectoryPage::Path { + directory, + roots: locations, + }); + } + Directories::Children { selection, after } => (selection, after), }; let root = root(&roots, &selection.root_id)?; let name = selection @@ -456,32 +477,55 @@ pub async fn selection_at( if path.starts_with(&managed) { return None; } - for (index, root) in roots.iter().enumerate() { - let Ok(relative) = path.strip_prefix(root.directory.path()) else { - continue; - }; - let segments = relative - .components() - .map(|part| part.as_os_str().to_str().map(str::to_owned)) - .collect::>>()?; - let Ok(selected) = choice( - root, - format!("root-{}", index + 1), - segments, - root.name.clone(), - ) else { - continue; - }; - if Path::new(&selected.path) == path { - return Some(selected.selection); - } - } - None + find(&roots, path).map(|directory| directory.selection) }) .await .map_err(|_| "agent_directory_unavailable".into()) } +/// The directory at `path` within the first location that holds it. `path` +/// is compared as captured, so a path that reaches the directory through +/// symbolic links is not found. +fn find(roots: &[Root], path: &Path) -> Option { + for (index, root) in roots.iter().enumerate() { + let Ok(relative) = path.strip_prefix(root.directory.path()) else { + continue; + }; + let segments = relative + .components() + .map(|part| part.as_os_str().to_str().map(str::to_owned)) + .collect::>>()?; + let name = segments + .last() + .cloned() + .unwrap_or_else(|| root.name.clone()); + let Ok(selected) = choice(root, format!("root-{}", index + 1), segments, name) else { + continue; + }; + if Path::new(&selected.path) == path { + return Some(selected); + } + } + None +} + +/// A caller's path as the directory it names: a leading `~` replaced by +/// `home`, relative paths refused, symbolic links followed. None when nothing +/// is there. +fn located(path: &str, home: Option<&Path>) -> Option { + let path = match path.strip_prefix('~') { + Some(rest) if rest.is_empty() || rest.starts_with(['/', '\\']) => { + home?.join(rest.trim_start_matches(['/', '\\'])) + } + _ => std::path::PathBuf::from(path), + }; + if !path.is_absolute() { + return None; + } + let path = path.canonicalize().ok()?; + path.is_dir().then_some(path) +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -601,4 +645,109 @@ mod tests { "agent_directory_unavailable" ); } + + #[tokio::test] + async fn a_path_resolves_to_the_same_selection_browsing_reaches() { + let temporary = tempfile::tempdir().unwrap(); + let published = temporary.path().join("published"); + std::fs::create_dir_all(published.join("code").join("app")).unwrap(); + std::fs::create_dir_all(temporary.path().join("elsewhere")).unwrap(); + let roots = open( + vec![DirectoryRootSpec { + label: "Workspace".into(), + path: published.clone(), + }], + &temporary.path().join("managed"), + ) + .unwrap(); + async fn browse(roots: &[Root], segments: &[&str]) -> Directory { + let DirectoryPage::Roots { roots: entries } = + query(roots.to_vec(), Directories::Roots).await.unwrap() + else { + unreachable!() + }; + let mut directory = entries.into_iter().next().unwrap(); + for name in segments { + let DirectoryPage::Children { children, .. } = query( + roots.to_vec(), + Directories::Children { + selection: directory.selection.clone(), + after: None, + }, + ) + .await + .unwrap() else { + unreachable!() + }; + directory = children.into_iter().find(|c| c.name == *name).unwrap(); + } + directory + } + async fn lookup(roots: &[Root], path: String) -> Option { + let DirectoryPage::Path { directory, roots } = + query(roots.to_vec(), Directories::Path { path }) + .await + .unwrap() + else { + unreachable!() + }; + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].name, "Workspace"); + directory + } + let app = published.join("code").join("app"); + let found = lookup(&roots, app.to_str().unwrap().into()).await.unwrap(); + let browsed = browse(&roots, &["code", "app"]).await; + assert_eq!(found.selection, browsed.selection); + assert_eq!(found.name, "app"); + assert_eq!(found.path, browsed.path); + // The location itself, and a path written with `..` and a trailing slash. + let root = lookup(&roots, published.to_str().unwrap().into()) + .await + .unwrap(); + assert_eq!(root.selection, browse(&roots, &[]).await.selection); + let dotted = format!("{}/code/../code/app/", published.display()); + assert_eq!( + lookup(&roots, dotted).await.unwrap().selection, + browsed.selection + ); + // Outside the locations, missing, a file, and relative: no directory. + for absent in [ + temporary.path().join("elsewhere").display().to_string(), + app.join("missing").display().to_string(), + "code/app".to_owned(), + ] { + assert!(lookup(&roots, absent.clone()).await.is_none(), "{absent}"); + } + std::fs::write(app.join("file.txt"), "x").unwrap(); + assert!( + lookup(&roots, app.join("file.txt").display().to_string()) + .await + .is_none() + ); + } + + #[test] + fn a_tilde_stands_for_the_home_directory_only_at_the_start() { + let temporary = tempfile::tempdir().unwrap(); + let home = temporary.path().join("home"); + std::fs::create_dir_all(home.join("code")).unwrap(); + let canonical = home.canonicalize().unwrap(); + assert_eq!(located("~", Some(&home)), Some(canonical.clone())); + assert_eq!(located("~/code", Some(&home)), Some(canonical.join("code"))); + assert_eq!( + located("~/code/", Some(&home)), + Some(canonical.join("code")) + ); + assert_eq!(located("~/missing", Some(&home)), None); + assert_eq!(located("~/code", None), None); + // `~user` and a `~` later in the path are not expansions. + assert_eq!(located("~someone/code", Some(&home)), None); + let literal = home.join("~"); + std::fs::create_dir(&literal).unwrap(); + assert_eq!( + located(literal.to_str().unwrap(), Some(&home)), + Some(canonical.join("~")) + ); + } } diff --git a/products/maka/bundle/src/installation.ts b/products/maka/bundle/src/installation.ts index d10e00dbb19..c11b1a277f0 100644 --- a/products/maka/bundle/src/installation.ts +++ b/products/maka/bundle/src/installation.ts @@ -183,10 +183,15 @@ export function installation( 'SessionHistory', 'Read a conversation transcript to inspect actual delegated results and failures. Page with returned cursors; tool metadata alone is not the full tool output. Use the indicated result reader when evidence is archived.', ], + [ + 'directories', + 'WorkspaceLookup', + 'Find the workspace selection for a directory before SessionCreate: kind=path with an absolute path (~ allowed) returns the directory with its selection, or null when the path is outside the configured workspace locations, which are listed as roots. kind=roots lists the locations; kind=children browses one. Selections come only from here or from an existing conversation; rootId and directoryIdentity cannot be derived or guessed.', + ], [ 'create', 'SessionCreate', - 'Create a child conversation for a distinct delegated task. Choose a concise, specific task title that a person can recognize later (for example, "Fix queued-message cancellation", not "worker 1"). The child inherits your current sandbox policy, not your one-off approvals. Select a workspace, then use SessionSend to provide the objective, relevant context, constraints and expected deliverable.', + 'Create a child conversation for a distinct delegated task. Choose a concise, specific task title that a person can recognize later (for example, "Fix queued-message cancellation", not "worker 1"). The child inherits your current sandbox policy, not your one-off approvals. Select a workspace: a directory selection from WorkspaceLookup, or kind=session to reuse a conversation\'s workspace; never invent rootId or directoryIdentity. Then use SessionSend to provide the objective, relevant context, constraints and expected deliverable.', ], [ 'fork', diff --git a/products/maka/workbench/app/WorkspacePicker.tsx b/products/maka/workbench/app/WorkspacePicker.tsx index a2a0a55b320..1db6326195d 100644 --- a/products/maka/workbench/app/WorkspacePicker.tsx +++ b/products/maka/workbench/app/WorkspacePicker.tsx @@ -138,7 +138,11 @@ export function WorkspacePicker(props: { }; const load = async (read = location()) => { const previous = read.page(); - if (!owns(read) || read.reading || (previous && (previous.kind === 'roots' || !previous.after))) + if ( + !owns(read) || + read.reading || + (previous && (previous.kind !== 'children' || !previous.after)) + ) return; read.reading = true; loading.set(true); @@ -175,7 +179,7 @@ export function WorkspacePicker(props: { props.changed(); } const rows = new Map(read.entries().map((entry) => [selectionKey(entry.selection), entry])); - for (const entry of page.kind === 'roots' ? page.roots : page.children) + for (const entry of page.kind === 'children' ? page.children : page.roots) rows.set(selectionKey(entry.selection), entry); batch(() => { read.page.set(page); From 5fe3df764126c77ddda1beacb96db6684651f6a6 Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Wed, 4 Nov 2026 23:39:53 +0800 Subject: [PATCH 2/4] feat(agent): select workspaces by path, not by directory selection Callers named a workspace as rootId, segments and directoryIdentity, values only the Host could know, so models asked for a conversation in a directory guessed them and the UI and ACP walked the directory tree to look them up. Workspace-selecting inputs now take a path: the Host expands a leading ~, normalizes and canonicalizes it, finds the most specific configured location holding it, excludes managed worktrees and private paths, and captures the directory identity as it opens the directory. Workspace.directory_identity stays Host-persisted state that validate checks. The directories query remains for UI browsing, paged by path; the ACP tree walk is removed, and so is the WorkspaceLookup tool this branch had added. Closes #6630. Generated-by: Claude Code --- packages/plugin-sdk/src/contracts.ts | 662 +++--------------- packages/plugin-sdk/src/generated/methods.ts | 8 +- packages/plugin-sdk/src/generated/runtime.ts | 49 +- .../plugin-sdk/tests/application.types.ts | 8 +- plugins/agent/api/src/contract.rs | 45 +- plugins/agent/api/src/contract/imports.rs | 21 +- plugins/agent/api/src/contract/projects.rs | 14 +- plugins/agent/api/src/workhub/contract.rs | 8 +- .../agent/conversations/ui/SessionSurface.tsx | 26 +- plugins/agent/native/src/owner.rs | 19 +- plugins/agent/native/src/owner/bot.rs | 20 +- plugins/agent/native/src/owner/imports.rs | 15 +- .../agent/native/src/owner/imports/access.rs | 10 +- plugins/agent/native/src/owner/models.rs | 8 +- plugins/agent/native/src/owner/projects.rs | 79 +-- plugins/agent/native/src/owner/tests.rs | 2 +- .../agent/native/src/owner/tests/bot_tests.rs | 7 - .../agent/native/src/owner/tests/imports.rs | 10 +- plugins/agent/native/src/owner/tests/mcp.rs | 6 +- .../agent/native/src/owner/tests/projects.rs | 24 +- .../native/src/owner/tests/session_context.rs | 8 +- .../agent/native/src/owner/tests/skills.rs | 2 +- .../agent/native/src/owner/tests/workhub.rs | 10 +- .../src/owner/tests/workhub/graph_tests.rs | 4 +- .../native/src/owner/tests/workspaces.rs | 55 +- plugins/agent/native/src/owner/worktrees.rs | 35 +- plugins/agent/native/src/service.rs | 8 +- plugins/agent/native/src/workhub.rs | 20 +- plugins/agent/native/src/workhub/tasks.rs | 4 +- .../native/src/workhub/tasks/graph/tools.rs | 6 +- .../native/src/workhub/tasks/preparation.rs | 17 +- plugins/workspace/support/Cargo.toml | 1 + plugins/workspace/support/README.md | 2 +- plugins/workspace/support/README.zh-CN.md | 2 +- plugins/workspace/support/src/lib.rs | 644 ++++++++--------- products/maka/acp/src/session.rs | 4 +- products/maka/acp/src/session/list.rs | 57 +- products/maka/bundle/src/installation.ts | 7 +- products/maka/cli/src/acp/tests/support.rs | 2 +- .../maka/workbench/app/ConfigurationScope.tsx | 4 +- .../maka/workbench/app/WorkspaceBrowser.tsx | 8 +- .../maka/workbench/app/WorkspacePicker.tsx | 65 +- .../maka/workbench/app/conversationGroups.ts | 8 +- .../tests/conversation-groups.test.mjs | 6 +- 44 files changed, 707 insertions(+), 1313 deletions(-) diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index af35a5b10dd..e5193644f88 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -2764,20 +2764,23 @@ export const contracts = { AgentWorkspaceSelection: { oneOf: [ { + description: 'A Git worktree of the directory at `path`, managed by the Host.', type: 'object', properties: { kind: { type: 'string', const: 'isolated_git', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'path'], }, { + description: 'A registered Project at one of its locations.', type: 'object', properties: { kind: { @@ -2787,26 +2790,30 @@ export const contracts = { project: { $ref: '#/$defs/AgentProjectRef', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'project', 'selection'], + required: ['kind', 'project', 'path'], }, { + description: + 'The directory at `path`: absolute, `~` standing for the home directory,\nwithin the configured workspace locations.', type: 'object', properties: { kind: { type: 'string', const: 'directory', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'path'], }, { type: 'object', @@ -2825,27 +2832,6 @@ export const contracts = { }, ], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentProjectRef: { type: 'object', properties: { @@ -3747,64 +3733,27 @@ export const contracts = { required: ['kind'], additionalProperties: false, }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'children', - }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', - }, - after: { - type: ['string', 'null'], - }, - }, - additionalProperties: false, - required: ['kind', 'selection', 'after'], - }, { description: - 'The directory at an absolute path (`~` stands for the home directory),\nfound within the configured workspace locations.', + 'The directories inside `path`, a page at a time; `after` is the last\nname of the previous page.', type: 'object', properties: { kind: { type: 'string', - const: 'path', + const: 'children', }, path: { type: 'string', minLength: 1, }, - }, - additionalProperties: false, - required: ['kind', 'path'], - }, - ], - $defs: { - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', + after: { + type: ['string', 'null'], }, }, additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], + required: ['kind', 'path', 'after'], }, - }, + ], }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -3827,35 +3776,6 @@ export const contracts = { additionalProperties: false, required: ['kind', 'roots'], }, - { - description: - '`directory` is null when the path is not a directory within any\nconfigured location; `roots` lists those locations either way.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'path', - }, - directory: { - anyOf: [ - { - $ref: '#/$defs/AgentDirectory', - }, - { - type: 'null', - }, - ], - }, - roots: { - type: 'array', - items: { - $ref: '#/$defs/AgentDirectory', - }, - }, - }, - additionalProperties: false, - required: ['kind', 'directory', 'roots'], - }, { type: 'object', properties: { @@ -3891,33 +3811,9 @@ export const contracts = { path: { type: 'string', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', - }, }, additionalProperties: false, - required: ['name', 'path', 'selection'], - }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], + required: ['name', 'path'], }, }, }, @@ -8550,8 +8446,9 @@ export const contracts = { selection: { $ref: '#/$defs/AgentImportSelection', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, title: { type: 'string', @@ -8559,7 +8456,7 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'selection', 'directory', 'title'], + required: ['kind', 'selection', 'path', 'title'], }, ], $defs: { @@ -8572,12 +8469,13 @@ export const contracts = { type: 'string', const: 'codex', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -8586,12 +8484,13 @@ export const contracts = { type: 'string', const: 'claude_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -8600,8 +8499,9 @@ export const contracts = { type: 'string', const: 'open_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, database: { type: 'string', @@ -8609,31 +8509,10 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'directory', 'database'], + required: ['kind', 'path', 'database'], }, ], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentImportSelection: { type: 'object', properties: { @@ -8791,12 +8670,13 @@ export const contracts = { type: 'string', const: 'codex', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -8805,12 +8685,13 @@ export const contracts = { type: 'string', const: 'claude_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -8819,8 +8700,9 @@ export const contracts = { type: 'string', const: 'open_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, database: { type: 'string', @@ -8828,31 +8710,10 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'directory', 'database'], + required: ['kind', 'path', 'database'], }, ], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentSession: { type: 'object', properties: { @@ -9296,64 +9157,27 @@ export const contracts = { required: ['kind'], additionalProperties: false, }, - { - type: 'object', - properties: { - kind: { - type: 'string', - const: 'children', - }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', - }, - after: { - type: ['string', 'null'], - }, - }, - additionalProperties: false, - required: ['kind', 'selection', 'after'], - }, { description: - 'The directory at an absolute path (`~` stands for the home directory),\nfound within the configured workspace locations.', + 'The directories inside `path`, a page at a time; `after` is the last\nname of the previous page.', type: 'object', properties: { kind: { type: 'string', - const: 'path', + const: 'children', }, path: { type: 'string', minLength: 1, }, - }, - additionalProperties: false, - required: ['kind', 'path'], - }, - ], - $defs: { - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', + after: { + type: ['string', 'null'], }, }, additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], + required: ['kind', 'path', 'after'], }, - }, + ], }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -9376,35 +9200,6 @@ export const contracts = { additionalProperties: false, required: ['kind', 'roots'], }, - { - description: - '`directory` is null when the path is not a directory within any\nconfigured location; `roots` lists those locations either way.', - type: 'object', - properties: { - kind: { - type: 'string', - const: 'path', - }, - directory: { - anyOf: [ - { - $ref: '#/$defs/AgentDirectory', - }, - { - type: 'null', - }, - ], - }, - roots: { - type: 'array', - items: { - $ref: '#/$defs/AgentDirectory', - }, - }, - }, - additionalProperties: false, - required: ['kind', 'directory', 'roots'], - }, { type: 'object', properties: { @@ -9440,33 +9235,9 @@ export const contracts = { path: { type: 'string', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', - }, }, additionalProperties: false, - required: ['name', 'path', 'selection'], - }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], + required: ['name', 'path'], }, }, }, @@ -9749,12 +9520,13 @@ export const contracts = { type: 'string', const: 'codex', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -9763,12 +9535,13 @@ export const contracts = { type: 'string', const: 'claude_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'directory'], + required: ['kind', 'path'], }, { type: 'object', @@ -9777,8 +9550,9 @@ export const contracts = { type: 'string', const: 'open_code', }, - directory: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, database: { type: 'string', @@ -9786,31 +9560,10 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'directory', 'database'], + required: ['kind', 'path', 'database'], }, ], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentImportSourceRef: { type: 'object', properties: { @@ -15056,20 +14809,23 @@ export const contracts = { AgentWorkspaceSelection: { oneOf: [ { + description: 'A Git worktree of the directory at `path`, managed by the Host.', type: 'object', properties: { kind: { type: 'string', const: 'isolated_git', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'path'], }, { + description: 'A registered Project at one of its locations.', type: 'object', properties: { kind: { @@ -15079,26 +14835,30 @@ export const contracts = { project: { $ref: '#/$defs/AgentProjectRef', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'project', 'selection'], + required: ['kind', 'project', 'path'], }, { + description: + 'The directory at `path`: absolute, `~` standing for the home directory,\nwithin the configured workspace locations.', type: 'object', properties: { kind: { type: 'string', const: 'directory', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'path'], }, { type: 'object', @@ -15117,27 +14877,6 @@ export const contracts = { }, ], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentProjectRef: { type: 'object', properties: { @@ -15402,8 +15141,9 @@ export const contracts = { type: 'string', const: 'register', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, expected: { anyOf: [ @@ -15417,7 +15157,7 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'selection', 'expected'], + required: ['kind', 'path', 'expected'], }, { type: 'object', @@ -15475,8 +15215,9 @@ export const contracts = { project: { $ref: '#/$defs/AgentProjectRef', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, destination: { anyOf: [ @@ -15490,31 +15231,10 @@ export const contracts = { }, }, additionalProperties: false, - required: ['kind', 'project', 'selection', 'destination'], + required: ['kind', 'project', 'path', 'destination'], }, ], $defs: { - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentProjectRef: { type: 'object', properties: { @@ -15612,22 +15332,13 @@ export const contracts = { available: { type: 'boolean', }, - selection: { - anyOf: [ - { - $ref: '#/$defs/AgentDirectorySelection', - }, - { - type: 'null', - }, - ], - }, path: { + description: 'The location the Project was last used at, when it is available.', type: ['string', 'null'], }, }, additionalProperties: false, - required: ['reference', 'name', 'archived', 'available', 'selection', 'path'], + required: ['reference', 'name', 'archived', 'available', 'path'], }, AgentProjectRef: { type: 'object', @@ -15644,27 +15355,6 @@ export const contracts = { additionalProperties: false, required: ['id', 'revision'], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, }, }, resources: {}, @@ -15813,12 +15503,13 @@ export const contracts = { type: 'string', const: 'directory', }, - selection: { - $ref: '#/$defs/AgentDirectorySelection', + path: { + type: 'string', + minLength: 1, }, }, additionalProperties: false, - required: ['kind', 'selection'], + required: ['kind', 'path'], }, ], $defs: { @@ -15837,27 +15528,6 @@ export const contracts = { additionalProperties: false, required: ['revision', 'after'], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, }, }, output: { @@ -15950,22 +15620,13 @@ export const contracts = { available: { type: 'boolean', }, - selection: { - anyOf: [ - { - $ref: '#/$defs/AgentDirectorySelection', - }, - { - type: 'null', - }, - ], - }, path: { + description: 'The location the Project was last used at, when it is available.', type: ['string', 'null'], }, }, additionalProperties: false, - required: ['reference', 'name', 'archived', 'available', 'selection', 'path'], + required: ['reference', 'name', 'archived', 'available', 'path'], }, AgentProjectRef: { type: 'object', @@ -15982,27 +15643,6 @@ export const contracts = { additionalProperties: false, required: ['id', 'revision'], }, - AgentDirectorySelection: { - type: 'object', - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentProjectCursor: { type: 'object', properties: { @@ -16021,9 +15661,6 @@ export const contracts = { AgentProjectLocation: { type: 'object', properties: { - selection: { - $ref: '#/$defs/AgentDirectorySelection', - }, path: { type: 'string', minLength: 1, @@ -16044,7 +15681,7 @@ export const contracts = { }, }, additionalProperties: false, - required: ['selection', 'path', 'name', 'project'], + required: ['path', 'name', 'project'], }, }, }, @@ -21939,26 +21576,18 @@ export const contracts = { mode: 'mutation', input: { $schema: 'https://json-schema.org/draft/2020-12/schema', - title: 'AgentDirectorySelection', + title: 'AgentDirectoryPath', + description: + 'A directory on the Host: an absolute path, `~` standing for the home\ndirectory, within the configured workspace locations.', type: 'object', properties: { - rootId: { + path: { type: 'string', minLength: 1, }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, }, additionalProperties: false, - required: ['rootId', 'segments', 'directoryIdentity'], + required: ['path'], }, output: { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -27951,7 +27580,7 @@ export const contracts = { const: 'pending', }, workspace: { - $ref: '#/$defs/AgentDirectorySelection', + type: 'string', }, }, required: ['kind', 'workspace'], @@ -27988,27 +27617,6 @@ export const contracts = { title: 'WorkHubCoordinator', $schema: 'https://json-schema.org/draft/2020-12/schema', $defs: { - AgentDirectorySelection: { - type: 'object', - additionalProperties: false, - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentSession: { type: 'object', additionalProperties: false, @@ -29440,36 +29048,15 @@ export const contracts = { additionalProperties: false, properties: { workspace: { - $ref: '#/$defs/AgentDirectorySelection', - description: 'Used only when freezing the first coordinator creation intent.', + type: 'string', + description: + "The coordinator's directory, used only when freezing the first\ncreation intent.", + minLength: 1, }, }, required: ['workspace'], title: 'WorkHubResolve', $schema: 'https://json-schema.org/draft/2020-12/schema', - $defs: { - AgentDirectorySelection: { - type: 'object', - additionalProperties: false, - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - required: ['rootId', 'segments', 'directoryIdentity'], - }, - }, }, output: { oneOf: [ @@ -29493,7 +29080,7 @@ export const contracts = { const: 'pending', }, workspace: { - $ref: '#/$defs/AgentDirectorySelection', + type: 'string', }, }, required: ['kind', 'workspace'], @@ -29530,27 +29117,6 @@ export const contracts = { title: 'WorkHubCoordinator', $schema: 'https://json-schema.org/draft/2020-12/schema', $defs: { - AgentDirectorySelection: { - type: 'object', - additionalProperties: false, - properties: { - rootId: { - type: 'string', - minLength: 1, - }, - segments: { - type: 'array', - items: { - type: 'string', - }, - }, - directoryIdentity: { - type: 'string', - pattern: '^sha256:[a-f0-9]{64}$', - }, - }, - required: ['rootId', 'segments', 'directoryIdentity'], - }, AgentSession: { type: 'object', additionalProperties: false, diff --git a/packages/plugin-sdk/src/generated/methods.ts b/packages/plugin-sdk/src/generated/methods.ts index e001238c0f2..9f66a77d173 100644 --- a/packages/plugin-sdk/src/generated/methods.ts +++ b/packages/plugin-sdk/src/generated/methods.ts @@ -885,7 +885,13 @@ export interface ServiceMethods { }; selectWorkspace: { mode: 'mutation'; - input: Runtime.AgentDirectorySelection; + /** + * A directory on the Host: an absolute path, `~` standing for the home + * directory, within the configured workspace locations. + */ + input: { + path: string; + }; output: Runtime.AgentWorkspace | null; }; send: { diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 0180354bc8e..544d816905d 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -1209,7 +1209,7 @@ export type WorkHubCoordinator = } | { kind: 'pending'; - workspace: AgentDirectorySelection; + workspace: string; } | { kind: 'ready'; @@ -2431,16 +2431,16 @@ export type AgentSessionListScope = export type AgentWorkspaceSelection = | { kind: 'isolated_git'; - selection: AgentDirectorySelection; + path: string; } | { kind: 'project'; project: AgentProjectRef; - selection: AgentDirectorySelection; + path: string; } | { kind: 'directory'; - selection: AgentDirectorySelection; + path: string; } | { kind: 'session'; @@ -3020,15 +3020,15 @@ export type AgentImportsPage = export type AgentImportLocation = | { kind: 'codex'; - directory: AgentDirectorySelection; + path: string; } | { kind: 'claude_code'; - directory: AgentDirectorySelection; + path: string; } | { kind: 'open_code'; - directory: AgentDirectorySelection; + path: string; database: string; }; export type AgentImportRecordKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'note'; @@ -3048,7 +3048,7 @@ export type AgentImportChange = | { kind: 'copy'; selection: AgentImportSelection; - directory: AgentDirectorySelection; + path: string; title: string; }; export type AgentImportResult = @@ -3077,7 +3077,7 @@ export type AgentProjectsRead = } | { kind: 'directory'; - selection: AgentDirectorySelection; + path: string; }; export type AgentProjectsPage = | { @@ -3244,7 +3244,7 @@ export type AgentProjectsPage = export type AgentProjectChange = | { kind: 'register'; - selection: AgentDirectorySelection; + path: string; expected: AgentProjectRef | null; } | { @@ -3263,7 +3263,7 @@ export type AgentProjectChange = | { kind: 'relocate'; project: AgentProjectRef; - selection: AgentDirectorySelection; + path: string; destination: AgentProjectRef | null; }; export type AgentProjectResult = @@ -3312,23 +3312,14 @@ export type AgentDirectories = } | { kind: 'children'; - selection: AgentDirectorySelection; - after: string | null; - } - | { - kind: 'path'; path: string; + after: string | null; }; export type AgentDirectoryPage = | { kind: 'roots'; roots: AgentDirectory[]; } - | { - kind: 'path'; - directory: AgentDirectory | null; - roots: AgentDirectory[]; - } | { kind: 'children'; directory: AgentDirectory; @@ -6203,14 +6194,10 @@ export interface WorkHubGraphReceiptRead { } export interface WorkHubResolve { /** - * Used only when freezing the first coordinator creation intent. + * The coordinator's directory, used only when freezing the first + * creation intent. */ - workspace: AgentDirectorySelection; -} -export interface AgentDirectorySelection { - rootId: string; - segments: string[]; - directoryIdentity: string; + workspace: string; } export interface AgentSession { /** @@ -8110,11 +8097,12 @@ export interface AgentProject { name: string; archived: boolean; available: boolean; - selection: AgentDirectorySelection | null; + /** + * The location the Project was last used at, when it is available. + */ path: string | null; } export interface AgentProjectLocation { - selection: AgentDirectorySelection; path: string; name: string; project: AgentProject | null; @@ -8142,7 +8130,6 @@ export interface AgentForkReceiptResult { export interface AgentDirectory { name: string; path: string; - selection: AgentDirectorySelection; } export interface AgentMetadata { /** diff --git a/packages/plugin-sdk/tests/application.types.ts b/packages/plugin-sdk/tests/application.types.ts index f0068e6c548..d669752cfdb 100644 --- a/packages/plugin-sdk/tests/application.types.ts +++ b/packages/plugin-sdk/tests/application.types.ts @@ -381,13 +381,7 @@ void [ unfencedSessionPage, ]; -const resolveCoordinator: WorkHubResolve = { - workspace: { - rootId: 'root-1', - segments: [], - directoryIdentity: `sha256:${'0'.repeat(64)}`, - }, -}; +const resolveCoordinator: WorkHubResolve = { workspace: '~/projects/hub' }; function coordinatorSession(value: WorkHubCoordinator): string | null { return value.kind === 'ready' ? value.session.id : null; } diff --git a/plugins/agent/api/src/contract.rs b/plugins/agent/api/src/contract.rs index 86404c0cec2..d5355f3caaa 100644 --- a/plugins/agent/api/src/contract.rs +++ b/plugins/agent/api/src/contract.rs @@ -335,46 +335,49 @@ pub struct Create { )] #[schemars(rename = "AgentWorkspaceSelection")] pub enum WorkspaceSelection { + /// A Git worktree of the directory at `path`, managed by the Host. IsolatedGit { - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, + /// A registered Project at one of its locations. Project { project: ProjectRef, - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, + /// The directory at `path`: absolute, `~` standing for the home directory, + /// within the configured workspace locations. Directory { - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, Session { #[schemars(length(min = 1))] session_id: String, }, } +/// A directory on the Host: an absolute path, `~` standing for the home +/// directory, within the configured workspace locations. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(rename = "AgentDirectorySelection")] -pub struct DirectorySelection { +#[schemars(rename = "AgentDirectoryPath")] +pub struct DirectoryPath { #[schemars(length(min = 1))] - pub root_id: String, - pub segments: Vec, - #[schemars(with = "String", regex(pattern = "^sha256:[a-f0-9]{64}$"))] - pub directory_identity: DirectoryIdentity, + pub path: String, } #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] #[schemars(rename = "AgentDirectories")] pub enum Directories { Roots, + /// The directories inside `path`, a page at a time; `after` is the last + /// name of the previous page. Children { - selection: DirectorySelection, - #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] - after: Option, - }, - /// The directory at an absolute path (`~` stands for the home directory), - /// found within the configured workspace locations. - Path { #[schemars(length(min = 1))] path: String, + #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] + after: Option, }, } #[derive(Serialize, JsonSchema, Deserialize)] @@ -383,7 +386,6 @@ pub enum Directories { pub struct Directory { pub name: String, pub path: String, - pub selection: DirectorySelection, } #[derive(Serialize, JsonSchema, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -392,13 +394,6 @@ pub enum DirectoryPage { Roots { roots: Vec, }, - /// `directory` is null when the path is not a directory within any - /// configured location; `roots` lists those locations either way. - Path { - #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] - directory: Option, - roots: Vec, - }, Children { directory: Directory, #[schemars(length(max = 32))] @@ -1795,7 +1790,7 @@ pub fn contract() -> Contract { ), ( "selectWorkspace".into(), - method::>(MethodMode::Mutation), + method::>(MethodMode::Mutation), ), ( "observe".into(), diff --git a/plugins/agent/api/src/contract/imports.rs b/plugins/agent/api/src/contract/imports.rs index da73b59e60f..be0e9543cc9 100644 --- a/plugins/agent/api/src/contract/imports.rs +++ b/plugins/agent/api/src/contract/imports.rs @@ -17,7 +17,7 @@ * under the License. */ -use super::{DirectorySelection, Session}; +use super::Session; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -27,23 +27,25 @@ use uuid::Uuid; #[schemars(rename = "AgentImportLocation")] pub enum ImportLocation { Codex { - directory: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, ClaudeCode { - directory: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, OpenCode { - directory: DirectorySelection, + #[schemars(length(min = 1))] + path: String, #[schemars(length(min = 1))] database: String, }, } impl ImportLocation { - pub fn directory(&self) -> &DirectorySelection { + /// The directory the source is read from, as the caller named it. + pub fn path(&self) -> &str { match self { - Self::Codex { directory } - | Self::ClaudeCode { directory } - | Self::OpenCode { directory, .. } => directory, + Self::Codex { path } | Self::ClaudeCode { path } | Self::OpenCode { path, .. } => path, } } } @@ -227,7 +229,8 @@ pub enum ImportChange { }, Copy { selection: ImportSelection, - directory: DirectorySelection, + #[schemars(length(min = 1))] + path: String, #[schemars(length(min = 1))] title: String, }, diff --git a/plugins/agent/api/src/contract/projects.rs b/plugins/agent/api/src/contract/projects.rs index 0920aff316f..10b47b0d846 100644 --- a/plugins/agent/api/src/contract/projects.rs +++ b/plugins/agent/api/src/contract/projects.rs @@ -17,7 +17,6 @@ * under the License. */ -use super::DirectorySelection; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -39,8 +38,7 @@ pub struct Project { pub name: String, pub archived: bool, pub available: bool, - #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] - pub selection: Option, + /// The location the Project was last used at, when it is available. #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] pub path: Option, } @@ -63,7 +61,8 @@ pub enum ProjectsRead { cursor: Option, }, Directory { - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, }, } @@ -71,7 +70,6 @@ pub enum ProjectsRead { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[schemars(rename = "AgentProjectLocation")] pub struct ProjectLocation { - pub selection: DirectorySelection, #[schemars(length(min = 1))] pub path: String, #[schemars(length(min = 1))] @@ -104,7 +102,8 @@ pub enum ProjectsPage { #[schemars(rename = "AgentProjectChange")] pub enum ProjectChange { Register { - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] expected: Option, }, @@ -121,7 +120,8 @@ pub enum ProjectChange { }, Relocate { project: ProjectRef, - selection: DirectorySelection, + #[schemars(length(min = 1))] + path: String, #[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable")] destination: Option, }, diff --git a/plugins/agent/api/src/workhub/contract.rs b/plugins/agent/api/src/workhub/contract.rs index 3c5da21d5e8..f94a2102141 100644 --- a/plugins/agent/api/src/workhub/contract.rs +++ b/plugins/agent/api/src/workhub/contract.rs @@ -213,8 +213,10 @@ pub enum ControlOutcome { #[serde(deny_unknown_fields)] #[schemars(rename = "WorkHubResolve")] pub struct Resolve { - /// Used only when freezing the first coordinator creation intent. - pub workspace: crate::contract::DirectorySelection, + /// The coordinator's directory, used only when freezing the first + /// creation intent. + #[schemars(length(min = 1))] + pub workspace: String, } #[derive(Serialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -222,7 +224,7 @@ pub struct Resolve { pub enum Coordinator { Missing, Pending { - workspace: crate::contract::DirectorySelection, + workspace: String, }, Ready { session: Box, diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index b37eb968904..6e6ac3677a9 100644 --- a/plugins/agent/conversations/ui/SessionSurface.tsx +++ b/plugins/agent/conversations/ui/SessionSurface.tsx @@ -97,10 +97,7 @@ import type { ComposerCommand, } from '../../../../products/maka/workbench/app/composerCommands'; import { matchComposerCommand } from '../../../../products/maka/workbench/app/composerCommands'; -import { - WorkspacePicker, - locateWorkspaceDirectory, -} from '../../../../products/maka/workbench/app/WorkspacePicker'; +import { WorkspacePicker } from '../../../../products/maka/workbench/app/WorkspacePicker'; import { RecentWorkspaces, type RecentWorkspace, @@ -400,7 +397,7 @@ export function createConversationUi( } else { await host.query('agent', 'directories', { kind: 'children', - selection: workspace.selection, + path: workspace.path, after: null, }); } @@ -723,26 +720,13 @@ export function createConversationUi( workspaceLabel ?? (workspace.kind === 'session' ? (handle()?.workspace() ?? '') - : (workspace.selection.segments.at(-1) ?? '')); + : (workspace.path.split(/[\\/]/).filter(Boolean).at(-1) ?? '')); if (workspace.kind === 'session') { const observed = await host.query('agent', 'observe', { sessionId: workspace.sessionId }); if (!alive || selected() !== expectedSessionId) return; label = observed.session.workspace.path; - if (!observed.session.workspace.worktree) { - const roots = await host.query('agent', 'directories', { kind: 'roots' }); - if (!alive || selected() !== expectedSessionId) return; - const found = - roots.kind === 'roots' ? locateWorkspaceDirectory(roots.roots, label) : null; - if (found) - recentWorkspace = { - kind: 'directory', - selection: { - rootId: found.root.selection.rootId, - segments: [...found.root.selection.segments, ...found.names!], - directoryIdentity: observed.session.workspace.directoryIdentity, - }, - }; - } + if (!observed.session.workspace.worktree) + recentWorkspace = { kind: 'directory', path: label }; } if (!(await shell()!.openSession(null)) || !alive || selected() !== null) return; if (expectedSessionId === null && pendingWorkspace()) { diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs index 579f6246d36..2cbc4b700f7 100644 --- a/plugins/agent/native/src/owner.rs +++ b/plugins/agent/native/src/owner.rs @@ -228,7 +228,10 @@ pub(crate) struct Owner { pub workspace_roots: Vec, instruction_home: Option>, pub import_roots: Vec, - managed_worktrees: std::path::PathBuf, + pub(crate) managed_worktrees: std::path::PathBuf, + /// The user's home directory, which a leading `~` in a caller's path + /// stands for. + pub home: Option, engine: Engine, tools: maka_tools::ToolCatalog, native_approval_executor: Arc, @@ -365,6 +368,7 @@ impl Owner { let managed_worktrees = root.canonical_path().join("subagent-worktrees"); let workspace_roots = workspace::open(settings.workspace_roots.clone(), &managed_worktrees)?; + let home = std::env::home_dir(); let import_roots = if settings.import_roots.is_empty() { Vec::new() } else { @@ -615,6 +619,7 @@ impl Owner { workspace_roots, import_roots, managed_worktrees, + home, revision, behavior, configured_locale, @@ -845,8 +850,8 @@ impl Owner { } } let selected = match input.workspace { - WorkspaceSelection::IsolatedGit { selection } => { - let Some(workspace) = self.isolated_workspace(selection, operation, scope).await? + WorkspaceSelection::IsolatedGit { path } => { + let Some(workspace) = self.isolated_workspace(&path, operation, scope).await? else { return Ok(Created::Rejected { reason: Rejection::WorkspaceUnavailable, @@ -854,16 +859,16 @@ impl Owner { }; workspace } - WorkspaceSelection::Project { project, selection } => { - let Some(workspace) = self.select_project(project, selection).await? else { + WorkspaceSelection::Project { project, path } => { + let Some(workspace) = self.select_project(project, &path).await? else { return Ok(Created::Rejected { reason: Rejection::WorkspaceUnavailable, }); }; workspace } - WorkspaceSelection::Directory { selection } => { - let Some(workspace) = self.create_project_workspace(selection).await? else { + WorkspaceSelection::Directory { path } => { + let Some(workspace) = self.create_project_workspace(&path).await? else { return Ok(Created::Rejected { reason: Rejection::WorkspaceUnavailable, }); diff --git a/plugins/agent/native/src/owner/bot.rs b/plugins/agent/native/src/owner/bot.rs index fe07e3655c3..a769d6e055a 100644 --- a/plugins/agent/native/src/owner/bot.rs +++ b/plugins/agent/native/src/owner/bot.rs @@ -64,7 +64,6 @@ pub(super) const COORDINATION_TOOLS: &[&str] = &[ "SessionExecution", // Delegation: the Bot may create workers and send to or stop only the // conversations it created itself (checked against `parentSessionId`). - "WorkspaceLookup", "SessionCreate", "SessionSend", "SessionStop", @@ -771,17 +770,14 @@ impl Owner { let page = maka_workspace_support::query( self.workspace_roots.clone(), crate::contract::Directories::Roots, + self.home.as_deref(), ) .await?; Ok(match page { - crate::contract::DirectoryPage::Roots { roots } => { - roots - .into_iter() - .next() - .map(|root| WorkspaceSelection::Directory { - selection: root.selection, - }) - } + crate::contract::DirectoryPage::Roots { roots } => roots + .into_iter() + .next() + .map(|root| WorkspaceSelection::Directory { path: root.path }), _ => None, }) } @@ -1038,12 +1034,6 @@ const ABILITIES: &[Ability] = &[ "{}(尋找可派發的模型及推理檔位)", "{} (find selectable models and thinking levels for delegation)", ), - ( - &["WorkspaceLookup"], - "{}(把目录路径解析成 SessionCreate 可用的工作区选择)", - "{}(把目錄路徑解析成 SessionCreate 可用的工作區選擇)", - "{} (turn a directory path into the workspace selection SessionCreate takes)", - ), ( &["SessionCreate", "SessionSend", "SessionStop"], "{}(新建工作会话并派活;只能给自己创建的会话发消息或停止)", diff --git a/plugins/agent/native/src/owner/imports.rs b/plugins/agent/native/src/owner/imports.rs index 8d2fe3a32a4..2c22539f58c 100644 --- a/plugins/agent/native/src/owner/imports.rs +++ b/plugins/agent/native/src/owner/imports.rs @@ -83,7 +83,13 @@ impl Owner { .map_err(|_| "agent_import_unavailable")? .canonical_path() .to_owned(); - workspace::query_excluding(owner.import_roots.clone(), input, Some(private)).await + workspace::query_excluding( + owner.import_roots.clone(), + input, + owner.home.as_deref(), + Some(private), + ) + .await }) }) .await @@ -306,7 +312,7 @@ impl Owner { match input { ImportChange::Copy { selection, - directory, + path, title, } => { if metadata_rejection(&title, &[]).is_some() { @@ -327,7 +333,7 @@ impl Owner { } reader.validate(self).await?; let workspace = self - .create_project_workspace(directory) + .create_project_workspace(&path) .await .map_err(|_| Failure::Unknown)? .ok_or(Failure::Unavailable)?; @@ -414,7 +420,8 @@ impl Owner { .to_owned(); let selected = workspace::observed( self.import_roots.clone(), - location.directory().clone(), + location.path(), + self.home.as_deref(), private, ) .await diff --git a/plugins/agent/native/src/owner/imports/access.rs b/plugins/agent/native/src/owner/imports/access.rs index 56220cda15e..525a9806bc9 100644 --- a/plugins/agent/native/src/owner/imports/access.rs +++ b/plugins/agent/native/src/owner/imports/access.rs @@ -44,7 +44,8 @@ impl SourceRead { .to_owned(); let selected = workspace::observed( owner.import_roots.clone(), - source.location.directory().clone(), + source.location.path(), + owner.home.as_deref(), private.clone(), ) .await @@ -53,7 +54,7 @@ impl SourceRead { if selected.path != source.path { return Err(Failure::Changed); } - let selection = source.location.directory().clone(); + let identity = selected.identity; let path = std::path::PathBuf::from(&source.path); let read_path = path.clone(); let root = tokio::task::spawn_blocking(move || { @@ -65,7 +66,7 @@ impl SourceRead { } .compile() .map_err(|_| Failure::Unavailable)?; - let dir = maka_fs_tools::directory::open(&read_path, &selection.directory_identity) + let dir = maka_fs_tools::directory::open(&read_path, &identity) .map_err(|_| Failure::Unavailable)?; ReadRoot::from_handle(dir, read_path) .restrict(Arc::new(policy)) @@ -114,7 +115,8 @@ impl SourceRead { .to_owned(); let observed = workspace::observed( owner.import_roots.clone(), - self.source.location.directory().clone(), + self.source.location.path(), + owner.home.as_deref(), private, ) .await diff --git a/plugins/agent/native/src/owner/models.rs b/plugins/agent/native/src/owner/models.rs index aa21a8db1df..26735e38fce 100644 --- a/plugins/agent/native/src/owner/models.rs +++ b/plugins/agent/native/src/owner/models.rs @@ -207,8 +207,12 @@ impl Owner { Ok(choices) } pub(super) async fn external_cwd(&self) -> Result, String> { - let DirectoryPage::Roots { roots } = - workspace::query(self.workspace_roots.clone(), Directories::Roots).await? + let DirectoryPage::Roots { roots } = workspace::query( + self.workspace_roots.clone(), + Directories::Roots, + self.home.as_deref(), + ) + .await? else { unreachable!("roots query") }; diff --git a/plugins/agent/native/src/owner/projects.rs b/plugins/agent/native/src/owner/projects.rs index 9700dcea8e7..a7dc790ba96 100644 --- a/plugins/agent/native/src/owner/projects.rs +++ b/plugins/agent/native/src/owner/projects.rs @@ -73,10 +73,7 @@ impl Owner { .map_err(|_| "agent_projects_unavailable")? .filter(|record| record.identity.starts_with(&self.project_prefix()))) } - async fn project_paths( - &self, - record: &ProjectRecord, - ) -> Result, String> { + async fn project_paths(&self, record: &ProjectRecord) -> Result, String> { let mut locations = record.locations.iter().collect::>(); locations.sort_by(|a, b| { b.last_used_at @@ -85,15 +82,17 @@ impl Owner { }); let mut paths = Vec::new(); for location in locations { - let Some(selection) = workspace::selection_at( + if workspace::observed( self.workspace_roots.clone(), - location.path.clone(), + &location.path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? - else { + .is_none() + { continue; - }; + } let Ok(physical) = filesystem::resolve_selected(std::path::Path::new(&location.path)).await else { @@ -103,33 +102,31 @@ impl Owner { .identity() .is_ok_and(|identity| self.project_identity(&identity) == record.identity) { - paths.push((location.path.clone(), selection)); + paths.push(location.path.clone()); } } Ok(paths) } async fn project_summary(&self, record: &ProjectRecord) -> Result { - let location = self.project_paths(record).await?.into_iter().next(); - let (path, selection) = match location { - Some((path, selection)) => (Some(path), Some(selection)), - None => (None, None), - }; + let path = self.project_paths(record).await?.into_iter().next(); Ok(Project { reference: reference(record), name: record.name.clone(), archived: record.archived_at.is_some(), available: path.is_some(), - selection, path, }) } + /// The directory at `path` as a Project registration, with the path as + /// the Host captured it. async fn project_directory( &self, - selection: DirectorySelection, - ) -> Result, String> { + path: &str, + ) -> Result, String> { let Some(directory) = workspace::selected( self.workspace_roots.clone(), - selection.clone(), + path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? @@ -147,7 +144,8 @@ impl Owner { // The directory picker observation is still the basis after Git inspection. if workspace::selected( self.workspace_roots.clone(), - selection.clone(), + path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? @@ -159,7 +157,7 @@ impl Owner { ProjectRegistration { identity: self.project_identity(&identity), name: physical.name, - path: directory.path, + path: directory.path.clone(), is_worktree: matches!( physical.kind, filesystem::ProjectKind::Git { @@ -168,14 +166,14 @@ impl Owner { } ), }, - selection, + directory.path, ))) } pub async fn projects(&self, input: ProjectsRead) -> Result { let records = self.project_records().await?; let result = match input { - ProjectsRead::Directory { selection } => { - let Some((location, selection)) = self.project_directory(selection).await? else { + ProjectsRead::Directory { path } => { + let Some((location, _)) = self.project_directory(&path).await? else { return Ok(ProjectsPage::Unavailable); }; let project = match records @@ -187,7 +185,6 @@ impl Owner { }; ProjectsPage::Directory { location: ProjectLocation { - selection, path: location.path, name: location.name, project, @@ -236,7 +233,7 @@ impl Owner { pub(crate) async fn select_workspace( self: &Arc, - selection: DirectorySelection, + input: DirectoryPath, scope: &maka_capabilities::call::Scope, ) -> Result, String> { self.resource_work(scope, move |owner, _scope| { @@ -247,7 +244,8 @@ impl Owner { .map_err(|error| error.to_string())?; workspace::initialize( &owner.workspace_roots, - selection, + &input.path, + owner.home.as_deref(), &owner.managed_worktrees, root.canonical_path(), root.control_directory(), @@ -260,9 +258,9 @@ impl Owner { pub(super) async fn create_project_workspace( &self, - selection: DirectorySelection, + path: &str, ) -> Result, String> { - let Some((registration, selection)) = self.project_directory(selection).await? else { + let Some((registration, path)) = self.project_directory(path).await? else { return Ok(None); }; let record = match self @@ -280,7 +278,8 @@ impl Owner { .map_err(|_| "agent_root_unavailable")?; let Some(mut workspace) = workspace::initialize( &self.workspace_roots, - selection, + &path, + self.home.as_deref(), &self.managed_worktrees, root.canonical_path(), root.control_directory(), @@ -311,7 +310,7 @@ impl Owner { pub(super) async fn select_project( &self, selected: ProjectRef, - selection: DirectorySelection, + path: &str, ) -> Result, String> { let Some(record) = self.project_record(selected.id).await? else { return Ok(None); @@ -319,7 +318,7 @@ impl Owner { if reference(&record) != selected || record.archived_at.is_some() { return Ok(None); } - let Some((registration, selection)) = self.project_directory(selection).await? else { + let Some((registration, path)) = self.project_directory(path).await? else { return Ok(None); }; if registration.identity != record.identity @@ -345,7 +344,8 @@ impl Owner { .map_err(|_| "agent_root_unavailable")?; let Some(mut workspace) = workspace::initialize( &self.workspace_roots, - selection, + &path, + self.home.as_deref(), &self.managed_worktrees, root.canonical_path(), root.control_directory(), @@ -366,11 +366,8 @@ impl Owner { } let archiving = matches!(&input, ProjectChange::Archive { .. }); let result = match input { - ProjectChange::Register { - selection, - expected, - } => { - let Some((registration, _)) = self.project_directory(selection).await? else { + ProjectChange::Register { path, expected } => { + let Some((registration, _)) = self.project_directory(&path).await? else { return Ok(ProjectResult::Unavailable); }; let current = self @@ -415,7 +412,7 @@ impl Owner { } ProjectChange::Relocate { project, - selection, + path, destination, } => { let Some(current) = self.project_record(project.id).await? else { @@ -424,8 +421,7 @@ impl Owner { if reference(¤t) != project { return Ok(ProjectResult::Changed); } - let Some((registration, selection)) = self.project_directory(selection).await? - else { + let Some((registration, path)) = self.project_directory(&path).await? else { return Ok(ProjectResult::Unavailable); }; let other = self @@ -477,7 +473,8 @@ impl Owner { .map_err(|_| "agent_root_unavailable")?; let Some(mut selected) = workspace::initialize( &self.workspace_roots, - selection, + &path, + self.home.as_deref(), &self.managed_worktrees, root.canonical_path(), root.control_directory(), diff --git a/plugins/agent/native/src/owner/tests.rs b/plugins/agent/native/src/owner/tests.rs index f7e5ac492d4..1becff2640b 100644 --- a/plugins/agent/native/src/owner/tests.rs +++ b/plugins/agent/native/src/owner/tests.rs @@ -865,7 +865,7 @@ impl Fixture { .query("directories", json!({"kind":"roots"})) .await .unwrap(); - json!({"title":title, "labels":[], "workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]}}) + json!({"title":title, "labels":[], "workspace":{"kind":"directory","path":roots["roots"][0]["path"]}}) } async fn selection(&self) -> ModelSelection { let row = self.config.catalog().await.unwrap().connections.remove(0); diff --git a/plugins/agent/native/src/owner/tests/bot_tests.rs b/plugins/agent/native/src/owner/tests/bot_tests.rs index 45c1c164b0b..be6a0e63e2d 100644 --- a/plugins/agent/native/src/owner/tests/bot_tests.rs +++ b/plugins/agent/native/src/owner/tests/bot_tests.rs @@ -1037,13 +1037,6 @@ fn the_instructions_name_only_the_tools_the_bot_holds() { reads.contains("SessionList / SessionRead"), "{language:?}: {reads}" ); - // The workspace lookup is named on its own, not as a session read. - assert!(!bare.contains("WorkspaceLookup"), "{language:?}: {bare}"); - let lookup = bot_prompt(language, &held(&["WorkspaceLookup"])); - assert!( - lookup.contains("WorkspaceLookup") && lookup.contains("SessionCreate"), - "{language:?}: {lookup}" - ); assert!( !reads.contains("SessionSend") && !reads.contains("SessionHistory"), "{language:?}: {reads}" diff --git a/plugins/agent/native/src/owner/tests/imports.rs b/plugins/agent/native/src/owner/tests/imports.rs index b27a311c036..312b0c764e5 100644 --- a/plugins/agent/native/src/owner/tests/imports.rs +++ b/plugins/agent/native/src/owner/tests/imports.rs @@ -41,7 +41,7 @@ async fn saved_source(fixture: &Fixture, kind: &str, revision: u64) -> Value { .query("importDirectories", json!({"kind":"roots"})) .await .unwrap(); - let mut location = json!({"kind":kind,"directory":roots["roots"][0]["selection"]}); + let mut location = json!({"kind":kind,"path":roots["roots"][0]["path"]}); if kind == "open_code" { location["database"] = json!("opencode.db"); } @@ -71,7 +71,7 @@ async fn imports_original_codex_history_requires_preview_and_keeps_receipt_acros json!({ "kind":"save_source", "revision":0, "id":null, "name":"完整".repeat(9000), - "location":{"kind":"codex","directory":roots["roots"][0]["selection"]} + "location":{"kind":"codex","path":roots["roots"][0]["path"]} }), Uuid::new_v4(), ) @@ -98,7 +98,7 @@ async fn imports_original_codex_history_requires_preview_and_keeps_receipt_acros .query("directories", json!({"kind":"roots"})) .await .unwrap(); - let input = json!({"kind":"copy","selection":review["preview"]["selection"],"directory":destinations["roots"][1]["selection"],"title":"My imported conversation"}); + let input = json!({"kind":"copy","selection":review["preview"]["selection"],"path":destinations["roots"][1]["path"],"title":"My imported conversation"}); let mut rows = codex_rows(); rows.push(json!({"type":"event_msg","payload":{"type":"agent_message","message":"Appended after review"}})); write_lines(&path, &rows); @@ -234,9 +234,9 @@ async fn imports_reject_stale_sources_foreign_entries_and_replaced_physical_dire let other = fixture .query("directories", json!({"kind":"roots"})) .await - .unwrap()["roots"][1]["selection"] + .unwrap()["roots"][1]["path"] .clone(); - assert_eq!(fixture.mutation(2,"import",json!({"kind":"save_source","revision":1,"id":null,"name":"Unpublished","location":{"kind":"codex","directory":other}}),Uuid::new_v4()).await.unwrap()["kind"],"unavailable"); + assert_eq!(fixture.mutation(2,"import",json!({"kind":"save_source","revision":1,"id":null,"name":"Unpublished","location":{"kind":"codex","path":other}}),Uuid::new_v4()).await.unwrap()["kind"],"unavailable"); assert_eq!( fixture .mutation( diff --git a/plugins/agent/native/src/owner/tests/mcp.rs b/plugins/agent/native/src/owner/tests/mcp.rs index ba713879ff2..e0dae9319fc 100644 --- a/plugins/agent/native/src/owner/tests/mcp.rs +++ b/plugins/agent/native/src/owner/tests/mcp.rs @@ -367,7 +367,7 @@ async fn exercise_file_consent() { .query("directories", json!({"kind":"roots"})) .await .unwrap(); - let b = f.mutation(2,"create",json!({"title":"project B","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][1]["selection"]}}),Uuid::new_v4()).await.unwrap()["session"]["id"].as_str().unwrap().to_owned(); + let b = f.mutation(2,"create",json!({"title":"project B","labels":[],"workspace":{"kind":"directory","path":roots["roots"][1]["path"]}}),Uuid::new_v4()).await.unwrap()["session"]["id"].as_str().unwrap().to_owned(); let workspace_b = f.owner.session(&b).await.unwrap().configuration.workspace; let record_a = home.path().join("a-record"); let record_b = home.path().join("b-record"); @@ -538,7 +538,7 @@ async fn mcp_slow_startup_does_not_block_other_session_stop_or_owner_shutdown() let f = Arc::new(Fixture::new(&format!("http://{}/v1",listener.local_addr().unwrap()),None).await); let a = f.create("slow MCP").await; let roots = f.query("directories",json!({"kind":"roots"})).await.unwrap(); - let b = f.mutation(2,"create",json!({"title":"other session","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][1]["selection"]}}),Uuid::new_v4()).await.unwrap()["session"]["id"].as_str().unwrap().to_owned(); + let b = f.mutation(2,"create",json!({"title":"other session","labels":[],"workspace":{"kind":"directory","path":roots["roots"][1]["path"]}}),Uuid::new_v4()).await.unwrap()["session"]["id"].as_str().unwrap().to_owned(); let running = f.send(&b).await.unwrap(); let (mut socket,_) = listener.accept().await.unwrap(); request(&mut socket).await; @@ -617,7 +617,7 @@ async fn mcp_management_preserves_file_fields_and_targets_physical_instances_wit .mutation( 2, "selectWorkspace", - roots["roots"][0]["selection"].clone(), + json!({"path":roots["roots"][0]["path"]}), Uuid::new_v4(), ) .await diff --git a/plugins/agent/native/src/owner/tests/projects.rs b/plugins/agent/native/src/owner/tests/projects.rs index 7d4245ab112..fdb39d69ea0 100644 --- a/plugins/agent/native/src/owner/tests/projects.rs +++ b/plugins/agent/native/src/owner/tests/projects.rs @@ -40,11 +40,11 @@ async fn projects_register_create_rename_archive_restore_and_relocate_original_s .query("directories", json!({"kind":"roots"})) .await .unwrap(); - let source = roots["roots"][0]["selection"].clone(); - let destination = roots["roots"][1]["selection"].clone(); + let source = roots["roots"][0]["path"].clone(); + let destination = roots["roots"][1]["path"].clone(); assert_eq!(projects(&fixture).await["projects"], json!([])); let preview = fixture - .query("projects", json!({"kind":"directory","selection":source})) + .query("projects", json!({"kind":"directory","path":source})) .await .unwrap(); assert!(preview["location"]["project"].is_null()); @@ -54,13 +54,13 @@ async fn projects_register_create_rename_archive_restore_and_relocate_original_s ); let registered = change( &fixture, - json!({"kind":"register","selection":source,"expected":null}), + json!({"kind":"register","path":source,"expected":null}), ) .await; assert_eq!(registered["kind"], "committed"); assert!(!fixture.workspace.join(MARKER_FILE).exists()); let selected = registered["project"]["reference"].clone(); - let created = fixture.mutation(2,"create",json!({"title":"Project conversation","labels":[],"workspace":{"kind":"project","project":selected,"selection":source}}),Uuid::new_v4()).await.unwrap(); + let created = fixture.mutation(2,"create",json!({"title":"Project conversation","labels":[],"workspace":{"kind":"project","project":selected,"path":source}}),Uuid::new_v4()).await.unwrap(); assert_eq!(created["kind"], "created"); let session = created["session"]["id"].as_str().unwrap(); assert_eq!(created["session"]["workspace"]["projectId"], selected["id"]); @@ -79,7 +79,7 @@ async fn projects_register_create_rename_archive_restore_and_relocate_original_s let listed = projects(&fixture).await; assert_eq!(listed["projects"][0], renamed["project"]); let directory = fixture - .query("projects", json!({"kind":"directory","selection":source})) + .query("projects", json!({"kind":"directory","path":source})) .await .unwrap(); assert_eq!(directory["location"]["project"], renamed["project"]); @@ -93,7 +93,7 @@ async fn projects_register_create_rename_archive_restore_and_relocate_original_s ) .await; assert_eq!(archived["project"]["archived"], true); - let denied = fixture.mutation(2,"create",json!({"title":"Archived","labels":[],"workspace":{"kind":"project","project":archived["project"]["reference"],"selection":source}}),Uuid::new_v4()).await.unwrap(); + let denied = fixture.mutation(2,"create",json!({"title":"Archived","labels":[],"workspace":{"kind":"project","project":archived["project"]["reference"],"path":source}}),Uuid::new_v4()).await.unwrap(); assert_eq!(denied["kind"], "rejected"); let denied = fixture.mutation(2,"create",json!({"title":"Archived session shortcut","labels":[],"workspace":{"kind":"session","sessionId":session}}),Uuid::new_v4()).await.unwrap(); assert_eq!(denied["kind"], "rejected"); @@ -138,7 +138,7 @@ async fn projects_register_create_rename_archive_restore_and_relocate_original_s ) .await .unwrap(); - let relocated = change(&fixture,json!({"kind":"relocate","project":restored["project"]["reference"],"selection":destination,"destination":null})).await; + let relocated = change(&fixture,json!({"kind":"relocate","project":restored["project"]["reference"],"path":destination,"destination":null})).await; assert_eq!(relocated["kind"], "committed"); assert_eq!(relocated["project"]["reference"]["id"], selected["id"]); let after = fixture.owner.session(session).await.unwrap(); @@ -211,16 +211,16 @@ async fn projects_relocation_rejects_running_and_queued_work_then_merges_without let fixture = Fixture::new(&base, None).await; let source_session = fixture.create("Source").await; let roots = fixture.query("directories", json!({"kind":"roots"})).await.unwrap(); - let destination = roots["roots"][1]["selection"].clone(); - let target = fixture.mutation(2, "create", json!({"title":"Destination", "labels":[], "workspace":{"kind":"directory","selection":destination}}), Uuid::new_v4()).await.unwrap(); + let destination = roots["roots"][1]["path"].clone(); + let target = fixture.mutation(2, "create", json!({"title":"Destination", "labels":[], "workspace":{"kind":"directory","path":destination}}), Uuid::new_v4()).await.unwrap(); let target_id = target["session"]["id"].as_str().unwrap(); let original = fixture.owner.session(&source_session).await.unwrap(); let target_original = fixture.owner.session(target_id).await.unwrap(); let project_id = original.configuration.workspace.project_id.unwrap().to_string(); let catalog = projects(&fixture).await; let source = catalog["projects"].as_array().unwrap().iter().find(|p| p["reference"]["id"] == project_id).unwrap()["reference"].clone(); - let preview = fixture.query("projects", json!({"kind":"directory","selection":destination})).await.unwrap(); - let relocation = json!({"kind":"relocate","project":source,"selection":destination,"destination":preview["location"]["project"]["reference"]}); + let preview = fixture.query("projects", json!({"kind":"directory","path":destination})).await.unwrap(); + let relocation = json!({"kind":"relocate","project":source,"path":destination,"destination":preview["location"]["project"]["reference"]}); fixture.send(&source_session).await.unwrap(); let (mut socket, _) = listener.accept().await.unwrap(); request(&mut socket).await; diff --git a/plugins/agent/native/src/owner/tests/session_context.rs b/plugins/agent/native/src/owner/tests/session_context.rs index 4e5e8826d1a..310f82eeee8 100644 --- a/plugins/agent/native/src/owner/tests/session_context.rs +++ b/plugins/agent/native/src/owner/tests/session_context.rs @@ -116,14 +116,14 @@ async fn no_agents_file_still_exposes_each_selected_workspace_and_current_sessio let child_path = fixture.workspace.join(child_name); std::fs::create_dir(&child_path).unwrap(); let roots = fixture.query("directories", json!({"kind":"roots"})).await.unwrap(); - let children = fixture.query("directories", json!({"kind":"children", "selection":roots["roots"][0]["selection"], "after":null})).await.unwrap(); + let children = fixture.query("directories", json!({"kind":"children", "path":roots["roots"][0]["path"], "after":null})).await.unwrap(); let child = children["children"].as_array().unwrap().iter().find(|entry| entry["path"] == child_path.to_str().unwrap()).unwrap(); let mut captured = Vec::new(); for (title, selection) in [ - ("Quoted Unicode workspace", child["selection"].clone()), - ("Independent workspace", roots["roots"][1]["selection"].clone()), + ("Quoted Unicode workspace", child["path"].clone()), + ("Independent workspace", roots["roots"][1]["path"].clone()), ] { - let created = fixture.mutation(2, "create", json!({"title":title,"labels":[],"workspace":{"kind":"directory","selection":selection}}), Uuid::new_v4()).await.unwrap(); + let created = fixture.mutation(2, "create", json!({"title":title,"labels":[],"workspace":{"kind":"directory","path":selection}}), Uuid::new_v4()).await.unwrap(); let session = created["session"].clone(); let id = session["id"].as_str().unwrap(); fixture.send(id).await.unwrap(); diff --git a/plugins/agent/native/src/owner/tests/skills.rs b/plugins/agent/native/src/owner/tests/skills.rs index bcd8d136a5e..1353b22ec9b 100644 --- a/plugins/agent/native/src/owner/tests/skills.rs +++ b/plugins/agent/native/src/owner/tests/skills.rs @@ -159,7 +159,7 @@ async fn standard_skills_follow_each_actual_session_for_preparation_and_model_re for (index,instructions) in [(0,"project A instructions"),(1,"project B instructions")] { let path=std::path::Path::new(roots["roots"][index]["path"].as_str().unwrap()); write(&path.join(".agents/skills"),"review",instructions); - let created=f.mutation(2,"create",json!({"title":"Project skill","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][index]["selection"]}}),Uuid::new_v4()).await.unwrap(); + let created=f.mutation(2,"create",json!({"title":"Project skill","labels":[],"workspace":{"kind":"directory","path":roots["roots"][index]["path"]}}),Uuid::new_v4()).await.unwrap(); let session=created["session"]["id"].as_str().unwrap(); let candidates=f.query("skillCandidates",json!({"sessionId":session,"backend":model_backend(&f.selection().await),"query":""})).await.unwrap(); assert_eq!(candidates["skills"].as_array().unwrap().len(),1,"{candidates}"); diff --git a/plugins/agent/native/src/owner/tests/workhub.rs b/plugins/agent/native/src/owner/tests/workhub.rs index e3662260594..69e6ed65b87 100644 --- a/plugins/agent/native/src/owner/tests/workhub.rs +++ b/plugins/agent/native/src/owner/tests/workhub.rs @@ -338,7 +338,7 @@ pub(super) async fn selection(fixture: &Fixture) -> Value { fixture .query("directories", json!({"kind":"roots"})) .await - .unwrap()["roots"][0]["selection"] + .unwrap()["roots"][0]["path"] .clone() } @@ -448,7 +448,7 @@ async fn worker_mutation(fixture: &Fixture, method: &str, input: Value) -> Value } async fn worker_session(fixture: &Fixture) -> String { let roots = query_import(fixture, "worker", "directories", json!({"kind":"roots"})).await; - worker_mutation(fixture,"create",json!({"title":"Worker","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]}})).await["session"]["id"].as_str().unwrap().into() + worker_mutation(fixture,"create",json!({"title":"Worker","labels":[],"workspace":{"kind":"directory","path":roots["roots"][0]["path"]}})).await["session"]["id"].as_str().unwrap().into() } async fn task_response(socket: &mut TcpStream, input: Value) { let input = json!({"request": input}); @@ -729,8 +729,8 @@ async fn workhub_creates_selected_worker_and_transfers_original_attachment_bytes let ready=resolve(&fixture,selection(&fixture).await,Uuid::new_v4()).await; let coordinator=ready["session"]["id"].as_str().unwrap().to_owned(); let directories=control_turn(&fixture,&listener,&coordinator,json!({"operation":"directories","worker":"worker","location":{"kind":"roots"}})).await; - let directory=directories["roots"][0]["selection"].clone(); - assert!(directory.is_object()); + let directory=directories["roots"][0]["path"].clone(); + assert!(directory.is_string()); let files: Vec<_> = (0..9).map(|index| (format!("transfer-{index}.txt"), if index == 0 { "original user attachment\n中\0".repeat(6000).into_bytes() } else { format!("original user attachment {index}\n中\0tail-{index}").into_bytes() })).collect(); let mut sources=Vec::new(); for (name,bytes) in &files { @@ -813,7 +813,7 @@ async fn workhub_dependencies_wait_cancel_and_dispatch_once_with_complete_origin let worker = worker_session(&fixture).await; let (coordinator, parent, mut running) = active_task(&fixture, &listener, &worker).await; let directory = query_import(&fixture, "worker", "directories", json!({"kind":"roots"})) - .await["roots"][0]["selection"] + .await["roots"][0]["path"] .clone(); let route = |title: &str, model: ModelSelection| { json!({"operation":"route","worker":"worker", diff --git a/plugins/agent/native/src/owner/tests/workhub/graph_tests.rs b/plugins/agent/native/src/owner/tests/workhub/graph_tests.rs index 1e64e94e853..d69eb0238de 100644 --- a/plugins/agent/native/src/owner/tests/workhub/graph_tests.rs +++ b/plugins/agent/native/src/owner/tests/workhub/graph_tests.rs @@ -102,7 +102,7 @@ async fn graph_real_model_dispatches_original_work_wakes_and_selects_actual_resu assert_eq!(updated["kind"], "committed"); let epoch = change(&fixture, json!({"sessionId":session,"action":{"kind":"start","mode":"swarm"}})).await; assert_eq!(epoch["kind"], "committed", "{epoch}"); - let directory = query_import(&fixture, "worker", "directories", json!({"kind":"roots"})).await["roots"][0]["selection"].clone(); + let directory = query_import(&fixture, "worker", "directories", json!({"kind":"roots"})).await["roots"][0]["path"].clone(); let selection = fixture.selection().await; let preset = json!({"id":"graph-reasoner","name":"Graph reasoner","description":"Frozen graph route", "worker":"worker","tools":[],"model":{"connectionId":selection.connection_id,"modelId":selection.model_id},"thinkingLevel":null,"enabled":true,"isolatedWorkspace":false}); let saved = workhub_mutation(&fixture, "savePresets", json!({"revision":null,"entries":[preset.clone()]})).await; @@ -247,7 +247,7 @@ async fn graph_stop_cancels_original_worker_and_other_coordinator_cannot_read_it let ready = resolve(&fixture, selection(&fixture).await, Uuid::new_v4()).await; let session = ready["session"]["id"].as_str().unwrap(); let epoch = change(&fixture, json!({"sessionId":session,"action":{"kind":"start","mode":"graph"}})).await; - let directory = query_import(&fixture, "worker", "directories", json!({"kind":"roots"})).await["roots"][0]["selection"].clone(); + let directory = query_import(&fixture, "worker", "directories", json!({"kind":"roots"})).await["roots"][0]["path"].clone(); fixture.send(session).await.unwrap(); let (mut socket, _) = listener.accept().await.unwrap(); request(&mut socket).await; graph_tool(&mut socket, "update_agent_graph", json!({"operation":"add_work","work":[{"target":{"kind":"worker","worker":"worker","title":"Original child","workspace":directory,"tools":[],"backend":model_backend(&fixture.selection().await)},"instruction":"Keep running until the original stop arrives."}]})).await; diff --git a/plugins/agent/native/src/owner/tests/workspaces.rs b/plugins/agent/native/src/owner/tests/workspaces.rs index a825786e242..bfb263c9dad 100644 --- a/plugins/agent/native/src/owner/tests/workspaces.rs +++ b/plugins/agent/native/src/owner/tests/workspaces.rs @@ -130,20 +130,21 @@ async fn published_directories_are_readonly_paged_and_create_only_observed_works assert_eq!(roots["roots"].as_array().unwrap().len(), 2); assert!(!fixture.workspace.join(MARKER_FILE).exists()); assert!(!fixture.root.join(MARKER_FILE).exists()); - let selection = roots["roots"][0]["selection"].clone(); + let root = roots["roots"][0]["path"].clone(); let page = fixture .query( "directories", - json!({"kind":"children","selection":selection,"after":null}), + json!({"kind":"children","path":root,"after":null}), ) .await .unwrap(); + assert_eq!(page["directory"]["path"], root); assert_eq!(page["children"].as_array().unwrap().len(), 32); assert_eq!(page["after"], "dir-31"); let tail = fixture .query( "directories", - json!({"kind":"children","selection":selection,"after":page["after"]}), + json!({"kind":"children","path":root,"after":page["after"]}), ) .await .unwrap(); @@ -152,37 +153,41 @@ async fn published_directories_are_readonly_paged_and_create_only_observed_works for (children, indices) in [(&page["children"], 0..32), (&tail["children"], 32..36)] { let expected: Vec<_> = indices.map(|index| { let name = format!("dir-{index:02}"); - let (path, identity) = maka_fs_tools::directory::capture(&fixture.workspace.join(&name)).unwrap(); - json!({"name":name,"path":maka_fs_tools::workspace::project::host_path(&path).unwrap(), - "selection":{"rootId":"root-1","segments":[name],"directoryIdentity":identity}}) + let (path, _) = maka_fs_tools::directory::capture(&fixture.workspace.join(&name)).unwrap(); + json!({"name":name,"path":maka_fs_tools::workspace::project::host_path(&path).unwrap()}) }).collect(); assert_eq!(*children, json!(expected)); } assert!(!fixture.workspace.join(MARKER_FILE).exists()); - for (field, value) in [ - ("rootId", json!("unknown")), - ("segments", json!([".."])), - ( - "directoryIdentity", - json!(format!("sha256:{}", "0".repeat(64))), - ), + // Outside the published locations, missing, a file, or relative: refused + // before any effect. + let outside = tempfile::tempdir().unwrap(); + std::fs::write(fixture.workspace.join("a-file"), []).unwrap(); + for invalid in [ + json!(outside.path().to_str().unwrap()), + json!(fixture.workspace.join("missing").to_str().unwrap()), + json!(fixture.workspace.join("a-file").to_str().unwrap()), + json!("dir-03"), ] { - let mut invalid = selection.clone(); - invalid[field] = value; - let result = fixture.mutation(2, "create", json!({"title":"Rejected","labels":[],"workspace":{"kind":"directory","selection":invalid}}), Uuid::new_v4()).await.unwrap(); + let result = fixture.mutation(2, "create", json!({"title":"Rejected","labels":[],"workspace":{"kind":"directory","path":invalid}}), Uuid::new_v4()).await.unwrap(); assert_eq!( result, - json!({"kind":"rejected","reason":"workspace_unavailable"}) + json!({"kind":"rejected","reason":"workspace_unavailable"}), + "{invalid}" ); } let child = &page["children"][3]; - let result = fixture.mutation(2, "create", json!({"title":"Selected child","labels":["research"],"workspace":{"kind":"directory","selection":child["selection"]}}), Uuid::new_v4()).await.unwrap(); + let result = fixture.mutation(2, "create", json!({"title":"Selected child","labels":["research"],"workspace":{"kind":"directory","path":child["path"]}}), Uuid::new_v4()).await.unwrap(); assert_eq!(result["kind"], "created"); let session = &result["session"]; assert_eq!(session["workspace"]["path"], child["path"]); assert_eq!( session["workspace"]["directoryIdentity"], - child["selection"]["directoryIdentity"] + json!( + maka_fs_tools::directory::capture(&fixture.workspace.join("dir-03")) + .unwrap() + .1 + ) ); assert_eq!(session["labels"], json!(["research"])); assert!(fixture.workspace.join("dir-03").join(MARKER_FILE).exists()); @@ -197,12 +202,12 @@ async fn published_directories_are_readonly_paged_and_create_only_observed_works let repeated = fixture .query( "directories", - json!({"kind":"children","selection":selection,"after":null}) + json!({"kind":"children","path":root,"after":null}) ) .await?; - let remaining = fixture.query("directories", json!({"kind":"children","selection":selection,"after":repeated["after"]})).await?; + let remaining = fixture.query("directories", json!({"kind":"children","path":root,"after":repeated["after"]})).await?; let query_markers: Vec<_> = (0..36).map(|index| fixture.workspace.join(format!("dir-{index:02}")).join(MARKER_FILE).exists()).collect(); - let created = fixture.mutation(2, "create", json!({"title":"Paged child","labels":[],"workspace":{"kind":"directory","selection":remaining["children"][0]["selection"]}}), Uuid::new_v4()).await?; + let created = fixture.mutation(2, "create", json!({"title":"Paged child","labels":[],"workspace":{"kind":"directory","path":remaining["children"][0]["path"]}}), Uuid::new_v4()).await?; Ok::<_, String>((repeated, remaining, query_markers, created)) }.await; fixture.drained().await; @@ -225,7 +230,11 @@ async fn published_directories_are_readonly_paged_and_create_only_observed_works ); assert_eq!( created["session"]["workspace"]["directoryIdentity"], - tail["children"][0]["selection"]["directoryIdentity"] + json!( + maka_fs_tools::directory::capture(&fixture.workspace.join("dir-32")) + .unwrap() + .1 + ) ); assert_eq!( created["session"]["workspace"]["identity"], diff --git a/plugins/agent/native/src/owner/worktrees.rs b/plugins/agent/native/src/owner/worktrees.rs index 7dfa3642c2e..2ecafc935c2 100644 --- a/plugins/agent/native/src/owner/worktrees.rs +++ b/plugins/agent/native/src/owner/worktrees.rs @@ -31,7 +31,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; struct Allocation { session: String, fingerprint: String, - selection: DirectorySelection, + /// The source directory as the Host captured it when allocating. + path: String, + identity: maka_agent_facts::execution::DirectoryIdentity, binding: Binding, } @@ -60,7 +62,7 @@ pub(super) async fn blocking( impl Owner { pub(super) async fn isolated_workspace( &self, - selection: DirectorySelection, + path: &str, operation: &maka_plugin_core::operation::RecoveryRef, scope: &Scope, ) -> Result, String> { @@ -86,7 +88,8 @@ impl Owner { None => { let Some(source) = workspace::selected( self.workspace_roots.clone(), - selection.clone(), + path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? @@ -120,7 +123,8 @@ impl Owner { }; let Some(observed) = workspace::selected( self.workspace_roots.clone(), - selection.clone(), + path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? @@ -128,14 +132,15 @@ impl Owner { return Ok(None); }; if std::path::Path::new(&observed.path) != binding.source() - || binding.source_identity() != &selection.directory_identity + || binding.source_identity() != &observed.identity { return Ok(None); } let allocation = Allocation { session: session.clone(), fingerprint: operation.fingerprint.clone(), - selection: selection.clone(), + path: observed.path, + identity: observed.identity, binding, }; match self @@ -169,23 +174,25 @@ impl Owner { } } }; - if allocation.session != session - || allocation.fingerprint != operation.fingerprint - || allocation.selection != selection - { - return Err("agent_worktree_allocation_conflict".into()); - } let Some(source) = workspace::selected( self.workspace_roots.clone(), - selection, + path, + self.home.as_deref(), self.managed_worktrees.clone(), ) .await? else { return Ok(None); }; + if allocation.session != session + || allocation.fingerprint != operation.fingerprint + || allocation.path != source.path + || allocation.identity != source.identity + { + return Err("agent_worktree_allocation_conflict".into()); + } if std::path::Path::new(&source.path) != allocation.binding.source() - || allocation.binding.source_identity() != &allocation.selection.directory_identity + || allocation.binding.source_identity() != &allocation.identity { return Ok(None); } diff --git a/plugins/agent/native/src/service.rs b/plugins/agent/native/src/service.rs index 47d46a858bb..04e34edd96a 100644 --- a/plugins/agent/native/src/service.rs +++ b/plugins/agent/native/src/service.rs @@ -348,8 +348,12 @@ impl Handler for Agent { ) } "directories" => encode( - maka_workspace_support::query(owner.workspace_roots.clone(), decode(input)?) - .await?, + maka_workspace_support::query( + owner.workspace_roots.clone(), + decode(input)?, + owner.home.as_deref(), + ) + .await?, ), "create" => { let input: Create = decode(input)?; diff --git a/plugins/agent/native/src/workhub.rs b/plugins/agent/native/src/workhub.rs index d1cee8dddde..975daaf63f1 100644 --- a/plugins/agent/native/src/workhub.rs +++ b/plugins/agent/native/src/workhub.rs @@ -55,7 +55,8 @@ pub(crate) fn prompt(language: maka_agent_facts::language::Language) -> &'static #[serde(deny_unknown_fields)] struct Intent { operation: RecoveryRef, - workspace: crate::contract::DirectorySelection, + /// The coordinator's directory as the caller named it. + workspace: String, } pub(crate) struct Hub { agent: Agent, @@ -227,16 +228,15 @@ impl Hub { None => { // Validate the selected directory before committing the immutable // intent. A later replacement cannot retarget this coordinator. - let roots = self.agent.0.workspace_roots.clone(); - let page = maka_workspace_support::query( - roots, - crate::contract::Directories::Children { - selection: input.workspace.clone(), - after: None, - }, + let owner = &self.agent.0; + let located = maka_workspace_support::selected( + owner.workspace_roots.clone(), + &input.workspace, + owner.home.as_deref(), + owner.managed_worktrees.clone(), ) .await; - if !matches!(page, Ok(crate::contract::DirectoryPage::Children { .. })) { + if !matches!(located, Ok(Some(_))) { return Ok(Coordinator::Rejected { reason: crate::contract::Rejection::WorkspaceUnavailable, }); @@ -291,7 +291,7 @@ impl Hub { title: "WorkHub".into(), labels: vec![], workspace: crate::contract::WorkspaceSelection::Directory { - selection: intent.workspace, + path: intent.workspace, }, }, &intent.operation, diff --git a/plugins/agent/native/src/workhub/tasks.rs b/plugins/agent/native/src/workhub/tasks.rs index 0e57892f6fa..dd4b1c3c760 100644 --- a/plugins/agent/native/src/workhub/tasks.rs +++ b/plugins/agent/native/src/workhub/tasks.rs @@ -114,7 +114,7 @@ enum Input { dependencies: Vec, preset: String, title: String, - workspace: crate::contract::DirectorySelection, + workspace: String, note: String, }, Control { @@ -254,7 +254,7 @@ pub(super) fn prepare( vec![ToolRegistration { definition: ToolDefinition { provider: None, name: NAME.into(), - description: "Coordinate work through selected Agent services. Workers, sessions, presets, tools, executors and directories expose available targets and published choices. route sends the original request, attachments and note; route_preset freezes the preset's worker, model, thinking level and tool restrictions. New targets use complete directory selections and a tagged backend: kind:model with connectionId/modelId/thinkingLevel, or kind:executor with selection{executor,settings{runtime,model,thinkingLevel}}. Executor settings use the worker backend, not Host model credentials; executor tasks cannot resume as native invocations. tools: null uses installed tools; [] disables tools. Restricted sessions cannot use external executors. + description: "Coordinate work through selected Agent services. Workers, sessions, presets, tools, executors and directories expose available targets and published choices. route sends the original request, attachments and note; route_preset freezes the preset's worker, model, thinking level and tool restrictions. New targets use a directory path from directories and a tagged backend: kind:model with connectionId/modelId/thinkingLevel, or kind:executor with selection{executor,settings{runtime,model,thinkingLevel}}. Executor settings use the worker backend, not Host model credentials; executor tasks cannot resume as native invocations. tools: null uses installed tools; [] disables tools. Restricted sessions cannot use external executors. Dependencies are existing taskIds; successful final answers are frozen into downstream input. Deferred tasks advance automatically and notify this conversation; failed or shared dependencies reject downstream work. Replacement work requires an explicit route. cancel_pending applies only before input resolution; already_resolved is not a confirmed stop. Use control stop for admitted work. diff --git a/plugins/agent/native/src/workhub/tasks/graph/tools.rs b/plugins/agent/native/src/workhub/tasks/graph/tools.rs index 6935ef81365..373d3bf65c6 100644 --- a/plugins/agent/native/src/workhub/tasks/graph/tools.rs +++ b/plugins/agent/native/src/workhub/tasks/graph/tools.rs @@ -36,7 +36,7 @@ enum Routing { Worker { worker: String, title: String, - workspace: crate::contract::DirectorySelection, + workspace: String, #[serde(default)] isolated_workspace: bool, tools: Option>, @@ -45,7 +45,7 @@ enum Routing { Preset { preset: String, title: String, - workspace: crate::contract::DirectorySelection, + workspace: String, }, Operator { work_id: WorkId, @@ -168,7 +168,7 @@ struct Empty {} pub(in crate::workhub::tasks) fn registrations(link: Link) -> Vec { let handler = Arc::new(GraphTool(link)); [ - ("agent_list", "List explicitly bound workers, presets, worker tools, executors and published directory selections. Workers includes the supervisor's source-tagged backend selection; workers validate copied selections. Graph targets use these exact published choices.", schemars::schema_for!(ToolRequest).into(), ToolSemantics::Parallel), + ("agent_list", "List explicitly bound workers, presets, worker tools, executors and published directories. Workers includes the supervisor's source-tagged backend selection; workers validate copied selections and directory paths. Graph targets use these exact published choices.", schemars::schema_for!(ToolRequest).into(), ToolSemantics::Parallel), ("view_agent_graph", "Read this conversation's current graph or immutable earlier epochs. Status returns at most 16 work items; continue with after. Work returns the complete instruction and input references. Result reads the original immutable terminal result; continue with nextOffset. History returns earlier epochs. Admission is not completion.", schemars::schema_for!(ToolRequest).into(), ToolSemantics::Parallel), ("update_agent_graph", "Add, replace or stop graph work, or finish with actual terminal result IDs. Worker targets must use explicit published workspace and tagged backend choices. Presets freeze their original settings. Operator targets continue an earlier work's original session only after it settles. inputIds await current graph results; selectedResultInputs must reference selected final results of earlier finished epochs. Finish selects terminal results, including failures, and ends this epoch. After a missing reply, inspect the original graph work; a replacement is a separate explicit decision.", schemars::schema_for!(ToolRequest).into(), ToolSemantics::ExclusiveStep), ("yield_agent_graph", "End this supervisor turn and await durable worker outcomes. Graph resumes on durable outcomes. Swarm resumes on attention or when the whole active batch settles, not each successful sibling.", schemars::schema_for!(Empty).into(), ToolSemantics::FinishTurn), diff --git a/plugins/agent/native/src/workhub/tasks/preparation.rs b/plugins/agent/native/src/workhub/tasks/preparation.rs index a7d7dcc8172..67b95eae1bb 100644 --- a/plugins/agent/native/src/workhub/tasks/preparation.rs +++ b/plugins/agent/native/src/workhub/tasks/preparation.rs @@ -42,7 +42,8 @@ pub(super) enum Target { #[schemars(extend("uniqueItems" = true))] tools: Option>, title: String, - workspace: crate::contract::DirectorySelection, + /// The directory as the caller named it; the worker resolves it. + workspace: String, }, } impl Target { @@ -298,7 +299,7 @@ impl Hub { } else { "directory" }; - let mut input = serde_json::json!({"title":title,"labels":[],"tools":tools,"workspace":{"kind":kind,"selection":workspace}}); + let mut input = serde_json::json!({"title":title,"labels":[],"tools":tools,"workspace":{"kind":kind,"path":workspace}}); if let Some(backend) = assignment.input.get("backend") { input["backend"] = backend.clone(); } @@ -333,14 +334,18 @@ impl Hub { let selected: crate::contract::Workspace = serde_json::from_value(value["session"]["workspace"].clone()) .map_err(|_| "workhub_creation_invalid")?; + // The worker resolved the same path; compare what it created + // with the directory the route named. + let requested = + maka_workspace_support::resolve_path(workspace, self.agent.0.home.as_deref()) + .ok_or("workhub_creation_invalid")?; let matches = if *isolated_workspace { selected.worktree.as_ref().is_some_and(|binding| { - binding.source_identity() == &workspace.directory_identity + binding.source() == requested && binding.directory() == std::path::Path::new(&selected.path) }) } else { - selected.worktree.is_none() - && selected.directory_identity == workspace.directory_identity + selected.worktree.is_none() && std::path::Path::new(&selected.path) == requested }; if !matches { return Err("workhub_creation_invalid".into()); @@ -472,7 +477,7 @@ fn directory(directory: &Value) -> Result { Ok(serde_json::json!({ "nameExcerpt":excerpt(directory["name"].as_str().ok_or("workhub_directory_invalid")?,32), "pathExcerpt":excerpt(directory["path"].as_str().ok_or("workhub_directory_invalid")?,48), - "selection":directory["selection"], + "path":directory["path"], })) } pub(super) fn directory_page(page: Value) -> Result { diff --git a/plugins/workspace/support/Cargo.toml b/plugins/workspace/support/Cargo.toml index f799bc30b7f..52c76a2d0da 100644 --- a/plugins/workspace/support/Cargo.toml +++ b/plugins/workspace/support/Cargo.toml @@ -24,6 +24,7 @@ publish.workspace = true [dependencies] maka-agent-api.workspace = true +maka-agent-facts.workspace = true maka-fs-tools.workspace = true maka-plugin-core.workspace = true maka-host-runtime.workspace = true diff --git a/plugins/workspace/support/README.md b/plugins/workspace/support/README.md index 394b6e63da2..90597408345 100644 --- a/plugins/workspace/support/README.md +++ b/plugins/workspace/support/README.md @@ -21,7 +21,7 @@ [简体中文](README.zh-CN.md) -Shares directory selection and validation across workspace services. It opens configured roots, excludes private Host paths and checks a selected workspace against those roots. `Root`, `query` and `validate` are the main entry points. A selected path is usable only within the authority supplied by the owning service. +Shares directory resolution and validation across workspace services. It opens configured roots, resolves a caller's path (absolute, or `~` for the home directory the owning service passes in) to the directory within the most specific root, excludes private Host paths and checks a selected workspace against those roots. `Root`, `query`, `selected`, `initialize` and `validate` are the main entry points. Directory identities are captured here as directories are opened; callers never supply them. A selected path is usable only within the authority supplied by the owning service. Child queries enumerate the selected directory before sorting and returning pages of up to 32 children with a 48 KiB byte target, allowing a complete first child to advance. Ordinary files do not prevent browsing child directories. Continue with the returned `after` cursor; each query revalidates the published root and selected directory identity. diff --git a/plugins/workspace/support/README.zh-CN.md b/plugins/workspace/support/README.zh-CN.md index e27ee890427..c519e8b4b29 100644 --- a/plugins/workspace/support/README.zh-CN.md +++ b/plugins/workspace/support/README.zh-CN.md @@ -21,7 +21,7 @@ [English](README.md) -在工作区服务之间共享目录选择与校验。它打开已配置根目录,排除 Host 私有路径,并检查所选工作区是否属于这些根目录。主要入口是 `Root`、`query` 和 `validate`。所选路径只能在所属服务提供的权限范围内使用。 +在工作区服务之间共享目录解析与校验。它打开已配置根目录,把调用方给出的路径(绝对路径,或以 `~` 代表所属服务传入的主目录)解析为最具体的那个根目录下的目录,排除 Host 私有路径,并检查所选工作区是否属于这些根目录。主要入口是 `Root`、`query`、`selected`、`initialize` 和 `validate`。目录身份在这里打开目录时采集,调用方不提供。所选路径只能在所属服务提供的权限范围内使用。 子目录查询先枚举所选目录,再排序并分页返回;每页最多 32 个子目录,以 48 KiB 为字节目标;完整首项始终可以推进。普通文件不会阻止子目录浏览。使用返回的 `after` 游标继续查询;每次查询都会重新校验已发布根目录和所选目录的身份。 diff --git a/plugins/workspace/support/src/lib.rs b/plugins/workspace/support/src/lib.rs index 728410f355b..3adfac88dc2 100644 --- a/plugins/workspace/support/src/lib.rs +++ b/plugins/workspace/support/src/lib.rs @@ -17,15 +17,17 @@ * under the License. */ -use maka_agent_api::contract::{ - Directories, Directory, DirectoryPage, DirectorySelection, Workspace, -}; +use maka_agent_api::contract::{Directories, Directory, DirectoryPage, Workspace}; +use maka_agent_facts::execution::DirectoryIdentity; use maka_fs_tools::{ directory, workspace::{self, directory::PublishedDirectory}, }; use maka_host_runtime::DirectoryRootSpec; -use std::{path::Path, sync::Arc}; +use std::{ + path::{Component, Path, PathBuf}, + sync::Arc, +}; pub const PAGE_BYTES: usize = 48 * 1024; @@ -109,121 +111,131 @@ pub fn open(specs: Vec, managed: &Path) -> Result, .collect::>()?; Ok(roots) } -fn root<'a>(roots: &'a [Root], id: &str) -> Result<&'a Root, String> { - roots +/// A directory found within a configured location, with the identity captured +/// as it was opened. Only a `Workspace` the Host persisted carries an identity +/// to the wire; callers name directories by path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Located { + pub name: String, + pub path: String, + pub identity: DirectoryIdentity, +} + +/// A caller's path as the directory it names: a leading `~` replaced by `home`, +/// relative paths refused, `.` and `..` resolved before symbolic links are +/// followed. None when nothing is there or the path is not a directory. +pub fn resolve_path(path: &str, home: Option<&Path>) -> Option { + let path = match path.strip_prefix('~') { + Some(rest) if rest.is_empty() || rest.starts_with(['/', '\\']) => { + home?.join(rest.trim_start_matches(['/', '\\'])) + } + _ => PathBuf::from(path), + }; + if !path.is_absolute() { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + let path = normalized.canonicalize().ok()?; + path.is_dir().then_some(path) +} + +/// The most specific configured location holding the canonical `path`, and +/// the directory as captured there. +fn find<'a>(roots: &'a [Root], path: &Path) -> Option<(&'a Root, Located)> { + let root = roots .iter() - .enumerate() - .find(|(index, _)| id == format!("root-{}", index + 1)) - .map(|(_, root)| root) - .ok_or("agent_directory_unavailable".into()) + .filter(|root| path.starts_with(root.directory.path())) + .max_by_key(|root| root.directory.path().as_os_str().len())?; + Some((root, capture(root, path)?)) } -fn choice( - root: &Root, - root_id: String, - segments: Vec, - name: String, -) -> Result { - if segments.iter().any(|s| s.chars().any(char::is_control)) { - return Err("agent_directory_invalid".into()); + +/// The directory at `path` under `root`, opened and identified in one step. +/// Identity capture may use O_PATH; browsing also needs a readable directory. +fn capture(root: &Root, path: &Path) -> Option { + if path + .strip_prefix(root.directory.path()) + .ok()? + .components() + .any(|part| { + part.as_os_str() + .to_str() + .is_none_or(|s| s.chars().any(char::is_control)) + }) + { + return None; } - let selected = root - .directory - .resolve(&segments) - .map_err(|_| "agent_directory_unavailable")?; - let (path, identity) = - directory::capture(&selected).map_err(|_| "agent_directory_unavailable")?; - // Identity capture may use O_PATH; browsing also needs a readable directory. - let _entries = directory::open(&path, &identity) + let (path, identity) = directory::capture(path).ok()?; + directory::open(&path, &identity) .and_then(|directory| directory.entries()) - .map_err(|_| "agent_directory_unavailable")?; - root.directory - .validate(&path) - .map_err(|_| "agent_directory_unavailable")?; - let path = host_path(&path)?; - Ok(Directory { + .ok()?; + root.directory.validate(&path).ok()?; + let name = if path == root.directory.path() { + root.name.clone() + } else { + path.file_name()?.to_str()?.to_owned() + }; + Some(Located { name, - path, - selection: DirectorySelection { - root_id, - segments, - directory_identity: identity, - }, + path: host_path(&path).ok()?, + identity, }) } fn host_path(path: &Path) -> Result { let path = workspace::project::host_path(path).map_err(|_| "agent_directory_invalid")?; Ok(path.into()) } -fn root_page(roots: &[Root]) -> Result { - Ok(DirectoryPage::Roots { +fn entry(located: Located) -> Directory { + Directory { + name: located.name, + path: located.path, + } +} +fn root_page(roots: &[Root]) -> DirectoryPage { + DirectoryPage::Roots { roots: roots .iter() - .enumerate() - .filter_map(|(index, root)| { - choice( - root, - format!("root-{}", index + 1), - vec![], - root.name.clone(), - ) - .ok() - }) + .filter_map(|root| capture(root, root.directory.path())) + .map(entry) .collect(), - }) + } } pub fn fits(value: &T) -> bool { serde_json::to_value(value) - .is_ok_and(|value| maka_plugin_core::json::check_delivery(&value, PAGE_BYTES).is_ok()) + .map(|value| value.to_string().len() <= PAGE_BYTES) + .unwrap_or(false) } -pub async fn query(roots: Vec, input: Directories) -> Result { - query_excluding(roots, input, None).await +pub async fn query( + roots: Vec, + input: Directories, + home: Option<&Path>, +) -> Result { + query_excluding(roots, input, home, None).await } pub async fn query_excluding( roots: Vec, input: Directories, - excluded: Option, + home: Option<&Path>, + excluded: Option, ) -> Result { + let home = home.map(Path::to_owned); tokio::task::spawn_blocking(move || { - let (selection, after) = match input { - Directories::Roots => return root_page(&roots), - Directories::Path { path } => { - let DirectoryPage::Roots { roots: locations } = root_page(&roots)? else { - unreachable!() - }; - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .filter(|home| !home.is_empty()) - .map(std::path::PathBuf::from); - let directory = located(&path, home.as_deref()) - .and_then(|path| find(&roots, &path)) - .filter(|directory| { - excluded - .as_ref() - .is_none_or(|path| !Path::new(&directory.path).starts_with(path)) - }); - return Ok(DirectoryPage::Path { - directory, - roots: locations, - }); - } - Directories::Children { selection, after } => (selection, after), + let Directories::Children { path, after } = input else { + return Ok(root_page(&roots)); }; - let root = root(&roots, &selection.root_id)?; - let name = selection - .segments - .last() - .cloned() - .unwrap_or_else(|| root.name.clone()); - let selected = choice( - root, - selection.root_id.clone(), - selection.segments.clone(), - name, - )?; - if selected.selection.directory_identity != selection.directory_identity - || excluded - .as_ref() - .is_some_and(|path| Path::new(&selected.path).starts_with(path)) + let path = resolve_path(&path, home.as_deref()).ok_or("agent_directory_unavailable")?; + let (root, selected) = find(&roots, &path).ok_or("agent_directory_unavailable")?; + if excluded + .as_ref() + .is_some_and(|excluded| Path::new(&selected.path).starts_with(excluded)) { return Err("agent_directory_unavailable".into()); } @@ -234,9 +246,16 @@ pub async fn query_excluding( }) { return Err("agent_directory_invalid".into()); } + let segments = Path::new(&selected.path) + .strip_prefix(root.directory.path()) + .map_err(|_| "agent_directory_unavailable")? + .components() + .map(|part| part.as_os_str().to_str().map(str::to_owned)) + .collect::>>() + .ok_or("agent_directory_invalid")?; let mut names: Vec<_> = root .directory - .directory_names(&selection.segments) + .directory_names(&segments) .map_err(|_| "agent_directory_unavailable")? .into_iter() .filter(|name| { @@ -248,28 +267,24 @@ pub async fn query_excluding( .collect(); if let Some(excluded) = &excluded { names.retain(|name| { - let mut segments = selection.segments.clone(); - segments.push(name.clone()); root.directory - .resolve(&segments) + .resolve(&[segments.clone(), vec![name.clone()]].concat()) .is_ok_and(|path| !path.starts_with(excluded)) }); } let mut children = Vec::new(); let mut remaining = names.iter(); for name in remaining.by_ref() { - let mut segments = selection.segments.clone(); - segments.push(name.clone()); - let Ok(child) = choice(root, selection.root_id.clone(), segments, name.clone()) else { + let Some(child) = capture(root, &Path::new(&selected.path).join(name)) else { continue; }; - children.push(child); + children.push(entry(child)); if children.len() == 32 { break; } } let mut page = DirectoryPage::Children { - directory: selected, + directory: entry(selected.clone()), after: remaining .next() .is_some() @@ -289,11 +304,7 @@ pub async fn query_excluding( children.pop(); *after = children.last().map(|child| child.name.clone()); } - let path = root - .directory - .resolve(&selection.segments) - .map_err(|_| "agent_directory_unavailable")?; - directory::open(&path, &selection.directory_identity) + directory::open(Path::new(&selected.path), &selected.identity) .map_err(|_| "agent_directory_unavailable")?; Ok(page) }) @@ -301,46 +312,39 @@ pub async fn query_excluding( .map_err(|_| "agent_directory_unavailable")? } -/// None is a confirmed pre-effect refusal. Marker initialization errors remain Unknown. +/// The workspace at `path`, with its directory identity captured as the +/// directory is opened and its workspace marker ensured. None is a confirmed +/// pre-effect refusal. Marker initialization errors remain Unknown. pub async fn initialize( roots: &[Root], - selection: DirectorySelection, + path: &str, + home: Option<&Path>, managed: &Path, private_root: &Path, control: &Path, ) -> Result, String> { - let Ok(root) = root(roots, &selection.root_id) else { + let Some(path) = resolve_path(path, home) else { return Ok(None); }; - if selection - .segments - .iter() - .any(|s| s.chars().any(char::is_control)) - { - return Ok(None); - } - let published = root.directory.clone(); - let captured = published.clone(); + let roots = roots.to_vec(); let managed = managed.to_owned(); - let expected = selection.directory_identity.clone(); let prepared = tokio::task::spawn_blocking(move || { - let path = captured.resolve(&selection.segments).ok()?; + let (root, located) = find(&roots, &path)?; + let path = PathBuf::from(&located.path); if path.starts_with(&managed) { return None; } - host_path(&path).ok()?; - let directory = directory::open(&path, &expected).ok()?; - captured.validate(&path).ok()?; + let directory = directory::open(&path, &located.identity).ok()?; match workspace::read_directory_identity(path.clone(), directory.try_clone().ok()?) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return None, } - Some((path, directory)) + Some((root.directory.clone(), path, directory, located.identity)) }) .await .map_err(|_| "agent_workspace_unavailable")?; - let Some((path, directory)) = prepared else { + let Some((published, path, directory, directory_identity)) = prepared else { return Ok(None); }; let identity = workspace::ensure_directory_identity( @@ -355,13 +359,13 @@ pub async fn initialize( published .validate(&path) .map_err(|_| "agent_workspace_initialization_failed")?; - directory::open(&path, &selection.directory_identity) + directory::open(&path, &directory_identity) .map_err(|_| "agent_workspace_initialization_failed")?; Ok(Some(Workspace { worktree: None, project_id: None, path: host_path(&path)?, - directory_identity: selection.directory_identity, + directory_identity, identity, })) } @@ -414,52 +418,45 @@ pub async fn validate(roots: &[Root], selected: &Workspace, managed: &Path) -> R /// Re-observe a published directory without creating a workspace marker. pub async fn selected( roots: Vec, - selection: DirectorySelection, - managed: std::path::PathBuf, -) -> Result, String> { - resolve_selection(roots, selection, managed, true).await + path: &str, + home: Option<&Path>, + managed: PathBuf, +) -> Result, String> { + resolve(roots, path, home, managed, true).await } +/// A published directory as it is, marker or not; `excluded` and below is +/// not published. pub async fn observed( roots: Vec, - selection: DirectorySelection, - excluded: std::path::PathBuf, -) -> Result, String> { - resolve_selection(roots, selection, excluded, false).await + path: &str, + home: Option<&Path>, + excluded: PathBuf, +) -> Result, String> { + resolve(roots, path, home, excluded, false).await } -async fn resolve_selection( +async fn resolve( roots: Vec, - selection: DirectorySelection, - managed: std::path::PathBuf, + path: &str, + home: Option<&Path>, + excluded: PathBuf, workspace_marker: bool, -) -> Result, String> { +) -> Result, String> { + let Some(path) = resolve_path(path, home) else { + return Ok(None); + }; tokio::task::spawn_blocking(move || { - let Ok(root) = root(&roots, &selection.root_id) else { - return None; - }; - let selected = choice( - root, - selection.root_id.clone(), - selection.segments.clone(), - selection - .segments - .last() - .cloned() - .unwrap_or_else(|| root.name.clone()), - ) - .ok()?; - if selected.selection.directory_identity != selection.directory_identity - || Path::new(&selected.path).starts_with(&managed) - { + let (_, located) = find(&roots, &path)?; + let path = PathBuf::from(&located.path); + if path.starts_with(&excluded) { return None; } - let path = std::path::PathBuf::from(&selected.path); - let directory = directory::open(&path, &selection.directory_identity).ok()?; + let directory = directory::open(&path, &located.identity).ok()?; if !workspace_marker { - return Some(selected); + return Some(located); } match workspace::read_directory_identity(path, directory) { - Ok(_) => Some(selected), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(selected), + Ok(_) => Some(located), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(located), Err(_) => None, } }) @@ -467,70 +464,46 @@ async fn resolve_selection( .map_err(|_| "agent_directory_unavailable".into()) } -pub async fn selection_at( - roots: Vec, - path: String, - managed: std::path::PathBuf, -) -> Result, String> { - tokio::task::spawn_blocking(move || { - let path = Path::new(&path); - if path.starts_with(&managed) { - return None; - } - find(&roots, path).map(|directory| directory.selection) - }) - .await - .map_err(|_| "agent_directory_unavailable".into()) -} - -/// The directory at `path` within the first location that holds it. `path` -/// is compared as captured, so a path that reaches the directory through -/// symbolic links is not found. -fn find(roots: &[Root], path: &Path) -> Option { - for (index, root) in roots.iter().enumerate() { - let Ok(relative) = path.strip_prefix(root.directory.path()) else { - continue; - }; - let segments = relative - .components() - .map(|part| part.as_os_str().to_str().map(str::to_owned)) - .collect::>>()?; - let name = segments - .last() - .cloned() - .unwrap_or_else(|| root.name.clone()); - let Ok(selected) = choice(root, format!("root-{}", index + 1), segments, name) else { - continue; - }; - if Path::new(&selected.path) == path { - return Some(selected); - } - } - None -} - -/// A caller's path as the directory it names: a leading `~` replaced by -/// `home`, relative paths refused, symbolic links followed. None when nothing -/// is there. -fn located(path: &str, home: Option<&Path>) -> Option { - let path = match path.strip_prefix('~') { - Some(rest) if rest.is_empty() || rest.starts_with(['/', '\\']) => { - home?.join(rest.trim_start_matches(['/', '\\'])) - } - _ => std::path::PathBuf::from(path), - }; - if !path.is_absolute() { - return None; - } - let path = path.canonicalize().ok()?; - path.is_dir().then_some(path) -} - #[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + fn roots(temporary: &Path, published: &Path) -> Vec { + open( + vec![DirectoryRootSpec { + label: "Projects".into(), + path: published.to_owned(), + }], + &temporary.join("managed"), + ) + .unwrap() + } + async fn children( + roots: &[Root], + path: &Path, + after: Option, + ) -> Result<(Directory, Vec, Option), String> { + let page = query( + roots.to_vec(), + Directories::Children { + path: path.to_str().unwrap().into(), + after, + }, + None, + ) + .await?; + let DirectoryPage::Children { + directory, + children, + after, + } = page + else { + panic!("children") + }; + Ok((directory, children, after)) + } + #[tokio::test] async fn inaccessible_children_do_not_break_paging_but_parent_failure_is_reported() { let temporary = tempfile::tempdir().unwrap(); @@ -540,20 +513,16 @@ mod tests { std::fs::create_dir(published.join(format!("folder-{index:02}"))).unwrap(); } let denied = published.join("folder-01"); - let roots = open( - vec![DirectoryRootSpec { - label: "Projects".into(), - path: published.clone(), - }], - &temporary.path().join("managed"), - ) - .unwrap(); + let roots = roots(temporary.path(), &published); let DirectoryPage::Roots { roots: entries } = - query(roots.clone(), Directories::Roots {}).await.unwrap() + query(roots.clone(), Directories::Roots {}, None) + .await + .unwrap() else { panic!("roots") }; - let selection = entries[0].selection.clone(); + assert_eq!(entries[0].name, "Projects"); + let root = PathBuf::from(&entries[0].path); std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o0)).unwrap(); assert_eq!( std::fs::read_dir(&denied) @@ -561,58 +530,31 @@ mod tests { .kind(), std::io::ErrorKind::PermissionDenied ); - let page = query( - roots.clone(), - Directories::Children { - selection: selection.clone(), - after: None, - }, - ) - .await; + let page = children(&roots, &root, None).await; std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o700)).unwrap(); - let DirectoryPage::Children { - children, after, .. - } = page.unwrap() - else { - panic!("children") - }; - assert_eq!(children.len(), 32); - assert!(!children.iter().any(|child| child.name == "folder-01")); + let (directory, listed, after) = page.unwrap(); + assert_eq!(directory.path, entries[0].path); + assert_eq!(listed.len(), 32); + assert!(!listed.iter().any(|child| child.name == "folder-01")); assert_eq!(after.as_deref(), Some("folder-32")); - let DirectoryPage::Children { - children, after, .. - } = query( - roots.clone(), - Directories::Children { - selection: selection.clone(), - after, - }, - ) - .await - .unwrap() - else { - panic!("children") - }; + let (_, listed, after) = children(&roots, &root, after).await.unwrap(); assert_eq!( - children + listed .iter() .map(|child| child.name.as_str()) .collect::>(), ["folder-33", "folder-34"] ); assert!(after.is_none()); - let DirectoryPage::Children { children, .. } = query( - roots.clone(), - Directories::Children { - selection: selection.clone(), - after: None, - }, - ) - .await - .unwrap() else { - panic!("children after permission restoration") - }; - assert!(children.iter().any(|child| child.name == "folder-01")); + let (_, listed, _) = children(&roots, &root, None).await.unwrap(); + assert!(listed.iter().any(|child| child.name == "folder-01")); + // A child is named by its own path, and its page names it too. + let (folder, inside, _) = children(&roots, &root.join("folder-02"), None) + .await + .unwrap(); + assert_eq!(folder.name, "folder-02"); + assert_eq!(folder.path, listed[2].path); + assert!(inside.is_empty()); std::fs::set_permissions(&published, std::fs::Permissions::from_mode(0o0)).unwrap(); assert_eq!( std::fs::read_dir(&published) @@ -620,111 +562,84 @@ mod tests { .kind(), std::io::ErrorKind::PermissionDenied ); - let unavailable = query( - roots.clone(), - Directories::Children { - selection: selection.clone(), - after: None, - }, - ) - .await; + let unavailable = children(&roots, &root, None).await; std::fs::set_permissions(&published, std::fs::Permissions::from_mode(0o700)).unwrap(); assert_eq!(unavailable.err().unwrap(), "agent_directory_unavailable"); std::fs::rename(&published, temporary.path().join("moved")).unwrap(); assert_eq!( - query( - roots, - Directories::Children { - selection, - after: None - } - ) - .await - .err() - .unwrap(), + children(&roots, &root, None).await.err().unwrap(), "agent_directory_unavailable" ); } #[tokio::test] - async fn a_path_resolves_to_the_same_selection_browsing_reaches() { + async fn directories_are_named_by_path_within_the_most_specific_location() { let temporary = tempfile::tempdir().unwrap(); let published = temporary.path().join("published"); - std::fs::create_dir_all(published.join("code").join("app")).unwrap(); + let app = published.join("code").join("app"); + std::fs::create_dir_all(&app).unwrap(); std::fs::create_dir_all(temporary.path().join("elsewhere")).unwrap(); + let managed = temporary.path().join("managed"); let roots = open( - vec![DirectoryRootSpec { - label: "Workspace".into(), - path: published.clone(), - }], - &temporary.path().join("managed"), + vec![ + DirectoryRootSpec { + label: "Projects".into(), + path: published.clone(), + }, + DirectoryRootSpec { + label: "Code".into(), + path: published.join("code"), + }, + ], + &managed, ) .unwrap(); - async fn browse(roots: &[Root], segments: &[&str]) -> Directory { - let DirectoryPage::Roots { roots: entries } = - query(roots.to_vec(), Directories::Roots).await.unwrap() - else { - unreachable!() - }; - let mut directory = entries.into_iter().next().unwrap(); - for name in segments { - let DirectoryPage::Children { children, .. } = query( - roots.to_vec(), - Directories::Children { - selection: directory.selection.clone(), - after: None, - }, - ) - .await - .unwrap() else { - unreachable!() - }; - directory = children.into_iter().find(|c| c.name == *name).unwrap(); - } - directory - } - async fn lookup(roots: &[Root], path: String) -> Option { - let DirectoryPage::Path { directory, roots } = - query(roots.to_vec(), Directories::Path { path }) - .await - .unwrap() - else { - unreachable!() - }; - assert_eq!(roots.len(), 1); - assert_eq!(roots[0].name, "Workspace"); - directory - } - let app = published.join("code").join("app"); - let found = lookup(&roots, app.to_str().unwrap().into()).await.unwrap(); - let browsed = browse(&roots, &["code", "app"]).await; - assert_eq!(found.selection, browsed.selection); + let located = |path: String| { + let roots = roots.clone(); + let managed = managed.clone(); + async move { selected(roots, &path, None, managed).await.unwrap() } + }; + let found = located(app.display().to_string()).await.unwrap(); + let canonical = app.canonicalize().unwrap(); assert_eq!(found.name, "app"); - assert_eq!(found.path, browsed.path); - // The location itself, and a path written with `..` and a trailing slash. - let root = lookup(&roots, published.to_str().unwrap().into()) + assert_eq!(found.path, canonical.to_str().unwrap()); + assert_eq!(found.identity, directory::capture(&app).unwrap().1); + // Written with `.`, `..` and a trailing separator: the same directory. + let dotted = format!("{}/./code/../code/app/", published.display()); + assert_eq!(located(dotted).await.unwrap(), found); + // A location names itself; the deeper of two overlapping locations wins. + let code = located(published.join("code").display().to_string()) .await .unwrap(); - assert_eq!(root.selection, browse(&roots, &[]).await.selection); - let dotted = format!("{}/code/../code/app/", published.display()); + assert_eq!(code.name, "Code"); + let projects = located(published.display().to_string()).await.unwrap(); + assert_eq!(projects.name, "Projects"); + let DirectoryPage::Roots { roots: entries } = + query(roots.clone(), Directories::Roots {}, None) + .await + .unwrap() + else { + panic!("roots") + }; assert_eq!( - lookup(&roots, dotted).await.unwrap().selection, - browsed.selection + entries + .iter() + .map(|root| root.path.as_str()) + .collect::>(), + [projects.path.as_str(), code.path.as_str()] ); - // Outside the locations, missing, a file, and relative: no directory. + // Outside the locations, missing, a file, relative, or managed: nothing. + std::fs::write(app.join("file.txt"), "x").unwrap(); + std::fs::create_dir_all(managed.join("wt")).unwrap(); for absent in [ temporary.path().join("elsewhere").display().to_string(), app.join("missing").display().to_string(), + app.join("file.txt").display().to_string(), "code/app".to_owned(), + managed.join("wt").display().to_string(), ] { - assert!(lookup(&roots, absent.clone()).await.is_none(), "{absent}"); + assert!(located(absent.clone()).await.is_none(), "{absent}"); } - std::fs::write(app.join("file.txt"), "x").unwrap(); - assert!( - lookup(&roots, app.join("file.txt").display().to_string()) - .await - .is_none() - ); } #[test] @@ -733,20 +648,27 @@ mod tests { let home = temporary.path().join("home"); std::fs::create_dir_all(home.join("code")).unwrap(); let canonical = home.canonicalize().unwrap(); - assert_eq!(located("~", Some(&home)), Some(canonical.clone())); - assert_eq!(located("~/code", Some(&home)), Some(canonical.join("code"))); + assert_eq!(resolve_path("~", Some(&home)), Some(canonical.clone())); assert_eq!( - located("~/code/", Some(&home)), + resolve_path("~/code", Some(&home)), Some(canonical.join("code")) ); - assert_eq!(located("~/missing", Some(&home)), None); - assert_eq!(located("~/code", None), None); + assert_eq!( + resolve_path("~/code/", Some(&home)), + Some(canonical.join("code")) + ); + assert_eq!( + resolve_path("~/code/..", Some(&home)), + Some(canonical.clone()) + ); + assert_eq!(resolve_path("~/missing", Some(&home)), None); + assert_eq!(resolve_path("~/code", None), None); // `~user` and a `~` later in the path are not expansions. - assert_eq!(located("~someone/code", Some(&home)), None); + assert_eq!(resolve_path("~someone/code", Some(&home)), None); let literal = home.join("~"); std::fs::create_dir(&literal).unwrap(); assert_eq!( - located(literal.to_str().unwrap(), Some(&home)), + resolve_path(literal.to_str().unwrap(), Some(&home)), Some(canonical.join("~")) ); } diff --git a/products/maka/acp/src/session.rs b/products/maka/acp/src/session.rs index 71ff8e573dd..1bcc8a59ebb 100644 --- a/products/maka/acp/src/session.rs +++ b/products/maka/acp/src/session.rs @@ -126,8 +126,8 @@ impl Registry { if peer.stop.is_cancelled() { return Err("ACP transport closed".into()); } - let selection = list::select(client, &cwd).await?; - let Created::Created { session } = client.mutate("agent", "create", json!({"title":"New session","labels":[],"workspace":{"kind":"directory","selection":selection}}), Attachments::new()).await? else { + let path = cwd.to_str().ok_or("Workspace cwd must be valid UTF-8")?; + let Created::Created { session } = client.mutate("agent", "create", json!({"title":"New session","labels":[],"workspace":{"kind":"directory","path":path}}), Attachments::new()).await? else { return Err("Host rejected session creation".into()); }; let id = session.id.clone(); diff --git a/products/maka/acp/src/session/list.rs b/products/maka/acp/src/session/list.rs index 61c03b1517d..e1fa70c7168 100644 --- a/products/maka/acp/src/session/list.rs +++ b/products/maka/acp/src/session/list.rs @@ -20,9 +20,7 @@ use crate::{Error, client::Client}; use agent_client_protocol::schema::v2 as acp; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use maka_agent_api::contract::{ - DirectoryPage, DirectorySelection, Session, SessionActivity, SessionPage, -}; +use maka_agent_api::contract::{Session, SessionActivity, SessionPage}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::{Component, Path, PathBuf}; @@ -44,59 +42,6 @@ pub(super) fn directory(path: &Path) -> Result { } Ok(path.components().collect()) } -pub(super) async fn select(client: &Client, cwd: &Path) -> Result { - let DirectoryPage::Roots { roots } = client - .query("agent", "directories", json!({"kind":"roots"})) - .await? - else { - return Err("Host omitted published workspace roots".into()); - }; - let mut current = roots - .into_iter() - .filter(|root| cwd.starts_with(&root.path)) - .max_by_key(|root| root.path.len()) - .ok_or("Workspace cwd is outside published Host roots")?; - loop { - if Path::new(¤t.path) == cwd { - return Ok(current.selection); - } - let mut after = None; - let mut seen = std::collections::HashSet::new(); - current = loop { - let DirectoryPage::Children { - children, - after: next, - .. - } = client - .query( - "agent", - "directories", - json!({"kind":"children","selection":current.selection,"after":after}), - ) - .await? - else { - return Err("Host omitted workspace children".into()); - }; - if let Some(child) = children.into_iter().find(|child| { - let path = Path::new(&child.path); - // Canonical aliases may point back to this directory or an - // ancestor. Every step must consume part of the target path. - path != Path::new(¤t.path) - && path.starts_with(¤t.path) - && cwd.starts_with(path) - }) { - break child; - } - let Some(next) = next else { - return Err("Workspace cwd is unavailable in the published Host root".into()); - }; - if seen.len() >= 4096 || !seen.insert(next.clone()) { - return Err("Directory pagination exceeded its limit".into()); - } - after = Some(next); - }; - } -} #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Cursor { diff --git a/products/maka/bundle/src/installation.ts b/products/maka/bundle/src/installation.ts index c11b1a277f0..1724a19c068 100644 --- a/products/maka/bundle/src/installation.ts +++ b/products/maka/bundle/src/installation.ts @@ -183,15 +183,10 @@ export function installation( 'SessionHistory', 'Read a conversation transcript to inspect actual delegated results and failures. Page with returned cursors; tool metadata alone is not the full tool output. Use the indicated result reader when evidence is archived.', ], - [ - 'directories', - 'WorkspaceLookup', - 'Find the workspace selection for a directory before SessionCreate: kind=path with an absolute path (~ allowed) returns the directory with its selection, or null when the path is outside the configured workspace locations, which are listed as roots. kind=roots lists the locations; kind=children browses one. Selections come only from here or from an existing conversation; rootId and directoryIdentity cannot be derived or guessed.', - ], [ 'create', 'SessionCreate', - 'Create a child conversation for a distinct delegated task. Choose a concise, specific task title that a person can recognize later (for example, "Fix queued-message cancellation", not "worker 1"). The child inherits your current sandbox policy, not your one-off approvals. Select a workspace: a directory selection from WorkspaceLookup, or kind=session to reuse a conversation\'s workspace; never invent rootId or directoryIdentity. Then use SessionSend to provide the objective, relevant context, constraints and expected deliverable.', + 'Create a child conversation for a distinct delegated task. Choose a concise, specific task title that a person can recognize later (for example, "Fix queued-message cancellation", not "worker 1"). The child inherits your current sandbox policy, not your one-off approvals. Select a workspace by directory path (absolute, ~ allowed, within the configured workspace locations) or reuse a conversation\'s with kind=session, then use SessionSend to provide the objective, relevant context, constraints and expected deliverable.', ], [ 'fork', diff --git a/products/maka/cli/src/acp/tests/support.rs b/products/maka/cli/src/acp/tests/support.rs index b67e38bc453..4ae38c46737 100644 --- a/products/maka/cli/src/acp/tests/support.rs +++ b/products/maka/cli/src/acp/tests/support.rs @@ -188,7 +188,7 @@ impl Fixture { native.catalogue().startup_error(), ); let roots = query(&native, "agent", "directories", json!({"kind":"roots"})).await; - let created = mutate(&native, "agent", "create", json!({"title":"SDK session","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]}})).await; + let created = mutate(&native, "agent", "create", json!({"title":"SDK session","labels":[],"workspace":{"kind":"directory","path":roots["roots"][0]["path"]}})).await; let session = created["session"]["id"].as_str().unwrap().to_owned(); let client = Native::connect(&endpoint, Some(epoch)).await.unwrap(); Self { diff --git a/products/maka/workbench/app/ConfigurationScope.tsx b/products/maka/workbench/app/ConfigurationScope.tsx index de978b2a680..71ce2f8387c 100644 --- a/products/maka/workbench/app/ConfigurationScope.tsx +++ b/products/maka/workbench/app/ConfigurationScope.tsx @@ -72,7 +72,9 @@ export function createConfigurationWorkspace(operations: SettingsOperations) { async choose(selection: AgentWorkspaceSelection) { if (selection.kind !== 'directory') return; await operations.run(async () => { - const selected = await operations.mutate('agent', 'selectWorkspace', selection.selection); + const selected = await operations.mutate('agent', 'selectWorkspace', { + path: selection.path, + }); if (!selected) throw settingsRejection({ reason: 'workspace_unavailable' }); if (alive) { workspace.set(selected); diff --git a/products/maka/workbench/app/WorkspaceBrowser.tsx b/products/maka/workbench/app/WorkspaceBrowser.tsx index 2180245795a..02ea43a6dde 100644 --- a/products/maka/workbench/app/WorkspaceBrowser.tsx +++ b/products/maka/workbench/app/WorkspaceBrowser.tsx @@ -609,16 +609,14 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { accessibilityLabel={tr('New conversation in {name}', { name: project()?.name ?? '', })} - disabled={ - props.switching() || !project()?.available || !project()?.selection - } + disabled={props.switching() || !project()?.available || !project()?.path} onPress={ - project()?.selection + project()?.path ? inputAction(createWorkspace, { workspace: { kind: 'project', project: project()!.reference, - selection: project()!.selection!, + path: project()!.path!, }, origin: props.selected(), label: project()!.path ?? project()!.name, diff --git a/products/maka/workbench/app/WorkspacePicker.tsx b/products/maka/workbench/app/WorkspacePicker.tsx index 1db6326195d..1b8dd67e268 100644 --- a/products/maka/workbench/app/WorkspacePicker.tsx +++ b/products/maka/workbench/app/WorkspacePicker.tsx @@ -31,7 +31,6 @@ import { useHost } from './host'; import type { AgentDirectory, AgentDirectoryPage, - AgentDirectorySelection, AgentWorkspaceSelection, DirectoryRootSpec, } from '../../../../packages/plugin-sdk/src/index.js'; @@ -57,9 +56,7 @@ type Location = { entries: Signal; reading: boolean; }; -type FolderAction = { location: number; selection: AgentDirectorySelection }; -const selectionKey = (selection: AgentDirectorySelection) => - JSON.stringify([selection.rootId, selection.segments, selection.directoryIdentity]); +type FolderAction = { location: number; path: string }; const pathNames = (path: string) => path.split(platform.os === 'windows' ? /[\\/]/ : /\//).filter(Boolean); /** The names from `root` down to `path`, or null when `path` is not in it. */ @@ -70,7 +67,7 @@ const below = (root: string, path: string) => { : null; }; /** The innermost Host location containing a path, and the names below it. */ -export const locateWorkspaceDirectory = (rows: AgentDirectory[], path: string) => +const locateWorkspaceDirectory = (rows: AgentDirectory[], path: string) => rows .map((root) => ({ root, names: root.path ? below(root.path, path) : null })) .filter((found) => found.names !== null) @@ -130,7 +127,7 @@ export function WorkspacePicker(props: { const index = () => Math.min(selected(), Math.max(0, matches().length - 1)); const isSelected = (directory: AgentDirectory) => { const row = matches()[index()]; - return !!row && selectionKey(row.selection) === selectionKey(directory.selection); + return !!row && row.path === directory.path; }; const currentDirectory = (read = location()) => { const page = read.page(); @@ -154,7 +151,7 @@ export function WorkspacePicker(props: { const page: AgentDirectoryPage = await host.query( 'agent', 'directories', - directory ? { kind: 'children', selection: directory.selection, after } : { kind: 'roots' }, + directory ? { kind: 'children', path: directory.path, after } : { kind: 'roots' }, ); if (!owns(read)) return; if ((directory && page.kind !== 'children') || (!directory && page.kind !== 'roots')) @@ -178,9 +175,9 @@ export function WorkspacePicker(props: { refreshSession = false; props.changed(); } - const rows = new Map(read.entries().map((entry) => [selectionKey(entry.selection), entry])); + const rows = new Map(read.entries().map((entry) => [entry.path, entry])); for (const entry of page.kind === 'children' ? page.children : page.roots) - rows.set(selectionKey(entry.selection), entry); + rows.set(entry.path, entry); batch(() => { read.page.set(page); read.entries.set([...rows.values()]); @@ -216,9 +213,7 @@ export function WorkspacePicker(props: { const browse = (action: FolderAction) => { const read = location(); if (!owns(read) || action.location !== read.id || error()) return; - const directory = read - .entries() - .find((entry) => selectionKey(entry.selection) === selectionKey(action.selection)); + const directory = read.entries().find((entry) => entry.path === action.path); if (directory) navigate([...read.trail, directory]); }; const back = ({ location: original, roots = false }: { location: number; roots?: boolean }) => { @@ -238,11 +233,11 @@ export function WorkspacePicker(props: { action.location !== read.id || error() || !directory || - selectionKey(directory.selection) !== selectionKey(action.selection) + directory.path !== action.path ) return; if (props.close(props.instance)) - void props.create({ kind: 'directory', selection: action.selection }, directory.path); + void props.create({ kind: 'directory', path: directory.path }, directory.path); }; const roots = async () => { await host.refresh(); @@ -279,32 +274,10 @@ export function WorkspacePicker(props: { } throw new UiError('Locations saved. Waiting for the Host to apply them…'); }; - /** The selection of the folder at `path`, adding a location for it when none holds it. */ + /** Makes sure a workspace location holds `path`, adding one when none does. */ const resolve = async (path: string) => { - const { root, names } = - locateWorkspaceDirectory(await roots(), path) ?? (await addLocation(path)); - let directory = root; - for (const name of names!) { - let after: string | null = null; - let found: AgentDirectory | undefined; - do { - const page: AgentDirectoryPage = await host.query('agent', 'directories', { - kind: 'children' as const, - selection: directory.selection, - after, - }); - if (page.kind !== 'children') - throw new UiError('The Host returned a different directory view. Reopen the folder.'); - found = page.children.find((child) => child.name === name); - after = page.after; - } while (!found && after !== null); - if (!found) - throw new UiError( - 'This folder is unavailable or cannot be opened. Choose another location or retry.', - ); - directory = found; - } - return directory.selection; + if (!locateWorkspaceDirectory(await roots(), path)) await addLocation(path); + return path; }; /** Chooses the workspace with the system's folder dialog (the desktop window's). */ const chooseFolder = () => { @@ -316,9 +289,9 @@ export function WorkspacePicker(props: { // The dialog stays open as long as the user needs it. const path: string | null = await shell.call('workspace.chooseFolder', {}, { timeout: 0 }); if (!path || !alive || !props.current()) return; - const selection = await resolve(path); + const resolved = await resolve(path); if (alive && props.close(props.instance)) - void props.create({ kind: 'directory', selection }, path); + void props.create({ kind: 'directory', path: resolved }, path); } catch (reason) { if (alive) error.set(errorText(reason)); } finally { @@ -328,7 +301,7 @@ export function WorkspacePicker(props: { }; const chooseCurrent = (read: Location) => { const directory = currentDirectory(read); - if (directory) choose({ location: read.id, selection: directory.selection }); + if (directory) choose({ location: read.id, path: directory.path }); }; const move = (read: Location, delta: number) => { if (!owns(read)) return; @@ -340,7 +313,7 @@ export function WorkspacePicker(props: { const activate = (read: Location) => { if (!owns(read)) return; const directory = matches()[index()]; - if (directory) browse({ location: read.id, selection: directory.selection }); + if (directory) browse({ location: read.id, path: directory.path }); }; effect(() => { if (!awaitingRoots() && !loading() && !error() && matches().length < count()) void load(); @@ -607,7 +580,7 @@ export function WorkspacePicker(props: { selectionKey(entry.selection)} + keyExtractor={(entry) => entry.path} ref={(node) => { list = node; }} @@ -654,7 +627,7 @@ export function WorkspacePicker(props: { selected={isSelected(entry())} onPress={inputAction(browse, { location: read.id, - selection: entry().selection, + path: entry().path, })} style={{ height: 32, @@ -694,7 +667,7 @@ export function WorkspacePicker(props: { currentDirectory(read) ? inputAction(choose, { location: read.id, - selection: currentDirectory(read)!.selection, + path: currentDirectory(read)!.path, }) : undefined } diff --git a/products/maka/workbench/app/conversationGroups.ts b/products/maka/workbench/app/conversationGroups.ts index 8f73c6f1d33..542d724a91d 100644 --- a/products/maka/workbench/app/conversationGroups.ts +++ b/products/maka/workbench/app/conversationGroups.ts @@ -107,15 +107,13 @@ export function browserOrder( /** Resolve creation through an owned workspace, never a display path. */ export function groupWorkspace( - group: ConversationGroup< - Pick - >, + group: ConversationGroup>, ): AgentWorkspaceSelection | null { if (group.pinned) return null; if (group.project) { const project = group.project; - if (project.archived || !project.available || !project.selection) return null; - return { kind: 'project', project: project.reference, selection: project.selection }; + if (project.archived || !project.available || !project.path) return null; + return { kind: 'project', project: project.reference, path: project.path }; } const sessionId = group.sessions[0]; return sessionId ? { kind: 'session', sessionId } : null; diff --git a/products/maka/workbench/tests/conversation-groups.test.mjs b/products/maka/workbench/tests/conversation-groups.test.mjs index cc6b6555958..a098d96db95 100644 --- a/products/maka/workbench/tests/conversation-groups.test.mjs +++ b/products/maka/workbench/tests/conversation-groups.test.mjs @@ -77,15 +77,15 @@ test('new conversations reuse the group workspace, not the currently selected co reference: { id: 'project', revision: 'revision' }, archived: false, available: true, - selection: { rootId: 'root', segments: ['work'], directoryIdentity: 'identity' }, + path: '/work', }; const [empty] = groupConversations([], [registered], true); assert.deepEqual(groupWorkspace(empty), { kind: 'project', project: registered.reference, - selection: registered.selection, + path: registered.path, }); - for (const change of [{ archived: true }, { available: false }, { selection: null }]) { + for (const change of [{ archived: true }, { available: false }, { path: null }]) { assert.equal(groupWorkspace({ ...empty, project: { ...registered, ...change } }), null); } }); From 9f407a0ea850e9e5629d839190897f23486f52c3 Mon Sep 17 00:00:00 2001 From: Xun Lu Date: Wed, 4 Nov 2026 23:41:51 +0800 Subject: [PATCH 3/4] chore(workspace): lock the facts dependency of workspace support Generated-by: Claude Code --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 59903f5189b..08bc97cf35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8867,6 +8867,7 @@ name = "maka-workspace-support" version = "0.2.0" dependencies = [ "maka-agent-api", + "maka-agent-facts", "maka-fs-tools", "maka-host-runtime", "maka-plugin-core", From 6c6a4cbc62e6771d40b56924cd4721553b3711d2 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 6 Nov 2026 08:41:32 +0800 Subject: [PATCH 4/4] fix(agent): finish the path-based workspace migration Migrate the remaining callers that still sent a directory selection: the real-Host desktop fixtures, the crash integration test, the Host benchmark client and the host-comparison script. Recent workspaces keep only the path, which the Host resolves on creation. maka-workspace-support now compares canonical paths with canonical roots and exclusions, so Windows verbatim prefixes no longer defeat the managed-worktree and private-path checks, and resolves paths inside the blocking task rather than on the executor. WorkHub freezes the Host-resolved path in its coordinator intent and checks only the shape of a worker's created workspace, leaving path resolution to the worker. Generated-by: Antigravity --- .../agent/conversations/ui/SessionSurface.tsx | 14 ++++---- plugins/agent/native/src/workhub.rs | 12 +++---- .../native/src/workhub/tasks/preparation.rs | 12 +++---- plugins/workspace/README.md | 2 +- plugins/workspace/README.zh-CN.md | 2 +- plugins/workspace/support/README.md | 2 +- plugins/workspace/support/README.zh-CN.md | 2 +- plugins/workspace/support/src/lib.rs | 32 ++++++++--------- .../cli/examples/host_benchmark_client.rs | 6 ++-- .../cli/tests/integration/candidate/crash.rs | 2 +- .../maka/client/src/journal/workspaces.rs | 12 +++---- products/maka/client/src/journal/write.rs | 6 ++-- products/maka/client/src/lib.rs | 6 ++-- products/maka/client/src/tests/workspaces.rs | 17 +++------ .../tests/fixtures/components.tsx | 1 - .../tests/real_host/desktop_new_session.rs | 4 +-- .../terminal-shell/tests/real_host/support.rs | 2 +- .../maka/workbench/app/RecentWorkspaces.tsx | 6 +--- products/maka/workbench/app/host.ts | 4 +-- products/maka/workbench/src/bindings.rs | 4 +-- scripts/rust/host-comparison/compare.py | 35 +------------------ 21 files changed, 63 insertions(+), 120 deletions(-) diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index 6e6ac3677a9..df6c1caef46 100644 --- a/plugins/agent/conversations/ui/SessionSurface.tsx +++ b/plugins/agent/conversations/ui/SessionSurface.tsx @@ -268,10 +268,10 @@ export function createConversationUi( const workspaceError = createText(); const recentOpen = signal(false); let recentWrite = Promise.resolve(); - const setRecentWorkspace = (path: string, workspace: RecentWorkspace['workspace'] | null) => { + const setRecentWorkspace = (path: string, remember: boolean) => { // Keep this window's writes and their full-list receipts in user-action order. const write = recentWrite.then(async () => { - const recent = (await host.setRecentWorkspace(path, workspace)) as RecentWorkspace[]; + const recent = (await host.setRecentWorkspace(path, remember)) as RecentWorkspace[]; if (!alive) return; recentKnown.set(true); recentWorkspaces.set(recent); @@ -281,9 +281,7 @@ export function createConversationUi( }; const rememberWorkspace = (workspace: BrowserWorkspace, path: string) => { if (platform.isTerminal || side || !path || workspace.kind === 'session') return; - void setRecentWorkspace(path, { kind: 'directory', selection: workspace.selection }).catch( - report, - ); + void setRecentWorkspace(path, true).catch(report); }; const pendingWorkspace = computed(() => pending()?.workspace ?? null); const pendingWorkspaceLabel = computed(() => pending()?.workspaceLabel ?? ''); @@ -1877,7 +1875,7 @@ export function createConversationUi( recentKnown.set(true); recentWorkspaces.set(recent); if (selected() === null && !pendingWorkspace() && overlay() === null && recent[0]) - await create(recent[0].workspace, null, recent[0].path, false); + await create({ kind: 'directory', path: recent[0].path }, null, recent[0].path, false); }, }); createPendingRecovery({ @@ -2454,11 +2452,11 @@ export function createConversationUi( disabled={() => pendingSending() || pendingFileBusy()} forget={(path) => { recentOpen.set(false); - void setRecentWorkspace(path, null).catch(report); + void setRecentWorkspace(path, false).catch(report); }} choose={(item) => { recentOpen.set(false); - void create(item.workspace, selected(), item.path); + void create({ kind: 'directory', path: item.path }, selected(), item.path); }} browse={() => { recentOpen.set(false); diff --git a/plugins/agent/native/src/workhub.rs b/plugins/agent/native/src/workhub.rs index 975daaf63f1..9e3a38efd83 100644 --- a/plugins/agent/native/src/workhub.rs +++ b/plugins/agent/native/src/workhub.rs @@ -55,7 +55,7 @@ pub(crate) fn prompt(language: maka_agent_facts::language::Language) -> &'static #[serde(deny_unknown_fields)] struct Intent { operation: RecoveryRef, - /// The coordinator's directory as the caller named it. + /// The coordinator's directory as the Host resolved it. workspace: String, } pub(crate) struct Hub { @@ -226,8 +226,8 @@ impl Hub { let intent = match self.intent().await? { Some(intent) => intent, None => { - // Validate the selected directory before committing the immutable - // intent. A later replacement cannot retarget this coordinator. + // Resolve the selected directory before committing the immutable + // intent, which keeps the Host's canonical path. let owner = &self.agent.0; let located = maka_workspace_support::selected( owner.workspace_roots.clone(), @@ -236,14 +236,14 @@ impl Hub { owner.managed_worktrees.clone(), ) .await; - if !matches!(located, Ok(Some(_))) { + let Ok(Some(located)) = located else { return Ok(Coordinator::Rejected { reason: crate::contract::Rejection::WorkspaceUnavailable, }); - } + }; let candidate = Intent { operation, - workspace: input.workspace, + workspace: located.path, }; if scope.cancellation.is_cancelled() { return Err("workhub_call_cancelled".into()); diff --git a/plugins/agent/native/src/workhub/tasks/preparation.rs b/plugins/agent/native/src/workhub/tasks/preparation.rs index 67b95eae1bb..4178e15b246 100644 --- a/plugins/agent/native/src/workhub/tasks/preparation.rs +++ b/plugins/agent/native/src/workhub/tasks/preparation.rs @@ -334,18 +334,14 @@ impl Hub { let selected: crate::contract::Workspace = serde_json::from_value(value["session"]["workspace"].clone()) .map_err(|_| "workhub_creation_invalid")?; - // The worker resolved the same path; compare what it created - // with the directory the route named. - let requested = - maka_workspace_support::resolve_path(workspace, self.agent.0.home.as_deref()) - .ok_or("workhub_creation_invalid")?; + // The worker's create resolves the route's path; only the + // shape of what it created is checked here. let matches = if *isolated_workspace { selected.worktree.as_ref().is_some_and(|binding| { - binding.source() == requested - && binding.directory() == std::path::Path::new(&selected.path) + binding.directory() == std::path::Path::new(&selected.path) }) } else { - selected.worktree.is_none() && std::path::Path::new(&selected.path) == requested + selected.worktree.is_none() }; if !matches { return Err("workhub_creation_invalid".into()); diff --git a/plugins/workspace/README.md b/plugins/workspace/README.md index 3b755df822b..76fb864732c 100644 --- a/plugins/workspace/README.md +++ b/plugins/workspace/README.md @@ -29,4 +29,4 @@ Workspace services can be composed independently of the Agent engine. Each leaf - [Decisions API](workspace-decisions-api/native/README.md) exposes the structured evaluation [engine](workspace-decisions-api/engine/README.md). - Computer Use exposes `cua_repl` and `cua_reset` through the [native provider](workspace-computer/native/src/lib.rs). Each conversation needs explicit access approval. Changing its permission boundary invalidates that grant; Explore mode cannot operate the computer. REPL variables and target bindings last until reset or Host shutdown, while the conversation grant survives restart. Desktop and browser connections are selected in the provider configuration. -The shared [API](api/README.md) defines workspace contracts. [Support](support/README.md) handles configured roots, directory selection and private-path exclusions. A service receives its resource limits and authority explicitly; a path in request JSON cannot widen them. +The shared [API](api/README.md) defines workspace contracts. [Support](support/README.md) handles configured roots, path resolution and private-path exclusions. A service receives its resource limits and authority explicitly; a path in request JSON cannot widen them. diff --git a/plugins/workspace/README.zh-CN.md b/plugins/workspace/README.zh-CN.md index 252177caa6d..814d83e5722 100644 --- a/plugins/workspace/README.zh-CN.md +++ b/plugins/workspace/README.zh-CN.md @@ -29,4 +29,4 @@ - [Decisions API](workspace-decisions-api/native/README.zh-CN.md)提供结构化评估[引擎](workspace-decisions-api/engine/README.zh-CN.md)。 - Computer Use 通过[原生提供方](workspace-computer/native/src/lib.rs)提供 `cua_repl` 和 `cua_reset`。每个会话需要显式授权,权限边界变更会使授权失效;Explore 模式不能操作电脑。REPL 变量和目标绑定保留到重置或 Host 关闭,会话授权则在重启后保留。桌面与浏览器连接由提供方配置选择。 -共用 [API](api/README.zh-CN.md)定义工作区契约,[support](support/README.zh-CN.md)处理已配置根目录、目录选择和私有路径排除。服务显式接收资源限制与权限,请求 JSON 中的路径不能扩大它们。 +共用 [API](api/README.zh-CN.md)定义工作区契约,[support](support/README.zh-CN.md)处理已配置根目录、路径解析和私有路径排除。服务显式接收资源限制与权限,请求 JSON 中的路径不能扩大它们。 diff --git a/plugins/workspace/support/README.md b/plugins/workspace/support/README.md index 90597408345..306a0f7683b 100644 --- a/plugins/workspace/support/README.md +++ b/plugins/workspace/support/README.md @@ -25,7 +25,7 @@ Shares directory resolution and validation across workspace services. It opens c Child queries enumerate the selected directory before sorting and returning pages of up to 32 children with a 48 KiB byte target, allowing a complete first child to advance. Ordinary files do not prevent browsing child directories. Continue with the returned `after` cursor; each query revalidates the published root and selected directory identity. -Selections have no separate directory-depth limit. Paths must still satisfy the Host's path representation and filesystem checks. ACP follows returned selections toward the requested canonical path, ignoring aliases that do not advance toward it. +Paths have no separate directory-depth limit, but must still satisfy the Host's path representation and filesystem checks. The complete configured root set is returned without a separate page-size admission ceiling. Component and canonical-path validation preserve the original directory authority without imposing additional pathname byte limits. diff --git a/plugins/workspace/support/README.zh-CN.md b/plugins/workspace/support/README.zh-CN.md index c519e8b4b29..d0b72ff28ef 100644 --- a/plugins/workspace/support/README.zh-CN.md +++ b/plugins/workspace/support/README.zh-CN.md @@ -25,7 +25,7 @@ 子目录查询先枚举所选目录,再排序并分页返回;每页最多 32 个子目录,以 48 KiB 为字节目标;完整首项始终可以推进。普通文件不会阻止子目录浏览。使用返回的 `after` 游标继续查询;每次查询都会重新校验已发布根目录和所选目录的身份。 -目录选择没有独立的层数上限,路径仍需满足 Host 的路径表示和文件系统校验。ACP 沿返回的目录选择走向请求的规范路径,忽略不能向目标前进的别名。 +路径没有独立的层数上限,但仍需满足 Host 的路径表示和文件系统校验。 配置的根目录集合完整返回,不再以单页大小拒绝整个集合。路径组成和规范路径校验保留原目录权限,不另加路径名字节上限。 diff --git a/plugins/workspace/support/src/lib.rs b/plugins/workspace/support/src/lib.rs index 3adfac88dc2..c44438c713d 100644 --- a/plugins/workspace/support/src/lib.rs +++ b/plugins/workspace/support/src/lib.rs @@ -210,8 +210,7 @@ fn root_page(roots: &[Root]) -> DirectoryPage { } pub fn fits(value: &T) -> bool { serde_json::to_value(value) - .map(|value| value.to_string().len() <= PAGE_BYTES) - .unwrap_or(false) + .is_ok_and(|value| maka_plugin_core::json::check_delivery(&value, PAGE_BYTES).is_ok()) } pub async fn query( roots: Vec, @@ -235,7 +234,7 @@ pub async fn query_excluding( let (root, selected) = find(&roots, &path).ok_or("agent_directory_unavailable")?; if excluded .as_ref() - .is_some_and(|excluded| Path::new(&selected.path).starts_with(excluded)) + .is_some_and(|excluded| path.starts_with(excluded)) { return Err("agent_directory_unavailable".into()); } @@ -246,7 +245,7 @@ pub async fn query_excluding( }) { return Err("agent_directory_invalid".into()); } - let segments = Path::new(&selected.path) + let segments = path .strip_prefix(root.directory.path()) .map_err(|_| "agent_directory_unavailable")? .components() @@ -275,7 +274,7 @@ pub async fn query_excluding( let mut children = Vec::new(); let mut remaining = names.iter(); for name in remaining.by_ref() { - let Some(child) = capture(root, &Path::new(&selected.path).join(name)) else { + let Some(child) = capture(root, &path.join(name)) else { continue; }; children.push(entry(child)); @@ -304,8 +303,7 @@ pub async fn query_excluding( children.pop(); *after = children.last().map(|child| child.name.clone()); } - directory::open(Path::new(&selected.path), &selected.identity) - .map_err(|_| "agent_directory_unavailable")?; + directory::open(&path, &selected.identity).map_err(|_| "agent_directory_unavailable")?; Ok(page) }) .await @@ -323,17 +321,16 @@ pub async fn initialize( private_root: &Path, control: &Path, ) -> Result, String> { - let Some(path) = resolve_path(path, home) else { - return Ok(None); - }; let roots = roots.to_vec(); + let path = path.to_owned(); + let home = home.map(Path::to_owned); let managed = managed.to_owned(); let prepared = tokio::task::spawn_blocking(move || { - let (root, located) = find(&roots, &path)?; - let path = PathBuf::from(&located.path); + let path = resolve_path(&path, home.as_deref())?; if path.starts_with(&managed) { return None; } + let (root, located) = find(&roots, &path)?; let directory = directory::open(&path, &located.identity).ok()?; match workspace::read_directory_identity(path.clone(), directory.try_clone().ok()?) { Ok(_) => {} @@ -441,15 +438,14 @@ async fn resolve( excluded: PathBuf, workspace_marker: bool, ) -> Result, String> { - let Some(path) = resolve_path(path, home) else { - return Ok(None); - }; + let path = path.to_owned(); + let home = home.map(Path::to_owned); tokio::task::spawn_blocking(move || { - let (_, located) = find(&roots, &path)?; - let path = PathBuf::from(&located.path); + let path = resolve_path(&path, home.as_deref())?; if path.starts_with(&excluded) { return None; } + let (_, located) = find(&roots, &path)?; let directory = directory::open(&path, &located.identity).ok()?; if !workspace_marker { return Some(located); @@ -602,7 +598,7 @@ mod tests { let found = located(app.display().to_string()).await.unwrap(); let canonical = app.canonicalize().unwrap(); assert_eq!(found.name, "app"); - assert_eq!(found.path, canonical.to_str().unwrap()); + assert_eq!(found.path, host_path(&canonical).unwrap()); assert_eq!(found.identity, directory::capture(&app).unwrap().1); // Written with `.`, `..` and a trailing separator: the same directory. let dotted = format!("{}/./code/../code/app/", published.display()); diff --git a/products/maka/cli/examples/host_benchmark_client.rs b/products/maka/cli/examples/host_benchmark_client.rs index 6322586acce..2e2e25d191a 100644 --- a/products/maka/cli/examples/host_benchmark_client.rs +++ b/products/maka/cli/examples/host_benchmark_client.rs @@ -506,10 +506,10 @@ async fn execute(state: &State, command: &Command) -> Result { let roots = client .query("agent", "directories", json!({"kind":"roots"})) .await?; - let selection = roots["roots"][0] - .get("selection") + let path = roots["roots"][0] + .get("path") .ok_or("no configured directory root; provide args.workspace")?; - json!({"kind":"directory","selection":selection}) + json!({"kind":"directory","path":path}) }; let input = json!({"title":string(args,"name")?,"automaticTitle":false,"labels":[],"workspace":workspace,"tools":args.get("tools").cloned().unwrap_or(Value::Null)}); commit(client.control(operation_id(args)?, "create", input).await?).await diff --git a/products/maka/cli/tests/integration/candidate/crash.rs b/products/maka/cli/tests/integration/candidate/crash.rs index 3c299515fde..a523747239a 100644 --- a/products/maka/cli/tests/integration/candidate/crash.rs +++ b/products/maka/cli/tests/integration/candidate/crash.rs @@ -96,7 +96,7 @@ async fn killed_host_leaves_an_ended_turn_and_a_session_that_takes_messages() { .query("agent", "directories", json!({"kind":"roots"})) .await .unwrap(); - let created = mutate(&client, "create", json!({"title":"Crash","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]}})).await; + let created = mutate(&client, "create", json!({"title":"Crash","labels":[],"workspace":{"kind":"directory","path":roots["roots"][0]["path"]}})).await; let session = created["session"]["id"].as_str().unwrap().to_owned(); mutate(&client, "send", json!({"sessionId":session,"message":"BEFORE_THE_CRASH","backend":{"kind":"model","connectionId":selection["connectionId"],"modelId":selection["modelId"]}})).await; let held = requests.recv().await.unwrap(); diff --git a/products/maka/client/src/journal/workspaces.rs b/products/maka/client/src/journal/workspaces.rs index 15b958a2453..90869c5d450 100644 --- a/products/maka/client/src/journal/workspaces.rs +++ b/products/maka/client/src/journal/workspaces.rs @@ -20,13 +20,11 @@ use super::{Journal, error, write}; use crate::Result; use serde::{Deserialize, Serialize}; -use serde_json::Value; use sqlx::SqliteConnection; #[derive(Serialize, Deserialize)] pub struct RecentWorkspace { pub path: String, - pub workspace: Value, } impl Journal { @@ -48,13 +46,13 @@ impl Journal { &self, owner: String, path: String, - workspace: Option, + remember: bool, ) -> Result> { let write::Output::RecentWorkspaces(recent) = self .write(write::Command::SetRecentWorkspace { owner, path, - workspace, + remember, }) .await? else { @@ -68,7 +66,7 @@ pub(super) async fn set( connection: &mut SqliteConnection, owner: String, path: String, - workspace: Option, + remember: bool, ) -> Result> { let value: Option = sqlx::query_scalar("SELECT value FROM recent_workspaces WHERE owner=?") @@ -82,8 +80,8 @@ pub(super) async fn set( .map_err(error)? .unwrap_or_default(); recent.retain(|item| item.path != path); - if let Some(workspace) = workspace { - recent.insert(0, RecentWorkspace { path, workspace }); + if remember { + recent.insert(0, RecentWorkspace { path }); } sqlx::query("INSERT INTO recent_workspaces(owner,value) VALUES (?,?) ON CONFLICT(owner) DO UPDATE SET value=excluded.value") .bind(owner).bind(serde_json::to_string(&recent).map_err(error)?) diff --git a/products/maka/client/src/journal/write.rs b/products/maka/client/src/journal/write.rs index 388c2eeba52..13d3d086db1 100644 --- a/products/maka/client/src/journal/write.rs +++ b/products/maka/client/src/journal/write.rs @@ -64,7 +64,7 @@ pub(super) enum Command { SetRecentWorkspace { owner: String, path: String, - workspace: Option, + remember: bool, }, SaveDraft { key: DraftKey, @@ -121,8 +121,8 @@ impl Command { Self::SetRecentWorkspace { owner, path, - workspace, - } => super::workspaces::set(connection, owner, path, workspace) + remember, + } => super::workspaces::set(connection, owner, path, remember) .await .map(Output::RecentWorkspaces), Self::SaveDraft { diff --git a/products/maka/client/src/lib.rs b/products/maka/client/src/lib.rs index c5defebf40d..171f7b9135e 100644 --- a/products/maka/client/src/lib.rs +++ b/products/maka/client/src/lib.rs @@ -829,15 +829,15 @@ impl Client { .await } - /// Move a directory to the front, or remove its recent entry when `workspace` is None. + /// Move a directory to the front, or remove its recent entry unless `remember`. pub async fn set_recent_workspace( &self, path: String, - workspace: Option, + remember: bool, ) -> Result> { self.0 .journal - .set_recent_workspace(self.workspace_owner().await?, path, workspace) + .set_recent_workspace(self.workspace_owner().await?, path, remember) .await } diff --git a/products/maka/client/src/tests/workspaces.rs b/products/maka/client/src/tests/workspaces.rs index 011d320b8b6..58d51e8500b 100644 --- a/products/maka/client/src/tests/workspaces.rs +++ b/products/maka/client/src/tests/workspaces.rs @@ -27,10 +27,7 @@ async fn recent_workspaces_survive_reopen_without_evicting_older_selections() { for number in 0..10 { let client = if number % 2 == 0 { &first } else { &second }; client - .set_recent_workspace( - format!("/project/{number}"), - Some(json!({"directory":number})), - ) + .set_recent_workspace(format!("/project/{number}"), true) .await .unwrap(); } @@ -49,15 +46,11 @@ async fn recent_workspaces_survive_reopen_without_evicting_older_selections() { .collect::>() ); let recent = client - .set_recent_workspace( - "/project/3".into(), - Some(json!({"directory":"new identity"})), - ) + .set_recent_workspace("/project/3".into(), true) .await .unwrap(); assert_eq!(recent.len(), 10); assert_eq!(recent[0].path, "/project/3"); - assert_eq!(recent[0].workspace, json!({"directory":"new identity"})); assert_eq!( recent .iter() @@ -68,7 +61,7 @@ async fn recent_workspaces_survive_reopen_without_evicting_older_selections() { client.close().await.unwrap(); let client = fixture.client().await; client - .set_recent_workspace("/project/3".into(), None) + .set_recent_workspace("/project/3".into(), false) .await .unwrap(); client.close().await.unwrap(); @@ -87,7 +80,7 @@ async fn recent_workspaces_are_scoped_to_the_admitted_host() { let second = Fixture::new().await; let client = first.client().await; client - .set_recent_workspace("/first".into(), Some(json!({"directory":1}))) + .set_recent_workspace("/first".into(), true) .await .unwrap(); let host = HostClient::connect(&second.address, Some(second.host.epoch())) @@ -99,7 +92,7 @@ async fn recent_workspaces_are_scoped_to_the_admitted_host() { let other = Client::new(host, journal, "receipts".into()).unwrap(); assert!(other.recent_workspaces().await.unwrap().is_empty()); other - .set_recent_workspace("/second".into(), Some(json!({"directory":2}))) + .set_recent_workspace("/second".into(), true) .await .unwrap(); assert_eq!(client.recent_workspaces().await.unwrap()[0].path, "/first"); diff --git a/products/maka/terminal-shell/tests/fixtures/components.tsx b/products/maka/terminal-shell/tests/fixtures/components.tsx index 2815012a97a..ca441a9fd3c 100644 --- a/products/maka/terminal-shell/tests/fixtures/components.tsx +++ b/products/maka/terminal-shell/tests/fixtures/components.tsx @@ -378,7 +378,6 @@ function WorkspacePages(props: { busy: boolean }) { name: `Empty ${index}`, archived: false, available: false, - selection: null, path: null, })) : [], diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index fc6569344ce..c3d97f20299 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -778,7 +778,7 @@ async fn desktop_new_conversation_preserves_an_owned_worktree() { "create", json!({ "title":"Isolated", "labels":[], - "workspace":{"kind":"isolated_git", "selection":roots["roots"][0]["selection"]} + "workspace":{"kind":"isolated_git", "path":roots["roots"][0]["path"]} }), ) .await; @@ -1078,7 +1078,7 @@ async fn desktop_side_pane_keeps_its_draft_separate_when_closed_and_reopened() { "create", json!({ "title":"Side conversation","labels":[], - "workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]} + "workspace":{"kind":"directory","path":roots["roots"][0]["path"]} }), ) .await; diff --git a/products/maka/terminal-shell/tests/real_host/support.rs b/products/maka/terminal-shell/tests/real_host/support.rs index 553ad52d318..4681c03e8c4 100644 --- a/products/maka/terminal-shell/tests/real_host/support.rs +++ b/products/maka/terminal-shell/tests/real_host/support.rs @@ -326,7 +326,7 @@ impl Fixture { .query("agent", "directories", json!({"kind":"roots"})) .await .unwrap(); - let created = mutate(&client,"create",json!({"title":"NSH real Agent","labels":[],"workspace":{"kind":"directory","selection":roots["roots"][0]["selection"]}})).await; + let created = mutate(&client,"create",json!({"title":"NSH real Agent","labels":[],"workspace":{"kind":"directory","path":roots["roots"][0]["path"]}})).await; fixture.session = created["session"]["id"].as_str().unwrap().to_owned(); client.close().await.unwrap(); fixture diff --git a/products/maka/workbench/app/RecentWorkspaces.tsx b/products/maka/workbench/app/RecentWorkspaces.tsx index dfb2cba5032..376eb469588 100644 --- a/products/maka/workbench/app/RecentWorkspaces.tsx +++ b/products/maka/workbench/app/RecentWorkspaces.tsx @@ -19,15 +19,11 @@ import { Text, View, windowSize } from 'nsh'; import { Select } from 'nsh/base'; -import type { BrowserWorkspace } from './browsing'; import { Icon } from './Icon'; import { tr } from './locale'; import { C, controlStyle, controlHoverStyle, controlFocusStyle, textTooltip } from './ui'; -export type RecentWorkspace = { - workspace: Extract; - path: string; -}; +export type RecentWorkspace = { path: string }; const name = (path: string) => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path; export function RecentWorkspaces(props: { diff --git a/products/maka/workbench/app/host.ts b/products/maka/workbench/app/host.ts index 4212bca2914..ab6e4570304 100644 --- a/products/maka/workbench/app/host.ts +++ b/products/maka/workbench/app/host.ts @@ -188,8 +188,8 @@ export function createHost(application: UiHost) { stageFiles: (files: string[]) => nativeHost.stageFiles(files), stageDroppedFiles: (files: string[]) => nativeHost.stageDroppedFiles(files), recentWorkspaces: () => nativeHost.recentWorkspaces(), - setRecentWorkspace: (path: string, workspace: unknown) => - nativeHost.setRecentWorkspace(path, workspace), + setRecentWorkspace: (path: string, remember: boolean) => + nativeHost.setRecentWorkspace(path, remember), stageClipboard: () => nativeHost.stageClipboard(), hasService: (endpoint: string) => nativeHost.hasService(endpoint), refresh: () => nativeHost.refresh(), diff --git a/products/maka/workbench/src/bindings.rs b/products/maka/workbench/src/bindings.rs index 2e8ce7c3dde..6c5f09a5e32 100644 --- a/products/maka/workbench/src/bindings.rs +++ b/products/maka/workbench/src/bindings.rs @@ -336,7 +336,7 @@ impl ApplicationHost { pub async fn set_recent_workspace( this: Instance, path: String, - workspace: Option, + remember: bool, ) -> Result { let (owner, source) = this.with(|host| (host.owner.clone(), host.client.clone())); drop(this); @@ -345,7 +345,7 @@ impl ApplicationHost { let recent = source .local(&original) .await? - .set_recent_workspace(path, workspace) + .set_recent_workspace(path, remember) .await?; serde_json::to_value(recent).map_err(|error| error.to_string()) }) diff --git a/scripts/rust/host-comparison/compare.py b/scripts/rust/host-comparison/compare.py index 8f021b5d499..32b75d1aaf6 100755 --- a/scripts/rust/host-comparison/compare.py +++ b/scripts/rust/host-comparison/compare.py @@ -1335,42 +1335,9 @@ async def rust_setup(adapter, provider_url, workspace): for item in refreshed["connections"] if item["slug"] == "offline-comparison" ) - # Traverse only public directory grants to obtain the exact scratch-workspace selection. - roots = await adapter.call( - "query", endpoint="agent", method="directories", input={"kind": "roots"} - ) - root = next( - root for root in roots["roots"] if workspace.is_relative_to(Path(root["path"])) - ) - directory = root - for segment in workspace.relative_to(Path(root["path"])).parts: - after = None - while True: - children = await adapter.call( - "query", - endpoint="agent", - method="directories", - input={ - "kind": "children", - "selection": directory["selection"], - "after": after, - }, - ) - selected = next( - (child for child in children["children"] if child["name"] == segment), - None, - ) - if selected: - directory = selected - break - after = children["after"] - if after is None: - raise RuntimeError( - f"Public directory grant lacks workspace segment {segment}" - ) return { "model": {**basis, "modelId": "comparison-model"}, - "workspace": {"kind": "directory", "selection": directory["selection"]}, + "workspace": {"kind": "directory", "path": str(workspace)}, }