diff --git a/docs/architecture/remote-workspace-transport.md b/docs/architecture/remote-workspace-transport.md index 6fc97ab78a..18c368cca9 100644 --- a/docs/architecture/remote-workspace-transport.md +++ b/docs/architecture/remote-workspace-transport.md @@ -246,6 +246,132 @@ from a container workspace. That would bypass the selected workspace and its security boundary. Host diagnosis, if added later, must be a typed, read-only capability with a distinct confirmation and audit surface. +## Workspace identity contract + +A workspace reference is `(owning host, workspace ID)`. Resolve that ID against +that host's persisted workspace catalog before choosing execution services. +`WorkspaceInfo.workspace_kind` is the authority for Normal, Assistant, or Remote; +an SSH connection ID is a property of a Remote workspace, not a type predicate. +A missing ID or unavailable record must not fall through to another workspace +with the same path. SSH endpoints named `localhost` remain Remote. + +Keep three different location roles separate: + +- The workspace record's root is the project directory on the execution host. +- A session execution target may select a registered worktree within that project. +- The persistence owner computes the local session storage directory. This + directory is never an execution root, workspace ID, or input to workspace selection. + +Paths remain necessary for file operations, terminal working directories and +new-folder creation. They must not key workspace catalogs, subscriptions, routing, +selection, or cache ownership. UI events retain workspace IDs through every +adapter; session operations recover their workspace from the session's stored ID. +Across devices, IDs are interpreted only by the selected owning host. + +Persisted IDs are opaque. Catalog validation checks record/map-key agreement and +reference integrity; it must not recompute IDs from paths or require a working +SSH profile. Keep unavailable records in the catalog. Activation validates the +selected record's IO requirements and reports unavailability. The pre-1.0 offline +import adapter may calculate historical destination IDs as part of its explicit +migration plan; that algorithm is not a runtime catalog API. + +### Temporary upgrade adapter and sunset + +`WorkspaceService::resolve_legacy_workspace_reference` in `workspace/legacy_compat.rs` +is the temporary conversion boundary for pre-ID data and protocols, including +1.0.0. It is not a workspace lookup API for new business code. Explicit IDs never +fall back to path matching. Legacy paths may select exactly one persisted record; +ambiguous or missing references retain the original data and require reselection. +Local hidden session storage paths must never be treated as project roots. +Successful conversions retain the record ID for subsequent requests and saves. + +Linked worktrees persist `worktree.mainWorkspaceId` as the project relationship. +`mainRepoPath` remains readable as a filesystem projection for older builds; it is +not a join key for session storage or navigation. The upgrade adapter converts +legacy relationships using only unambiguous local workspace records. Remote +records with the same POSIX path are not eligible local project parents. Once an +ID is present, a stale path cannot redirect it. The upgrade may register an existing local main folder once when 1.0.0 never +opened it; unavailable folders and hidden runtime directories remain +recoverable errors; loading or importing an unavailable workspace must not delete +it. Remote workspaces are never inspected with the controller's local Git scanner. + +Filesystem IO providers receive an explicit local or SSH target selected from the +ID-owned record's `workspaceKind`. They must not scan a remote workspace registry +to infer the provider from the requested filename. Directory browsing before a +workspace exists is an explicit device/connection operation, not workspace lookup. +Desktop file IO ingress accepts the workspace ID, including read/write, metadata, +rename/delete/create, and runtime-artifact reads. A `current` artifact URI resolves +against the request ID; a URI naming a different workspace is rejected. Legacy +file routing is isolated in the upgrade adapter and rejects a path shared by +multiple filesystem providers instead of using the active SSH connection. +Remote search binds its provider directly to the ID-owned record and validates +subsequent IO scope without looking up the workspace a second time by its root. + +Snapshot command ingress resolves the ID before selecting local/remote behavior. +The local snapshot port takes IDs and rejects remote records before accessing IO. +Local writer, read-only view, initialization-lock, and bound remote snapshot caches +are keyed by workspace ID. Their on-disk layout is an IO projection and remains +unchanged for history upgrades. +Session history storage is resolved independently from the execution directory. +Remote full rollback remains unsupported; it must fail before changing files or +messages. Persisted remote operation reads retain their connection-scoped history. +The current client sends an ID; the negotiated old-host projection includes both +connection ID and SSH host. Client in-flight cache ownership includes the driving host and workspace ID. + +Session list, restore and coordinator-load requests carry a workspace ID. A +current client does not supply an execution directory or hidden history directory +as the selector. History hydration and context-restore deduplication use host, +workspace ID and session ID. A session creation with an explicit unavailable ID +must fail; it cannot choose the active workspace by matching a folder or SSH host. + +Session fork requests also carry workspace IDs. Fork validates both the persisted +and loaded source against that ID under the session mutation lock; worktree +execution IDs retain their explicit project-ID relationship. Stale path and SSH +projections cannot override the registered kind. Only the temporary fork ingress +adapter accepts a 1.0.0 resolved session-directory payload, and only after locating +the source by its registered workspace ID and checking the exact storage projection. + +Child detail hydration first reads missing metadata through the parent's project +workspace ID, then uses the child's own execution workspace ID. A child's worktree +ID must not be replaced with the parent's ID. Metadata completion preserves live +turns and UI state. + +Scheduled-job filters match workspace IDs exactly, including after upgrading +persisted 1.0.0 targets through the temporary adapter. Missing IDs are not wildcard +matches. Workspace job execution refreshes its binding from the catalog, and the +editor requires explicit reselection for an unresolved target. HTML preview also +resolves its root and SSH provider by ID; file paths remain IO operands. + +The private Shared TUI protocol advertises `workspace_id_references` in version 19. +Current clients can read version 18 discovery and handshake responses; absent +capability means the adapter emits the legacy payload using the selected object's +root. `legacy_workspace_operation` is an outbound part of this temporary boundary, +not a path lookup API. Its sunset is removal of private protocol 18 support. The +Shared Runtime list and restore handlers compare workspace IDs; the predecessor payload is +accepted only at legacy ingress. + +External integration settings written under the retired path-hash key are copied +once to the ID-derived key by this same upgrade boundary. Keep the old entry for +rollback, never overwrite an existing ID entry, and reject a legacy key shared by +multiple workspace records. Discovery caches, MCP routes and tool routes use the +ID thereafter; the root derived from the record is only an IO parameter. A missing +workspace ID is not the user-global scope: only an explicitly unscoped request may +read global settings. + +New peer hosts advertise `workspace_id_references_v1`. An old-protocol serializer +may translate an ID-selected object into the old payload only after negotiating +legacy support; it may not resolve identity from a path or guess another host. +Legacy fields remain readable with defaults and unrelated future fields remain +ignored. Do not delete sessions, credentials or workspace records on conversion +failure. Tests must include real 1.0.0 payload shapes, unknown IDs, same-path +local/SSH and multiple-host records, storage/execution separation and restart. + +Sunset the adapter when persisted references have migrated and every supported +peer can negotiate ID references. Until then, keep path conversion isolated at +upgrade/old-protocol ingress. New internal APIs and new protocol consumers must +not acquire a path-based workspace key. Removing the adapter is a coordinated +minimum-peer-version change, not an opportunistic deletion of user data. + ## Upgrade compatibility New configuration fields have Serde defaults. Profiles written before this diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 216e1dada8..6edcd0e16c 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -23,7 +23,7 @@ "visibility": "documented", "rustPath": "accept_file", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn accept_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: AcceptFileRequest, ) -> Result", + "signature": "fn accept_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: AcceptFileRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -39,7 +39,7 @@ "visibility": "documented", "rustPath": "accept_operation", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn accept_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result", + "signature": "fn accept_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: GetOperationSummaryRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -55,7 +55,7 @@ "visibility": "documented", "rustPath": "accept_session", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn accept_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: AcceptSessionRequest, ) -> Result", + "signature": "fn accept_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: AcceptSessionRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -355,7 +355,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::activate_session_goal", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn activate_session_goal( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: ActivateSessionGoalRequest, ) -> Result", + "signature": "fn activate_session_goal( coordinator: State<'_, Arc>, request: ActivateSessionGoalRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -371,7 +371,7 @@ "visibility": "documented", "rustPath": "add_skill", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_path: Option, source_key: Option, target_name: Option, expected_source_fingerprint: Option, ) -> Result", + "signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_id: Option, workspace_path: Option, source_key: Option, target_name: Option, expected_source_fingerprint: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -1119,7 +1119,7 @@ "visibility": "documented", "rustPath": "check_git_isolation", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn check_git_isolation( request: SnapshotWorkspaceRequest, ) -> Result", + "signature": "fn check_git_isolation( mut request: SnapshotWorkspaceRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -1231,7 +1231,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::clear_session_thread_goal", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn clear_session_thread_goal( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: ClearSessionThreadGoalRequest, ) -> Result<(), String>", + "signature": "fn clear_session_thread_goal( coordinator: State<'_, Arc>, request: ClearSessionThreadGoalRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -1279,7 +1279,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::compact_session", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn compact_session( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: CompactSessionRequest, ) -> Result", + "signature": "fn compact_session( coordinator: State<'_, Arc>, request: CompactSessionRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -1849,7 +1849,7 @@ "visibility": "documented", "rustPath": "delete_skill", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn delete_skill( state: State<'_, AppState>, skill_key: String, workspace_path: Option, expected_import_id: Option, ) -> Result", + "signature": "fn delete_skill( state: State<'_, AppState>, skill_key: String, workspace_id: Option, workspace_path: Option, expected_import_id: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -2247,7 +2247,7 @@ "visibility": "implementation", "rustPath": "api::agentic_api::ensure_assistant_bootstrap", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, request: EnsureAssistantBootstrapRequest, ) -> Result", + "signature": "fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: EnsureAssistantBootstrapRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -2643,7 +2643,7 @@ "visibility": "documented", "rustPath": "get_all_modified_files", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_all_modified_files( runtime: State<'_, DesktopRuntimeContext>, request: GetAllModifiedFilesRequest, ) -> Result, String>", + "signature": "fn get_all_modified_files( runtime: State<'_, DesktopRuntimeContext>, mut request: GetAllModifiedFilesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -2757,7 +2757,7 @@ "visibility": "documented", "rustPath": "get_baseline_snapshot_diff", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_baseline_snapshot_diff( request: GetBaselineSnapshotDiffRequest, ) -> Result", + "signature": "fn get_baseline_snapshot_diff( mut request: GetBaselineSnapshotDiffRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3011,7 +3011,7 @@ "visibility": "documented", "rustPath": "get_file_change_history", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_file_change_history( runtime: State<'_, DesktopRuntimeContext>, request: GetFileChangeHistoryRequest, ) -> Result", + "signature": "fn get_file_change_history( runtime: State<'_, DesktopRuntimeContext>, mut request: GetFileChangeHistoryRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3027,7 +3027,7 @@ "visibility": "documented", "rustPath": "get_file_diff", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_file_diff( runtime: State<'_, DesktopRuntimeContext>, request: GetFileDiffRequest, ) -> Result", + "signature": "fn get_file_diff( runtime: State<'_, DesktopRuntimeContext>, mut request: GetFileDiffRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -3370,7 +3370,7 @@ "visibility": "documented", "rustPath": "get_mode_skill_configs", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn get_mode_skill_configs( state: State<'_, AppState>, mode_id: String, force_refresh: Option, include_diagnostics: Option, workspace_path: Option, ) -> Result", + "signature": "fn get_mode_skill_configs( state: State<'_, AppState>, mode_id: String, force_refresh: Option, include_diagnostics: Option, workspace_id: Option, workspace_path: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3450,7 +3450,7 @@ "visibility": "documented", "rustPath": "get_operation_diff", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_operation_diff( runtime: State<'_, DesktopRuntimeContext>, request: GetOperationDiffRequest, ) -> Result", + "signature": "fn get_operation_diff( runtime: State<'_, DesktopRuntimeContext>, mut request: GetOperationDiffRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -3466,7 +3466,7 @@ "visibility": "documented", "rustPath": "get_operation_summary", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_operation_summary( runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result", + "signature": "fn get_operation_summary( runtime: State<'_, DesktopRuntimeContext>, mut request: GetOperationSummaryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -3626,7 +3626,7 @@ "visibility": "documented", "rustPath": "get_session_file_diff_stats", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_session_file_diff_stats( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionFileDiffStatsRequest, ) -> Result", + "signature": "fn get_session_file_diff_stats( runtime: State<'_, DesktopRuntimeContext>, mut request: GetSessionFileDiffStatsRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3642,7 +3642,7 @@ "visibility": "documented", "rustPath": "get_session_files", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_session_files( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionFilesRequest, ) -> Result, String>", + "signature": "fn get_session_files( runtime: State<'_, DesktopRuntimeContext>, mut request: GetSessionFilesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3688,7 +3688,7 @@ "visibility": "documented", "rustPath": "get_session_operations", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_session_operations( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionFilesRequest, ) -> Result", + "signature": "fn get_session_operations( runtime: State<'_, DesktopRuntimeContext>, mut request: GetSessionFilesRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3720,7 +3720,7 @@ "visibility": "documented", "rustPath": "get_session_stats", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_session_stats( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionStatsRequest, ) -> Result", + "signature": "fn get_session_stats( runtime: State<'_, DesktopRuntimeContext>, mut request: GetSessionStatsRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3736,7 +3736,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::get_session_thread_goal", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn get_session_thread_goal( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: GetSessionThreadGoalRequest, ) -> Result", + "signature": "fn get_session_thread_goal( coordinator: State<'_, Arc>, startup_trace: State<'_, DesktopStartupTrace>, request: GetSessionThreadGoalRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3752,7 +3752,7 @@ "visibility": "documented", "rustPath": "get_session_turns", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_session_turns( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionTurnsRequest, ) -> Result, String>", + "signature": "fn get_session_turns( runtime: State<'_, DesktopRuntimeContext>, mut request: GetSessionTurnsRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3784,7 +3784,7 @@ "visibility": "documented", "rustPath": "get_skill_configs", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn get_skill_configs( state: State<'_, AppState>, force_refresh: Option, include_diagnostics: Option, workspace_path: Option, ) -> Result", + "signature": "fn get_skill_configs( state: State<'_, AppState>, force_refresh: Option, include_diagnostics: Option, workspace_id: Option, workspace_path: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3800,7 +3800,7 @@ "visibility": "documented", "rustPath": "get_snapshot_sessions", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_snapshot_sessions( request: SnapshotWorkspaceRequest, ) -> Result, String>", + "signature": "fn get_snapshot_sessions( mut request: SnapshotWorkspaceRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3816,7 +3816,7 @@ "visibility": "documented", "rustPath": "get_snapshot_system_stats", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_snapshot_system_stats( request: SnapshotWorkspaceRequest, ) -> Result", + "signature": "fn get_snapshot_system_stats( mut request: SnapshotWorkspaceRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -3946,7 +3946,7 @@ "visibility": "documented", "rustPath": "get_turn_files", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn get_turn_files( runtime: State<'_, DesktopRuntimeContext>, request: GetTurnFilesRequest, ) -> Result, String>", + "signature": "fn get_turn_files( runtime: State<'_, DesktopRuntimeContext>, mut request: GetTurnFilesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -4010,7 +4010,7 @@ "visibility": "documented", "rustPath": "git_add_files", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_add_files( state: State<'_, AppState>, request: GitAddFilesRequest, ) -> Result", + "signature": "fn git_add_files( state: State<'_, AppState>, mut request: GitAddFilesRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4026,7 +4026,7 @@ "visibility": "documented", "rustPath": "git_add_worktree", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_add_worktree( state: State<'_, AppState>, request: GitAddWorktreeRequest, ) -> Result", + "signature": "fn git_add_worktree( state: State<'_, AppState>, mut request: GitAddWorktreeRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -4042,7 +4042,7 @@ "visibility": "documented", "rustPath": "git_checkout_branch", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_checkout_branch( state: State<'_, AppState>, request: GitCheckoutBranchRequest, ) -> Result", + "signature": "fn git_checkout_branch( state: State<'_, AppState>, mut request: GitCheckoutBranchRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4058,7 +4058,7 @@ "visibility": "documented", "rustPath": "git_cherry_pick", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_cherry_pick( state: State<'_, AppState>, request: GitCherryPickRequest, ) -> Result", + "signature": "fn git_cherry_pick( state: State<'_, AppState>, mut request: GitCherryPickRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4074,7 +4074,7 @@ "visibility": "documented", "rustPath": "git_cherry_pick_abort", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_cherry_pick_abort( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_cherry_pick_abort( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4090,7 +4090,7 @@ "visibility": "documented", "rustPath": "git_cherry_pick_continue", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_cherry_pick_continue( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_cherry_pick_continue( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4106,7 +4106,7 @@ "visibility": "documented", "rustPath": "git_commit", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_commit( state: State<'_, AppState>, request: GitCommitRequest, ) -> Result", + "signature": "fn git_commit( state: State<'_, AppState>, mut request: GitCommitRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4122,7 +4122,7 @@ "visibility": "documented", "rustPath": "git_create_branch", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_create_branch( state: State<'_, AppState>, request: GitCreateBranchRequest, ) -> Result", + "signature": "fn git_create_branch( state: State<'_, AppState>, mut request: GitCreateBranchRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4138,7 +4138,7 @@ "visibility": "documented", "rustPath": "git_delete_branch", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_delete_branch( state: State<'_, AppState>, request: GitDeleteBranchRequest, ) -> Result", + "signature": "fn git_delete_branch( state: State<'_, AppState>, mut request: GitDeleteBranchRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4154,7 +4154,7 @@ "visibility": "documented", "rustPath": "git_get_branches", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_branches( state: State<'_, AppState>, request: GitBranchesRequest, ) -> Result, String>", + "signature": "fn git_get_branches( state: State<'_, AppState>, mut request: GitBranchesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4170,7 +4170,7 @@ "visibility": "documented", "rustPath": "git_get_changed_files", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_changed_files( state: State<'_, AppState>, request: GitChangedFilesRequest, ) -> Result, String>", + "signature": "fn git_get_changed_files( state: State<'_, AppState>, mut request: GitChangedFilesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4186,7 +4186,7 @@ "visibility": "documented", "rustPath": "git_get_commits", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_commits( state: State<'_, AppState>, request: GitCommitsRequest, ) -> Result, String>", + "signature": "fn git_get_commits( state: State<'_, AppState>, mut request: GitCommitsRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4202,7 +4202,7 @@ "visibility": "documented", "rustPath": "git_get_diff", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_diff( state: State<'_, AppState>, request: GitDiffRequest, ) -> Result", + "signature": "fn git_get_diff( state: State<'_, AppState>, mut request: GitDiffRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4218,7 +4218,7 @@ "visibility": "documented", "rustPath": "git_get_enhanced_branches", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_enhanced_branches( state: State<'_, AppState>, request: GitBranchesRequest, ) -> Result, String>", + "signature": "fn git_get_enhanced_branches( state: State<'_, AppState>, mut request: GitBranchesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4234,7 +4234,7 @@ "visibility": "documented", "rustPath": "git_get_file_content", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_file_content( state: State<'_, AppState>, request: GitGetFileContentRequest, ) -> Result", + "signature": "fn git_get_file_content( state: State<'_, AppState>, mut request: GitGetFileContentRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4250,7 +4250,7 @@ "visibility": "documented", "rustPath": "git_get_graph", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_graph( _state: State<'_, AppState>, repository_path: String, max_count: Option, branch_name: Option, ) -> Result", + "signature": "fn git_get_graph( _state: State<'_, AppState>, repository_path: Option, mut workspace_id: Option, max_count: Option, branch_name: Option, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -4266,7 +4266,7 @@ "visibility": "documented", "rustPath": "git_get_repository", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_repository( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_get_repository( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4282,7 +4282,7 @@ "visibility": "documented", "rustPath": "git_get_repository_basic", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_repository_basic( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_get_repository_basic( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4298,7 +4298,7 @@ "visibility": "documented", "rustPath": "git_get_repository_trust", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_repository_trust( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_get_repository_trust( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4314,7 +4314,7 @@ "visibility": "documented", "rustPath": "git_get_status", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_get_status( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_get_status( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4330,7 +4330,7 @@ "visibility": "documented", "rustPath": "git_is_repository", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_is_repository( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_is_repository( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4348,7 +4348,7 @@ "visibility": "documented", "rustPath": "git_list_worktrees", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_list_worktrees( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result, String>", + "signature": "fn git_list_worktrees( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -4364,7 +4364,7 @@ "visibility": "documented", "rustPath": "git_pull", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_pull( state: State<'_, AppState>, request: GitPullRequest, ) -> Result", + "signature": "fn git_pull( state: State<'_, AppState>, mut request: GitPullRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4380,7 +4380,7 @@ "visibility": "documented", "rustPath": "git_push", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_push( state: State<'_, AppState>, request: GitPushRequest, ) -> Result", + "signature": "fn git_push( state: State<'_, AppState>, mut request: GitPushRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4398,7 +4398,7 @@ "visibility": "documented", "rustPath": "git_remove_worktree", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_remove_worktree( state: State<'_, AppState>, request: GitRemoveWorktreeRequest, ) -> Result", + "signature": "fn git_remove_worktree( state: State<'_, AppState>, mut request: GitRemoveWorktreeRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -4414,7 +4414,7 @@ "visibility": "documented", "rustPath": "git_reset_files", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_reset_files( state: State<'_, AppState>, request: GitResetFilesRequest, ) -> Result", + "signature": "fn git_reset_files( state: State<'_, AppState>, mut request: GitResetFilesRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4430,7 +4430,7 @@ "visibility": "documented", "rustPath": "git_reset_to_commit", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_reset_to_commit( state: State<'_, AppState>, request: GitResetToCommitRequest, ) -> Result", + "signature": "fn git_reset_to_commit( state: State<'_, AppState>, mut request: GitResetToCommitRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4446,7 +4446,7 @@ "visibility": "documented", "rustPath": "git_resolve_revision", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_resolve_revision( state: State<'_, AppState>, request: GitResolveRevisionRequest, ) -> Result", + "signature": "fn git_resolve_revision( state: State<'_, AppState>, mut request: GitResolveRevisionRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -4462,7 +4462,7 @@ "visibility": "documented", "rustPath": "git_trust_repository", "sourceFile": "src/apps/desktop/src/api/git_api.rs", - "signature": "fn git_trust_repository( state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result", + "signature": "fn git_trust_repository( state: State<'_, AppState>, mut request: GitRepositoryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -6786,7 +6786,7 @@ "visibility": "documented", "rustPath": "reject_file", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn reject_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: AcceptFileRequest, ) -> Result", + "signature": "fn reject_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: AcceptFileRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -6802,7 +6802,7 @@ "visibility": "documented", "rustPath": "reject_operation", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn reject_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result", + "signature": "fn reject_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: GetOperationSummaryRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -7984,7 +7984,7 @@ "visibility": "documented", "rustPath": "rollback_session", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn rollback_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: RollbackSessionRequest, ) -> Result, String>", + "signature": "fn rollback_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, mut request: RollbackSessionRequest, ) -> Result, String>", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -8000,7 +8000,7 @@ "visibility": "documented", "rustPath": "rollback_session_to_turn", "sourceFile": "src/apps/desktop/src/api/snapshot_service.rs", - "signature": "fn rollback_session_to_turn( runtime: State<'_, DesktopRuntimeContext>, request: openbitfun_runtime_ports::AgentSessionRollbackToTurnRequest, ) -> Result", + "signature": "fn rollback_session_to_turn( runtime: State<'_, DesktopRuntimeContext>, mut request: openbitfun_runtime_ports::AgentSessionRollbackToTurnRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -8016,7 +8016,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::run_init_agents_md", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn run_init_agents_md( coordinator: State<'_, Arc>, scheduler: State<'_, Arc>, app_state: State<'_, AppState>, request: RunInitAgentsMdRequest, ) -> Result", + "signature": "fn run_init_agents_md( coordinator: State<'_, Arc>, scheduler: State<'_, Arc>, request: RunInitAgentsMdRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -8238,7 +8238,7 @@ "visibility": "documented", "rustPath": "search_file_contents", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn search_file_contents( state: State<'_, AppState>, request: SearchFileContentsRequest, ) -> Result", + "signature": "fn search_file_contents( state: State<'_, AppState>, mut request: SearchFileContentsRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -8254,7 +8254,7 @@ "visibility": "documented", "rustPath": "search_filenames", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn search_filenames( state: State<'_, AppState>, request: SearchFilenamesRequest, ) -> Result", + "signature": "fn search_filenames( state: State<'_, AppState>, mut request: SearchFilenamesRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -8270,7 +8270,7 @@ "visibility": "documented", "rustPath": "search_files", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn search_files( state: State<'_, AppState>, request: SearchFilesRequest, ) -> Result", + "signature": "fn search_files( state: State<'_, AppState>, mut request: SearchFilesRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -8750,7 +8750,7 @@ "visibility": "documented", "rustPath": "set_mode_skill_disabled", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn set_mode_skill_disabled( state: State<'_, AppState>, mode_id: String, skill_key: String, disabled: bool, workspace_path: Option, ) -> Result", + "signature": "fn set_mode_skill_disabled( state: State<'_, AppState>, mode_id: String, skill_key: String, disabled: bool, workspace_id: Option, workspace_path: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -8814,7 +8814,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::set_session_memory_mode", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn set_session_memory_mode( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: SetSessionMemoryModeRequest, ) -> Result", + "signature": "fn set_session_memory_mode( coordinator: State<'_, Arc>, request: SetSessionMemoryModeRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -8830,7 +8830,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::set_session_thread_goal_status", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn set_session_thread_goal_status( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: SetSessionThreadGoalStatusRequest, ) -> Result", + "signature": "fn set_session_thread_goal_status( coordinator: State<'_, Arc>, request: SetSessionThreadGoalStatusRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -9523,7 +9523,7 @@ "visibility": "documented", "rustPath": "start_file_watch", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn start_file_watch(path: String, recursive: Option) -> Result<(), String>", + "signature": "fn start_file_watch( state: State<'_, AppState>, request: Option, path: Option, recursive: Option, ) -> Result<(), String>", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -9571,7 +9571,7 @@ "visibility": "documented", "rustPath": "start_search_file_contents_stream", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn start_search_file_contents_stream( app_handle: AppHandle, state: State<'_, AppState>, request: SearchFileContentsRequest, ) -> Result", + "signature": "fn start_search_file_contents_stream( app_handle: AppHandle, state: State<'_, AppState>, mut request: SearchFileContentsRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -9587,7 +9587,7 @@ "visibility": "documented", "rustPath": "start_search_filenames_stream", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn start_search_filenames_stream( app_handle: AppHandle, state: State<'_, AppState>, request: SearchFilenamesRequest, ) -> Result", + "signature": "fn start_search_filenames_stream( app_handle: AppHandle, state: State<'_, AppState>, mut request: SearchFilenamesRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -9681,7 +9681,7 @@ "visibility": "documented", "rustPath": "stop_file_watch", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn stop_file_watch(path: String) -> Result<(), String>", + "signature": "fn stop_file_watch( state: State<'_, AppState>, request: Option, path: Option, ) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { @@ -9811,7 +9811,7 @@ "visibility": "documented", "rustPath": "api::terminal_api::terminal_create", "sourceFile": "src/apps/desktop/src/api/terminal_api.rs", - "signature": "fn terminal_create( _app: AppHandle, request: CreateSessionRequest, state: State<'_, TerminalState>, app_state: State<'_, AppState>, ) -> Result", + "signature": "fn terminal_create( _app: AppHandle, mut request: CreateSessionRequest, state: State<'_, TerminalState>, app_state: State<'_, AppState>, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -10262,7 +10262,7 @@ "visibility": "documented", "rustPath": "api::agentic_api::update_session_thread_goal_objective", "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", - "signature": "fn update_session_thread_goal_objective( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, request: UpdateSessionThreadGoalObjectiveRequest, ) -> Result", + "signature": "fn update_session_thread_goal_objective( coordinator: State<'_, Arc>, request: UpdateSessionThreadGoalObjectiveRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -10374,7 +10374,7 @@ "visibility": "documented", "rustPath": "validate_skill_path", "sourceFile": "src/apps/desktop/src/api/skill_api.rs", - "signature": "fn validate_skill_path( path: String, source_key: Option, workspace_path: Option, ) -> Result", + "signature": "fn validate_skill_path( path: String, source_key: Option, workspace_id: Option, workspace_path: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { @@ -10448,7 +10448,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_create", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_create( request: WorktreeCreateRequest, ) -> Result", + "signature": "fn worktree_create( mut request: WorktreeCreateRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -10464,7 +10464,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_create_branch", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_create_branch( request: WorktreeCreateBranchRequest, ) -> Result", + "signature": "fn worktree_create_branch( mut request: WorktreeCreateBranchRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -10480,7 +10480,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_list", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_list( request: WorktreeListRequest, ) -> Result, WorktreeError>", + "signature": "fn worktree_list( mut request: WorktreeListRequest, ) -> Result, WorktreeError>", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -10514,7 +10514,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_promote", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_promote( request: WorktreePromoteRequest, ) -> Result", + "signature": "fn worktree_promote( mut request: WorktreePromoteRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -10530,7 +10530,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_recreate", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_recreate( request: WorktreeRecreateRequest, ) -> Result", + "signature": "fn worktree_recreate( mut request: WorktreeRecreateRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { @@ -10548,7 +10548,7 @@ "visibility": "documented", "rustPath": "api::worktree_api::worktree_remove", "sourceFile": "src/apps/desktop/src/api/worktree_api.rs", - "signature": "fn worktree_remove( request: WorktreeRemoveRequest, ) -> Result", + "signature": "fn worktree_remove( mut request: WorktreeRemoveRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteUnsupported" }, { diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 90baba0640..3310ef6a91 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -2015,6 +2015,59 @@ test('CLI Core capability closure requires every reviewed owner', () => { assert.match(violations[0].message, /must include plugin-runtime/); }); +test('CLI openbitfun-core dev-dependency may select only test-support', () => { + const core = packageAt('openbitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const cli = packageAt('openbitfun-cli', 'src/apps/cli/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'openbitfun-core', + usesDefaultFeatures: false, + features: CLI_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/core', { + name: 'openbitfun-core', + kind: 'dev', + usesDefaultFeatures: false, + features: ['test-support'], + }), + ]); + + assert.deepEqual( + findProductEntrypointCoreFeatureViolations( + [cli, core], + { root: TEST_ROOT, crateLayoutRules }, + ), + [], + ); +}); + +test('CLI openbitfun-core dev-dependency cannot widen the product capability closure', () => { + const core = packageAt('openbitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const cli = packageAt('openbitfun-cli', 'src/apps/cli/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'openbitfun-core', + usesDefaultFeatures: false, + features: CLI_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/core', { + name: 'openbitfun-core', + kind: 'dev', + usesDefaultFeatures: false, + features: ['test-support', 'tools-canvas'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [cli, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /openbitfun-core dev-dependency may select only test-support, not \[test-support, tools-canvas\]/, + ); +}); + test('CLI entrypoint must not select the product-full Core feature', () => { const core = packageAt('openbitfun-core', 'src/crates/assembly/core/Cargo.toml'); const cli = packageAt('openbitfun-cli', 'src/apps/cli/Cargo.toml', [ diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 4b797fb152..70f1dc2ece 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -1441,6 +1441,7 @@ export function findProductEntrypointCoreFeatureViolations( 'tools-image-analysis', 'tools-agent-control', ]; + const CORE_TEST_SUPPORT_FEATURE = 'test-support'; const reviewedCoreFeatureClosures = new Map([ ['openbitfun-cli', [ 'tools-pages', @@ -1691,6 +1692,21 @@ export function findProductEntrypointCoreFeatureViolations( } const roleOwnedAcpDependency = sourcePackage.name === 'openbitfun-acp' && dependency.optional === true; + if (dependency.kind === 'dev') { + // A `[dev-dependencies]` edge on openbitfun-core only widens the test + // build. It may switch on Core test-support constructors (isolated + // workspace catalogs for in-process fixtures) and nothing else; the + // product capability closure is owned by the normal dependency edge. + const devSelectedFeatures = [...new Set(dependency.features ?? [])].sort(); + if (devSelectedFeatures.length !== 1 || devSelectedFeatures[0] !== CORE_TEST_SUPPORT_FEATURE) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${sourcePackage.name} openbitfun-core dev-dependency may select only ${CORE_TEST_SUPPORT_FEATURE}, not [${devSelectedFeatures.join(', ')}]`, + }); + } + continue; + } if ( !roleOwnedAcpDependency && (!Array.isArray(dependency.features) || dependency.features.length === 0) diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index ade2342e16..b0fae00f9e 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -8,6 +8,20 @@ const agentRuntimeRootUnexpectedLine = new RegExp( ); export const forbiddenContentRules = [ + { + path: 'src/apps/desktop/src/api/path_target.rs', + patterns: [{ + regex: /\b(?:lookup_remote_entry_for_path|lookup_remote_connection(?:_scoped|_with_hint)?|should_force_local_assistant_path)\b/, + message: 'desktop file routing must resolve workspace IDs; legacy path interpretation belongs only to the upgrade adapter', + }], + }, + { + path: 'src/crates/assembly/core/src/service/filesystem/service.rs', + patterns: [{ + regex: /\blookup_remote_connection(?:_scoped|_with_hint)?\b/, + message: 'filesystem IO must use an explicit provider scope, never infer a workspace from a path', + }], + }, ...[ 'file_read_tool', 'file_write_tool', 'file_edit_tool', 'delete_file_tool', 'ls_tool', ].map((tool) => ({ @@ -1464,6 +1478,10 @@ export const forbiddenContentRules = [ { path: 'src/crates/assembly/core/src/service/search/remote.rs', patterns: [ + { + regex: /\blookup_remote_connection(?:_scoped|_with_hint)?\b/, + message: 'remote search must use the ID-bound workspace object, never reselect a workspace by a path', + }, { regex: /\bconst\s+REMOTE_FLASHGREP_INSTALL_DIR\b/, message: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index f2dcae355e..183fd6ff41 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3191,8 +3191,8 @@ export const requiredContentRules = [ 'core filesystem service may keep remote-workspace overlay and OpenBitFunError compatibility, but local filesystem owner must remain services-core', patterns: [ { - regex: /lookup_remote_connection_with_hint/, - message: 'core filesystem wrapper must preserve remote workspace connection disambiguation', + regex: /let connection_id = explicit_connection_id\?/, + message: 'core filesystem wrapper must require explicit remote workspace connection scope', }, { regex: /get_remote_workspace_manager/, @@ -8244,8 +8244,8 @@ export const requiredContentRules = [ message: 'missing core remote search provider adapter', }, { - regex: /\blookup_remote_connection_with_hint\b/, - message: 'missing preferred remote connection lookup adapter', + regex: /\brequire_workspace\(workspace_id\)/, + message: 'remote search must resolve the registered workspace by ID', }, { regex: /\bopen_workspace_stdio\b/, @@ -8282,8 +8282,8 @@ export const requiredContentRules = [ message: 'missing explicit disabled remote search diagnostic', }, { - regex: /\bremote_workspace_search_service_for_path\b/, - message: 'missing disabled remote workspace search resolver', + regex: /\bremote_workspace_search_service_for_workspace\b/, + message: 'missing ID-based disabled remote workspace search resolver', }, ], }, @@ -8301,8 +8301,8 @@ export const requiredContentRules = [ message: 'missing disabled remote workspace search service surface', }, { - regex: /\bremote_workspace_search_service_for_path\b/, - message: 'missing disabled remote workspace search resolver', + regex: /\bpub async fn search_content\b/, + message: 'missing explicit unsupported search operation', }, ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index b11385d5e2..8dcd7caba0 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4448,7 +4448,7 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/service/search/remote.rs', - contracts: ['ServiceRemoteWorkspaceSearchService', 'impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider', 'lookup_remote_connection_with_hint', 'open_workspace_stdio', 'RemoteWorkspaceSearchStdioProtocol'], + contracts: ['ServiceRemoteWorkspaceSearchService', 'impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider', 'require_workspace(workspace_id)', 'open_workspace_stdio', 'RemoteWorkspaceSearchStdioProtocol'], }, { path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs', @@ -4475,11 +4475,11 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/service/search/remote_disabled.rs', - contracts: ['Remote SSH search is disabled', 'remote_workspace_search_service_for_path'], + contracts: ['Remote SSH search is disabled', 'remote_workspace_search_service_for_workspace'], }, { path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs', - contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'remote_workspace_search_service_for_path'], + contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'pub async fn search_content'], }, { path: 'src/crates/assembly/core/Cargo.toml', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 19dcea2be3..5de4e6c447 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -129,6 +129,7 @@ shlex = { workspace = true } [dev-dependencies] hex = { workspace = true } +openbitfun-core = { path = "../../crates/assembly/core", features = ["test-support"] } tokio = { workspace = true, features = ["io-util", "net"] } [target.'cfg(windows)'.dependencies] diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 371ebea110..471cb4c966 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -45,6 +45,25 @@ use crate::diagnostics::with_session_conflict_help; use crate::runtime::approval::{approval_metadata, CliApprovalPolicy}; use crate::runtime::CliRuntimeContext; +async fn upgrade_shared_workspace_binding( + binding: AgentSessionWorkspaceBinding, +) -> Result { + if binding.workspace_id.is_some() + && binding.project_workspace_id.is_some() + && binding.workspace_kind.is_some() + { + return Ok(binding); + } + let service = + openbitfun_core::service::workspace::get_global_workspace_service().ok_or_else(|| { + anyhow::anyhow!("Workspace service is unavailable for legacy Runtime binding upgrade") + })?; + service + .upgrade_legacy_session_binding(binding) + .await + .map_err(Into::into) +} + fn shared_restore_error(error: RuntimeIpcClientError) -> anyhow::Error { let error = if matches!(&error, RuntimeIpcClientError::Remote(remote) if remote.code == RuntimeIpcErrorCode::FrameTooLarge) { @@ -239,6 +258,8 @@ impl SessionOperationError { #[derive(Clone, Debug)] struct CliWorkspacePaths { + workspace_kind: Option, + project_workspace_id: Option, workspace_id: Option, project: Option, execution: Option, @@ -250,6 +271,8 @@ struct CliWorkspacePaths { impl CliWorkspacePaths { fn new(workspace_path: Option) -> Self { Self { + workspace_kind: None, + project_workspace_id: None, workspace_id: None, project: workspace_path.clone(), execution: workspace_path, @@ -276,6 +299,8 @@ impl CliWorkspacePaths { } fn apply_binding(&mut self, binding: &AgentSessionWorkspaceBinding) { + self.workspace_kind = binding.workspace_kind.clone(); + self.project_workspace_id = binding.project_workspace_id.clone(); self.workspace_id = binding.workspace_id.clone(); let execution = PathBuf::from(&binding.workspace_path); let project = binding @@ -294,6 +319,8 @@ impl CliWorkspacePaths { fn binding(&self) -> AgentSessionWorkspaceBinding { let execution = self.execution(); AgentSessionWorkspaceBinding { + workspace_kind: self.workspace_kind.clone(), + project_workspace_id: self.project_workspace_id.clone(), workspace_id: self.workspace_id.clone(), workspace_path: execution.to_string_lossy().to_string(), project_workspace_path: Some(self.project().to_string_lossy().to_string()), @@ -313,19 +340,23 @@ impl CliWorkspacePaths { self.execution_target = Some(SessionExecutionTarget::local( project.to_string_lossy().to_string(), )); - self.workspace_id = None; + self.workspace_id = self.project_workspace_id.clone(); self.remote_connection_id = None; self.remote_ssh_host = None; project } fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { - if self.remote_connection_id.is_some() || self.remote_ssh_host.is_some() { + if self.workspace_id.is_none() + || self.project_workspace_id.is_none() + || self.workspace_kind.is_none() + { + return Some("Workspace diff is unavailable until workspace identity is resolved"); + } + if self.workspace_kind == Some(openbitfun_core::service::workspace::WorkspaceKind::Remote) { return Some("Workspace diff is unavailable for remote Sessions"); } - let execution = self.execution(); - let project = self.project(); - if !same_workspace_location(&execution, &project) { + if self.workspace_id != self.project_workspace_id { return Some( "Workspace diff is unavailable when the Session uses a different worktree", ); @@ -334,14 +365,6 @@ impl CliWorkspacePaths { } } -fn same_workspace_location(left: &Path, right: &Path) -> bool { - left == right - || dunce::canonicalize(left) - .ok() - .zip(dunce::canonicalize(right).ok()) - .is_some_and(|(left, right)| left == right) -} - /// CLI-owned client for the portable Agent Runtime SDK. /// Stateless regarding agent_type; callers pass it per call. pub(crate) struct CliAgentRuntimeClient { @@ -376,12 +399,18 @@ pub(crate) struct CliAgentMode { type SharedBroadcast = Arc>>>; impl CliAgentRuntimeClient { - pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { + /// The runtime's activated workspace record is the only workspace + /// identity; its root path is read from that record, never passed in. + pub(crate) fn new(runtime: &CliRuntimeContext) -> Self { + let mut paths = CliWorkspacePaths::new(Some(runtime.workspace().root_path.clone())); + paths.workspace_id = Some(runtime.workspace().id.clone()); + paths.project_workspace_id = Some(runtime.workspace().id.clone()); + paths.workspace_kind = Some(runtime.workspace().workspace_kind.clone()); Self { backend: CliAgentRuntimeBackend::Embedded(runtime.agent_runtime().clone()), context_reload: Some(Arc::new(runtime.compatibility().clone())), approval_policy: Arc::new(RwLock::new(runtime.approval_policy())), - workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), + workspace_paths: Arc::new(RwLock::new(paths)), session_id: Arc::new(Mutex::new(None)), current_turn_id: Arc::new(Mutex::new(None)), shared_agent_events: None, @@ -390,7 +419,14 @@ impl CliAgentRuntimeClient { } } - pub(crate) fn new_shared(client: RuntimeIpcClient, workspace_path: Option) -> Self { + pub(crate) fn new_shared( + client: RuntimeIpcClient, + workspace: &openbitfun_core::service::workspace::WorkspaceInfo, + ) -> Self { + let mut paths = CliWorkspacePaths::new(Some(workspace.root_path.clone())); + paths.workspace_id = Some(workspace.id.clone()); + paths.project_workspace_id = Some(workspace.id.clone()); + paths.workspace_kind = Some(workspace.workspace_kind.clone()); let (agent_sender, _) = broadcast::channel(256); let (permission_sender, _) = broadcast::channel(64); let shared_agent_events = Arc::new(RwLock::new(Some(agent_sender.clone()))); @@ -409,7 +445,7 @@ impl CliAgentRuntimeClient { backend: CliAgentRuntimeBackend::Shared(client), context_reload: None, approval_policy: Arc::new(RwLock::new(CliApprovalPolicy::Ask)), - workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), + workspace_paths: Arc::new(RwLock::new(paths)), session_id, current_turn_id: Arc::new(Mutex::new(None)), shared_agent_events: Some(shared_agent_events), @@ -422,12 +458,20 @@ impl CliAgentRuntimeClient { pub(crate) fn new_embedded_for_test( runtime: AgentRuntime, workspace_path: Option, + workspace_id: Option, ) -> Self { + let mut paths = CliWorkspacePaths::new(workspace_path); + paths.workspace_id = workspace_id.clone(); + paths.project_workspace_id = workspace_id; + paths.workspace_kind = paths + .workspace_id + .is_some() + .then_some(openbitfun_core::service::workspace::WorkspaceKind::Normal); Self { backend: CliAgentRuntimeBackend::Embedded(runtime), context_reload: None, approval_policy: Arc::new(RwLock::new(CliApprovalPolicy::Ask)), - workspace_paths: Arc::new(RwLock::new(CliWorkspacePaths::new(workspace_path))), + workspace_paths: Arc::new(RwLock::new(paths)), session_id: Arc::new(Mutex::new(None)), current_turn_id: Arc::new(Mutex::new(None)), shared_agent_events: None, @@ -453,10 +497,9 @@ impl CliAgentRuntimeClient { error ); } - let workspace = PathBuf::from(&binding.workspace_path); if let Err(error) = openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( - Some(&workspace), + binding.workspace_id.as_deref(), ) .await { @@ -464,7 +507,8 @@ impl CliAgentRuntimeClient { } runtime .list_agent_modes(AgentModeCatalogQuery { - workspace_root: Some(workspace.to_string_lossy().to_string()), + workspace_id: binding.workspace_id.clone(), + workspace_root: None, include_external: true, }) .await @@ -599,6 +643,10 @@ impl CliAgentRuntimeClient { .execution() } + pub(crate) fn workspace_id(&self) -> Option { + self.current_workspace_binding().workspace_id + } + pub(crate) fn workspace_path_string(&self) -> String { self.workspace_path_buf().to_string_lossy().to_string() } @@ -630,20 +678,9 @@ impl CliAgentRuntimeClient { .binding() } - pub(crate) fn remote_workspace_scope(&self) -> (Option, Option) { - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - ( - paths.remote_connection_id.clone(), - paths.remote_ssh_host.clone(), - ) - } - pub(crate) fn is_remote_workspace(&self) -> bool { - let (connection_id, ssh_host) = self.remote_workspace_scope(); - connection_id.is_some() || ssh_host.is_some() + self.current_workspace_binding().workspace_kind + == Some(openbitfun_core::service::workspace::WorkspaceKind::Remote) } fn execution_target(&self) -> Option { @@ -667,10 +704,11 @@ impl CliAgentRuntimeClient { async fn list_sessions_in_workspace( &self, - workspace_path: &Path, + workspace_id: &str, ) -> Result> { let request = AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(workspace_id.to_owned()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }; @@ -680,7 +718,14 @@ impl CliAgentRuntimeClient { .await .map_err(|error| anyhow::anyhow!(error.into_message())), CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::ListSessions { request }) + .request(if client.capabilities().workspace_id_references { + RuntimeIpcOperation::ListSessions { request } + } else { + openbitfun_agent_runtime_ipc::legacy_workspace_operation( + RuntimeIpcOperation::ListSessions { request }, + &self.project_workspace_path_buf().to_string_lossy(), + ) + }) .await? { RuntimeIpcOperationResult::Sessions { sessions } => Ok(sessions), @@ -690,8 +735,11 @@ impl CliAgentRuntimeClient { } pub(crate) async fn list_sessions(&self) -> Result> { - let workspace_path = self.current_workspace_path(); - self.list_sessions_in_workspace(&workspace_path).await + let id = self + .current_workspace_binding() + .project_workspace_id + .ok_or_else(|| anyhow::anyhow!("Project workspace ID is unavailable"))?; + self.list_sessions_in_workspace(&id).await } pub(crate) async fn session_lineage( @@ -699,6 +747,12 @@ impl CliAgentRuntimeClient { root_session_id: &str, ) -> Result> { let request = AgentSessionLineageRequest { + workspace_id: self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .workspace_id + .clone(), workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), anchor_session_id: root_session_id.to_string(), remote_connection_id: None, @@ -726,6 +780,7 @@ impl CliAgentRuntimeClient { required_settled_turn_ids: &[String], ) -> std::result::Result { let request = AgentSessionLineageTranscriptRequest { + workspace_id: self.workspace_id(), workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), root_session_id: root_session_id.to_string(), session_id: session_id.to_string(), @@ -760,6 +815,7 @@ impl CliAgentRuntimeClient { expected_active_turn_id: &str, ) -> Result { let request = AgentSessionLineageCancellationRequest { + workspace_id: self.workspace_id(), workspace_path: self.current_workspace_path().to_string_lossy().into_owned(), root_session_id: root_session_id.to_string(), session_id: session_id.to_string(), @@ -797,7 +853,7 @@ impl CliAgentRuntimeClient { tracing::info!("Restoring session: {}", session_id); let project_workspace = self.current_workspace_path(); - let sessions = self.list_sessions_in_workspace(&project_workspace).await?; + let sessions = self.list_sessions().await?; let previous_summary = validated_session_summary(&sessions, session_id, &project_workspace)?; @@ -806,6 +862,7 @@ impl CliAgentRuntimeClient { CliAgentRuntimeBackend::Embedded(runtime) => { let restored = runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: self.workspace_id(), workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, @@ -842,11 +899,22 @@ impl CliAgentRuntimeClient { (restored.session, transcript, restored_turn_id, None, None) } CliAgentRuntimeBackend::Shared(client) => match client - .request(RuntimeIpcOperation::RestoreSession { - request: RuntimeSessionRestoreRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), - session_id: session_id.to_string(), - }, + .request({ + let operation = RuntimeIpcOperation::RestoreSession { + request: RuntimeSessionRestoreRequest { + workspace_id: self.workspace_id(), + workspace_path: String::new(), + session_id: session_id.to_string(), + }, + }; + if client.capabilities().workspace_id_references { + operation + } else { + openbitfun_agent_runtime_ipc::legacy_workspace_operation( + operation, + &project_workspace.to_string_lossy(), + ) + } }) .await .map_err(shared_restore_error)? @@ -882,11 +950,11 @@ impl CliAgentRuntimeClient { }; let binding = if let Some(binding) = shared_workspace_binding { + let binding = upgrade_shared_workspace_binding(binding).await?; self.set_workspace_binding(&binding); binding } else { - self.resolve_session_workspace_binding(session_id, &project_workspace) - .await? + self.resolve_session_workspace_binding(session_id).await? }; self.ensure_embedded_plugin_workspace_ready(&binding) .await?; @@ -916,9 +984,7 @@ impl CliAgentRuntimeClient { async fn resolve_session_workspace_binding( &self, session_id: &str, - fallback_project_workspace: &Path, ) -> Result { - let fallback_project = fallback_project_workspace.to_string_lossy().to_string(); let resolved = match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => runtime .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { @@ -928,14 +994,9 @@ impl CliAgentRuntimeClient { .map_err(|error| anyhow::anyhow!(error.into_message()))?, CliAgentRuntimeBackend::Shared(_) => None, }; - let binding = resolved.unwrap_or_else(|| AgentSessionWorkspaceBinding { - workspace_id: None, - workspace_path: fallback_project.clone(), - project_workspace_path: Some(fallback_project.clone()), - execution_target: Some(SessionExecutionTarget::local(fallback_project)), - remote_connection_id: None, - remote_ssh_host: None, - }); + let binding = resolved.ok_or_else(|| { + anyhow::anyhow!("Session {session_id} has no resolved workspace binding") + })?; self.set_workspace_binding(&binding); Ok(binding) @@ -955,9 +1016,7 @@ impl CliAgentRuntimeClient { if self.is_shared() { return Err(anyhow::anyhow!("Session {session_id} is not attached")); } - let project_workspace = self.project_workspace_path_buf(); - self.resolve_session_workspace_binding(session_id, &project_workspace) - .await + self.resolve_session_workspace_binding(session_id).await } pub(crate) async fn reload_context(&self, request: AgentContextReloadRequest) -> Result<()> { @@ -999,10 +1058,7 @@ impl CliAgentRuntimeClient { } // Session creation holds session_id until activation has completed. // Resolve directly instead of re-locking that non-reentrant mutex. - let project_workspace = self.project_workspace_path_buf(); - let binding = self - .resolve_session_workspace_binding(session_id, &project_workspace) - .await?; + let binding = self.resolve_session_workspace_binding(session_id).await?; self.ensure_embedded_plugin_workspace_ready(&binding).await } @@ -1013,6 +1069,7 @@ impl CliAgentRuntimeClient { match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: self.workspace_id(), workspace_path: self.project_workspace_path_string(), session_id: session_id.to_string(), remote_connection_id: None, @@ -1065,6 +1122,7 @@ impl CliAgentRuntimeClient { match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => { let request = AgentSessionRenameRequest { + workspace_id: self.workspace_id(), workspace_path: self.project_workspace_path_string(), session_id: session_id.to_string(), session_name: session_name.to_string(), @@ -1133,7 +1191,8 @@ impl CliAgentRuntimeClient { ) -> Result { self.embedded_runtime("forking sessions")? .fork_session(AgentSessionForkRequest { - workspace_path: self.project_workspace_path_string(), + workspace_id: self.workspace_id(), + workspace_path: String::new(), source_session_id: source_session_id.to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -1158,7 +1217,8 @@ impl CliAgentRuntimeClient { Some(source_turn_id) => { runtime .fork_session_before_turn(AgentSessionForkBeforeTurnRequest { - workspace_path: workspace_path.clone(), + workspace_id: self.workspace_id(), + workspace_path: String::new(), source_session_id, source_turn_id: source_turn_id.to_string(), remote_connection_id: None, @@ -1169,7 +1229,8 @@ impl CliAgentRuntimeClient { None => { runtime .fork_session(AgentSessionForkRequest { - workspace_path: workspace_path.clone(), + workspace_id: self.workspace_id(), + workspace_path: String::new(), source_session_id, remote_connection_id: None, remote_ssh_host: None, @@ -1180,6 +1241,7 @@ impl CliAgentRuntimeClient { .map_err(|error| anyhow::anyhow!(error.into_message()))?; let restored = runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: self.workspace_id(), workspace_path: workspace_path.clone(), session_id: forked.session_id.clone(), include_internal: false, @@ -1216,10 +1278,11 @@ impl CliAgentRuntimeClient { }; let binding = if let Some(binding) = shared_workspace_binding { + let binding = upgrade_shared_workspace_binding(binding).await?; self.set_workspace_binding(&binding); binding } else { - self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) + self.resolve_session_workspace_binding(&session.session_id) .await? }; self.ensure_embedded_plugin_workspace_ready(&binding) @@ -1239,6 +1302,7 @@ impl CliAgentRuntimeClient { ) -> Result { let session_id = self.require_session_id().await?; let request = AgentSessionRevertRequest { + workspace_id: self.workspace_id(), workspace_path: self.project_workspace_path_string(), session_id: session_id.clone(), remote_connection_id: None, @@ -1374,7 +1438,8 @@ impl CliAgentRuntimeClient { let project_workspace = self.project_workspace_path_buf(); if let Ok(sessions) = runtime .list_sessions(AgentSessionListRequest { - workspace_path: project_workspace.to_string_lossy().to_string(), + workspace_id: self.current_workspace_binding().project_workspace_id, + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) @@ -1396,7 +1461,7 @@ impl CliAgentRuntimeClient { workspace_path: Some(workspace.to_string_lossy().to_string()), project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), execution_target: self.execution_target(), - workspace_id: None, + workspace_id: self.workspace_id(), remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -1416,6 +1481,7 @@ impl CliAgentRuntimeClient { let project_workspace = self.project_workspace_path_buf(); match runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: self.workspace_id(), workspace_path: project_workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), include_internal: false, @@ -1425,9 +1491,7 @@ impl CliAgentRuntimeClient { .await { Ok(_) => { - let binding = self - .resolve_session_workspace_binding(session_id, &project_workspace) - .await?; + let binding = self.resolve_session_workspace_binding(session_id).await?; self.ensure_embedded_plugin_workspace_ready(&binding) .await?; tracing::info!("Backend session restored: {}", session_id); @@ -1470,7 +1534,7 @@ impl CliAgentRuntimeClient { workspace_path: Some(workspace_path), project_workspace_path: Some(project_workspace_path), execution_target: self.execution_target(), - workspace_id: None, + workspace_id: self.workspace_id(), remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -1515,7 +1579,7 @@ impl CliAgentRuntimeClient { workspace_path: Some(self.workspace_path_string()), project_workspace_path: None, execution_target: None, - workspace_id: None, + workspace_id: self.workspace_id(), remote_connection_id: None, remote_ssh_host: None, model_id, @@ -1684,9 +1748,11 @@ impl CliAgentRuntimeClient { turn_id: Some(turn_id.clone()), execution, agent_type: agent_type.to_string(), - // Dialog submission uses this path to locate persisted session - // state. Execution still comes from the session's resolved binding. + // Dialog submission locates persisted session state through the + // owning project workspace ID; the path is only its IO projection. + // Execution still comes from the session's resolved binding. workspace_path: Some(self.project_workspace_path_string()), + workspace_id: self.current_workspace_binding().project_workspace_id, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), @@ -1963,7 +2029,7 @@ impl CliAgentRuntimeClient { workspace_path: Some(project_workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(project_workspace_path)), - workspace_id: None, + workspace_id: self.workspace_id(), remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -2384,6 +2450,8 @@ mod tests { fn workspace_paths_keep_project_and_execution_roots_separate() { let mut paths = CliWorkspacePaths::new(Some("/project".into())); let binding = AgentSessionWorkspaceBinding { + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: Some("project-1".into()), workspace_id: Some("workspace-1".to_string()), workspace_path: "/managed-worktree".to_string(), project_workspace_path: Some("/project".to_string()), @@ -2417,6 +2485,7 @@ mod tests { Path::new("/project").to_path_buf() ); assert_eq!(paths.execution(), Path::new("/project")); + assert_eq!(paths.workspace_id.as_deref(), Some("project-1")); assert!(paths .execution_target .as_ref() @@ -2427,9 +2496,15 @@ mod tests { #[test] fn workspace_diff_fails_closed_for_other_worktrees_and_remote_sessions() { let mut paths = CliWorkspacePaths::new(Some("/project".into())); + assert!(paths.workspace_diff_unavailable_reason().is_some()); + paths.workspace_id = Some("project-1".into()); + paths.project_workspace_id = Some("project-1".into()); + paths.workspace_kind = Some(openbitfun_core::service::workspace::WorkspaceKind::Normal); assert_eq!(paths.workspace_diff_unavailable_reason(), None); paths.apply_binding(&AgentSessionWorkspaceBinding { + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: Some("project-1".into()), workspace_id: Some("workspace-1".to_string()), workspace_path: "/managed-worktree".to_string(), project_workspace_path: Some("/project".to_string()), @@ -2450,7 +2525,9 @@ mod tests { .is_some_and(|reason| reason.contains("different worktree"))); paths.apply_binding(&AgentSessionWorkspaceBinding { - workspace_id: None, + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Remote), + project_workspace_id: Some("remote-workspace".into()), + workspace_id: Some("remote-workspace".into()), workspace_path: "/project".to_string(), project_workspace_path: Some("/project".to_string()), execution_target: Some(SessionExecutionTarget::local("/project")), @@ -2866,6 +2943,55 @@ mod dual_backend_behavior_tests { use super::CliAgentRuntimeClient; + /// User root of the isolated workspace catalog every test in this binary + /// shares. It lives under the process temp directory and is never the + /// developer's real data directory. + static ISOLATED_USER_ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + + /// Install an isolated global workspace service before any fixture creates + /// a record. The production code under test resolves the global service, + /// so without this the fixtures' temp folders would be persisted into the + /// developer's real `workspace_data.json` as opened, current workspaces. + async fn ensure_isolated_workspace_catalog() -> PathBuf { + use openbitfun_core::service::workspace::{ + get_global_workspace_service, set_global_workspace_service, WorkspaceService, + }; + + let user_root = ISOLATED_USER_ROOT + .get_or_init(|| { + std::env::temp_dir() + .join("openbitfun-cli-runtime-client-tests") + .join(uuid::Uuid::new_v4().simple().to_string()) + .join("user-root") + }) + .clone(); + if get_global_workspace_service().is_none() { + let service = WorkspaceService::new_isolated_for_tests(user_root.clone()).await; + set_global_workspace_service(Arc::new(service)); + } + let installed = get_global_workspace_service() + .expect("global workspace service installed") + .path_manager() + .user_root_dir() + .to_path_buf(); + assert_eq!( + installed, user_root, + "CLI runtime client tests must run against an isolated workspace catalog, not the real user data directory" + ); + user_root + } + + /// Open the fixture directory as a real local workspace record. The + /// embedded client, the fixture runtime, and the shared handler all + /// address that workspace by this ID, exactly as production hosts do. + async fn open_fixture_workspace(workspace: &Path) -> String { + ensure_isolated_workspace_catalog().await; + crate::create_cli_local_workspace(workspace) + .await + .expect("fixture workspace record") + .id + } + fn fixture_identity() -> RuntimeInstanceIdentity { let identity = format!( "{}{}", @@ -2877,6 +3003,7 @@ mod dual_backend_behavior_tests { #[derive(Clone)] struct FixtureState { workspace: PathBuf, + workspace_id: String, event_queue: Arc, sessions: Arc>>, @@ -2886,9 +3013,10 @@ mod dual_backend_behavior_tests { } impl FixtureState { - fn new(workspace: PathBuf) -> Self { + fn new(workspace: PathBuf, workspace_id: String) -> Self { Self { workspace, + workspace_id, event_queue: Arc::new(EventQueue::new(EventQueueConfig::default())), sessions: Arc::new(StdMutex::new(HashMap::new())), transcripts: Arc::new(StdMutex::new(HashMap::new())), @@ -2923,10 +3051,14 @@ mod dual_backend_behavior_tests { ); } + /// The fixture runtime answers like the production owner: a session's + /// binding names its workspace by ID, and the path is a projection. fn workspace_binding(&self) -> AgentSessionWorkspaceBinding { let workspace = self.workspace.to_string_lossy().into_owned(); AgentSessionWorkspaceBinding { - workspace_id: None, + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: Some(self.workspace_id.clone()), + workspace_id: Some(self.workspace_id.clone()), workspace_path: workspace.clone(), project_workspace_path: Some(workspace.clone()), execution_target: Some(SessionExecutionTarget::local(workspace)), @@ -3291,9 +3423,9 @@ mod dual_backend_behavior_tests { } impl Fixture { - fn new(workspace: &Path) -> Self { + fn new(workspace: &Path, workspace_id: &str) -> Self { let workspace = dunce::canonicalize(workspace).expect("canonical workspace"); - let state = FixtureState::new(workspace.clone()); + let state = FixtureState::new(workspace.clone(), workspace_id.to_string()); let queue = state.event_queue.clone(); let event_source = AgentEventSource::new(queue.clone()); let store = Arc::new(MemoryPermissionStore::default()); @@ -3342,6 +3474,7 @@ mod dual_backend_behavior_tests { CliAgentRuntimeClient::new_embedded_for_test( self.runtime.clone(), Some(self.workspace.clone()), + Some(self.state.workspace_id.clone()), ) } } @@ -3350,9 +3483,11 @@ mod dual_backend_behavior_tests { fn embedded_client_preserves_managed_workspace_binding() { let root = tempfile::tempdir().expect("workspace root"); let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); - let fixture = Fixture::new(&workspace); + let fixture = Fixture::new(&workspace, "workspace-1"); let client = fixture.embedded_client(); let binding = AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace-1".to_string()), workspace_path: workspace.join("managed-worktree").display().to_string(), project_workspace_path: Some(workspace.display().to_string()), @@ -3385,8 +3520,12 @@ mod dual_backend_behavior_tests { let identity = fixture_identity(); let handler = Arc::new( - SharedRuntimeHandler::build_for_test(fixture.runtime.clone(), &fixture.workspace) - .expect("shared runtime handler"), + SharedRuntimeHandler::build_for_test( + fixture.runtime.clone(), + &fixture.workspace, + &fixture.state.workspace_id, + ) + .expect("shared runtime handler"), ); let server = RuntimeIpcServer::bind_with_handler( root.path(), @@ -3427,9 +3566,15 @@ mod dual_backend_behavior_tests { tokio::task::JoinHandle<()>, ) { let (root, client, server_task) = shared_backend(fixture).await; + ensure_isolated_workspace_catalog().await; ( root, - CliAgentRuntimeClient::new_shared(client, Some(fixture.workspace.clone())), + CliAgentRuntimeClient::new_shared( + client, + &crate::create_cli_local_workspace(&fixture.workspace) + .await + .unwrap(), + ), server_task, ) } @@ -3638,8 +3783,9 @@ mod dual_backend_behavior_tests { }; let root = tempfile::tempdir().unwrap(); let workspace = dunce::canonicalize(root.path()).unwrap(); + let workspace_id = open_fixture_workspace(&workspace).await; for shared in [false, true] { - let fixture = Fixture::new(&workspace); + let fixture = Fixture::new(&workspace, &workspace_id); let shared_backend = if shared { Some(shared_client(&fixture).await) } else { @@ -3685,12 +3831,13 @@ mod dual_backend_behavior_tests { async fn embedded_and_shared_clients_are_behaviorally_equivalent() { let root = tempfile::tempdir().expect("workspace root"); let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); + let workspace_id = open_fixture_workspace(&workspace).await; - let embedded_fixture = Fixture::new(&workspace); + let embedded_fixture = Fixture::new(&workspace, &workspace_id); let embedded_client = embedded_fixture.embedded_client(); let embedded = observe_backend(&embedded_client, &embedded_fixture).await; - let shared_fixture = Fixture::new(&workspace); + let shared_fixture = Fixture::new(&workspace, &workspace_id); let (_root, shared_client, _server_task) = shared_client(&shared_fixture).await; let shared = observe_backend(&shared_client, &shared_fixture).await; @@ -3723,9 +3870,10 @@ mod dual_backend_behavior_tests { async fn embedded_and_shared_clients_preserve_outcome_unknown() { let root = tempfile::tempdir().expect("workspace root"); let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); + let workspace_id = open_fixture_workspace(&workspace).await; for shared in [false, true] { - let fixture = Fixture::new(&workspace); + let fixture = Fixture::new(&workspace, &workspace_id); let (_root, client, _server_task); if shared { let tuple = shared_client(&fixture).await; @@ -3772,7 +3920,8 @@ mod dual_backend_behavior_tests { async fn shared_handler_rejects_remote_lineage_scope() { let root = tempfile::tempdir().expect("workspace root"); let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); - let fixture = Fixture::new(&workspace); + let workspace_id = open_fixture_workspace(&workspace).await; + let fixture = Fixture::new(&workspace, &workspace_id); let (_root, client, _server_task) = shared_backend(&fixture).await; let create_request = AgentSessionCreateRequest { @@ -3803,6 +3952,7 @@ mod dual_backend_behavior_tests { let response = client .request(RuntimeIpcOperation::GetSessionLineage { request: openbitfun_agent_runtime::sdk::AgentSessionLineageRequest { + workspace_id: None, workspace_path: fixture.workspace.to_string_lossy().into_owned(), anchor_session_id: session.session_id, remote_connection_id: Some("remote-connection".to_string()), @@ -3821,7 +3971,8 @@ mod dual_backend_behavior_tests { async fn shared_disconnect_projects_system_error_event() { let root = tempfile::tempdir().expect("workspace root"); let workspace = dunce::canonicalize(root.path()).expect("canonical workspace"); - let fixture = Fixture::new(&workspace); + let workspace_id = open_fixture_workspace(&workspace).await; + let fixture = Fixture::new(&workspace, &workspace_id); let (_root, client, server_task) = shared_client(&fixture).await; let mut events = client .subscribe_events() diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 8ba52a94d3..341e2f5bed 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -461,6 +461,8 @@ impl ChatState { workspace .as_ref() .map(|workspace_path| AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: None, workspace_path: workspace_path.clone(), project_workspace_path: Some(workspace_path.clone()), @@ -510,6 +512,18 @@ impl ChatState { .or(self.workspace.as_deref()) } + /// Owning project workspace ID from the bound session, falling back to the + /// execution workspace ID when the binding predates linked worktrees. + pub(crate) fn project_workspace_id(&self) -> Option<&str> { + self.workspace_binding.as_ref().and_then(|binding| { + binding + .project_workspace_id + .as_deref() + .or(binding.workspace_id.as_deref()) + .filter(|id| !id.trim().is_empty()) + }) + } + pub(crate) fn set_git_repository_status( &mut self, is_repository: bool, @@ -1671,6 +1685,8 @@ mod tests { base_commit: Option<&str>, ) -> AgentSessionWorkspaceBinding { AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace-1".to_string()), workspace_path: "/tmp/managed-worktree".to_string(), project_workspace_path: Some("/tmp/project".to_string()), diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 90499613a8..057ec7424a 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -400,6 +400,10 @@ async fn query_in_store( Some(&token_usage), openbitfun_core::service::session_usage::SessionUsageReportRequest { session_id: job.request.session_id.clone(), + // Dispatch jobs are keyed by the canonical target path the + // controller submitted; the target workspace record only + // exists while the worker runtime is up. + workspace_id: None, workspace_path: Some(job.request.workspace_path.clone()), remote_connection_id: None, remote_ssh_host: None, diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index c0d81195bf..17ceca5505 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -164,7 +164,8 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { // turn because the persisted id already exists. let restore_error = agent_runtime .restore_session(AgentSessionRestoreRequest { - workspace_path: workspace_path.clone(), + workspace_id: Some(runtime.workspace().id.clone()), + workspace_path: String::new(), session_id: job.request.session_id.clone(), include_internal: false, remote_connection_id: None, @@ -187,7 +188,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { workspace_path: Some(workspace_path.clone()), project_workspace_path: Some(workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(workspace_path.clone())), - workspace_id: None, + workspace_id: Some(runtime.workspace().id.clone()), remote_connection_id: None, remote_ssh_host: None, model_id: effective_model.clone(), @@ -251,6 +252,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { execution: Default::default(), agent_type: job.request.agent_type.clone(), workspace_path: Some(workspace_path), + workspace_id: Some(runtime.workspace().id.clone()), remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), diff --git a/src/apps/cli/src/hook_import.rs b/src/apps/cli/src/hook_import.rs index dd5fd93246..20c824e4ea 100644 --- a/src/apps/cli/src/hook_import.rs +++ b/src/apps/cli/src/hook_import.rs @@ -77,7 +77,11 @@ pub(crate) enum HookAction { } pub(crate) async fn run(action: Option) -> Result<()> { - let workspace = std::env::current_dir().ok(); + let workspace = Some( + crate::create_cli_local_workspace(&std::env::current_dir()?) + .await? + .id, + ); match action.unwrap_or(HookAction::List { refresh: false, format: HookImportOutputFormat::Text, @@ -181,7 +185,7 @@ pub(crate) async fn run(action: Option) -> Result<()> { } async fn preview_or_apply( - workspace: Option<&std::path::Path>, + workspace: Option<&str>, source: SourceKey, confirm: Option, format: HookImportOutputFormat, @@ -244,7 +248,7 @@ pub(crate) fn completed_import_status( } pub(crate) async fn mutate( - workspace: Option<&std::path::Path>, + workspace: Option<&str>, action: ExternalHookImportMutationV1, ) -> std::result::Result { let snapshot = diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index b6b5422ce9..37dfbbd63a 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -890,7 +890,7 @@ async fn initialize_core_services_for_deployment( { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "CLI startup configuration", - Some(workspace_root), + None, error, ) .await; @@ -908,7 +908,7 @@ async fn initialize_core_services_for_deployment( Err(error) => { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "CLI startup", - Some(workspace_root), + None, error, ) .await; @@ -958,9 +958,10 @@ async fn initialize_core_services_for_deployment( .map_err(|error| anyhow!("Failed to initialize agentic system: {error}"))?; tracing::info!("Agentic system initialized"); + let workspace = create_cli_local_workspace(workspace_root).await?; let runtime = std::sync::Arc::new(runtime::CliRuntimeContext::build( agentic_system, - workspace_root, + workspace, approval_policy, )?); // Restore on the executing host for TUI, exec, Shared and detached jobs alike. @@ -1056,10 +1057,7 @@ async fn run_interactive( ) }; let agent = if let Some(runtime) = &runtime { - Arc::new(agent::runtime_client::CliAgentRuntimeClient::new( - runtime, - Some(workspace_path.clone()), - )) + Arc::new(agent::runtime_client::CliAgentRuntimeClient::new(runtime)) } else { let client = shared_runtime::connect_or_start(&workspace_path).await?; client.health().await?; @@ -1068,7 +1066,7 @@ async fn run_interactive( } Arc::new(agent::runtime_client::CliAgentRuntimeClient::new_shared( client, - Some(workspace_path.clone()), + &create_cli_local_workspace(&workspace_path).await?, )) }; let account_runtime = runtime @@ -1671,10 +1669,7 @@ async fn run_interactive_with_session( let workspace_path = runtime.workspace_root().to_path_buf(); let workspace = Some(workspace_path.to_string_lossy().to_string()); - let agent = Arc::new(agent::runtime_client::CliAgentRuntimeClient::new( - &runtime, - Some(workspace_path), - )); + let agent = Arc::new(agent::runtime_client::CliAgentRuntimeClient::new(&runtime)); let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() @@ -2356,3 +2351,34 @@ mod daemon_command_tests { assert!(!daemon_help.contains("__dispatch_")); } } + +/// Resolves the stable workspace record for a directory the CLI is operating in +/// (its cwd, `--workspace`, or a command operand). Session and protocol routing +/// must use the resulting record ID. +/// +/// This registers a hidden record only. The CLI shares the workspace catalog +/// with the desktop, and running `openbitfun doctor` or a chat in some folder +/// must not make that folder an opened, recent, or current desktop workspace; +/// opening a folder for the desktop UI is an explicit user action there. +async fn create_cli_local_workspace( + path: &std::path::Path, +) -> Result { + use openbitfun_core::service::workspace::{ + get_global_workspace_service, set_global_workspace_service, WorkspaceService, + }; + let service = if let Some(service) = get_global_workspace_service() { + service + } else { + let service = std::sync::Arc::new( + WorkspaceService::new() + .await + .map_err(|error| anyhow!(error.to_string()))?, + ); + set_global_workspace_service(service.clone()); + service + }; + service + .register_local_workspace_record(path.to_owned()) + .await + .map_err(|error| anyhow!(error.to_string())) +} diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 8ca23ed812..6602da011b 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -31,10 +31,16 @@ async fn ensure_global_config_service( pub(crate) async fn print_agents(workspace: Option<&Path>) -> Result<()> { let registry = get_agent_registry(); + let workspace_id = match workspace { + Some(root) => Some(crate::create_cli_local_workspace(root).await?.id), + None => None, + }; if workspace.is_some() { if let Err(error) = - openbitfun_core::external_sources::ensure_external_source_workspace_snapshot(workspace) - .await + openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( + workspace_id.as_deref(), + ) + .await { eprintln!( "Warning: external agent sources could not be refreshed: {}", @@ -43,9 +49,9 @@ pub(crate) async fn print_agents(workspace: Option<&Path>) -> Result<()> { } } let modes = registry - .get_modes_info_for_workspace(workspace, workspace.is_some()) + .get_modes_info_for_workspace(workspace_id.as_deref(), workspace_id.is_some()) .await; - let subagents = registry.get_subagents_info(workspace).await; + let subagents = registry.get_subagents_info(workspace_id.as_deref()).await; println!("Agent modes"); println!(); @@ -542,7 +548,8 @@ pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { _ => runtime .agent_runtime() .list_sessions(openbitfun_runtime_ports::AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(runtime.workspace().id.clone()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) @@ -555,6 +562,7 @@ pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { let report = runtime .agent_runtime() .generate_session_usage(AgentSessionUsageRequest { + workspace_id: Some(runtime.workspace().id.clone()), session_id: resolved_session_id, workspace_path: Some(workspace_path.to_string_lossy().to_string()), remote_connection_id: None, @@ -932,18 +940,17 @@ pub(crate) async fn print_doctor(product_runtime: &ProductRuntimeParts) -> Resul let config_service = ensure_global_config_service().await?; let models = config_service.get_ai_models().await?; let agent_registry = get_agent_registry(); + let workspace_id = crate::create_cli_local_workspace(&workspace).await?.id; let external_source_error = openbitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - &workspace, + &workspace_id, )) .await .err(); let modes = agent_registry - .get_modes_info_for_workspace(Some(&workspace), true) - .await; - let subagents = agent_registry - .get_subagents_info(Some(workspace.as_path())) + .get_modes_info_for_workspace(Some(&workspace_id), true) .await; + let subagents = agent_registry.get_subagents_info(Some(&workspace_id)).await; let mcp_service = openbitfun_core::service::mcp::MCPService::new(config_service.clone()) .map_err(|error| anyhow!(error.to_string()))?; let mcp_configs = mcp_service.config_service().load_all_configs().await?; diff --git a/src/apps/cli/src/mcp_import.rs b/src/apps/cli/src/mcp_import.rs index 1e03d18071..2ed8f4c351 100644 --- a/src/apps/cli/src/mcp_import.rs +++ b/src/apps/cli/src/mcp_import.rs @@ -21,7 +21,11 @@ pub(crate) struct McpImportCommand { } pub(crate) async fn execute(command: McpImportCommand) -> Result<()> { - let workspace = std::env::current_dir().ok(); + let workspace = Some( + crate::create_cli_local_workspace(&std::env::current_dir()?) + .await? + .id, + ); let plan = openbitfun_core::external_mcp_import::plan_external_mcp_import(workspace.clone()) .await .map_err(operation_error)?; diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index 639a1c6ded..e0d91fe724 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -56,7 +56,13 @@ impl ChatMode { if self.agent.is_remote_workspace() { anyhow::bail!("Skill management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; + let workspace = + openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&workspace_id) + .await?; let registry = openbitfun_core::agentic::tools::implementations::skills::get_skill_registry(); let skills = registry @@ -108,11 +114,20 @@ impl ChatMode { if self.agent.is_remote_workspace() { anyhow::bail!("Skill management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; + let workspace = + openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&workspace_id) + .await?; let registry = openbitfun_core::agentic::tools::implementations::skills::get_skill_registry(); let skills = registry - .get_mode_skill_infos_for_workspace(Some(&workspace), &self.agent_type) + .get_mode_skill_infos_for_workspace( + openbitfun_core::agentic::tools::implementations::skills::mode_overrides::SkillPolicyWorkspace::from_binding(&workspace), + &self.agent_type, + ) .await; Ok::<_, anyhow::Error>( skills @@ -270,12 +285,15 @@ impl ChatMode { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; let values = openbitfun_core::agentic::agents::get_agent_registry() .get_subagents_for_query( &openbitfun_core::agentic::agents::SubagentQueryContext { parent_agent_type: Some(&self.agent_type), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace_id), list_scope: openbitfun_core::agentic::agents::SubagentListScope::TaskVisible, include_disabled: false, @@ -329,11 +347,11 @@ impl ChatMode { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self.agent.workspace_id().ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; let values = openbitfun_core::agentic::agents::get_agent_registry() .get_subagents_for_query(&openbitfun_core::agentic::agents::SubagentQueryContext { parent_agent_type: Some(&self.agent_type), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace_id), list_scope: openbitfun_core::agentic::agents::SubagentListScope::RegistryManagement, include_disabled: true, @@ -431,9 +449,12 @@ impl ChatMode { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; openbitfun_core::agentic::agents::get_agent_registry() - .update_subagent_override(&mode_id, &subagent.id, enabled, Some(&workspace)) + .update_subagent_override(&mode_id, &subagent.id, enabled, Some(&workspace_id)) .await .map_err(|error| anyhow::anyhow!(error.to_string()))?; Ok::<(), anyhow::Error>(()) diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index cf2646c48f..d38376d7cb 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -712,10 +712,10 @@ impl ChatMode { .unwrap_or(0); let persisted = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); let conflicts = openbitfun_core::external_sources::set_native_prompt_command_conflict_choice( - Some(&workspace), + workspace.as_deref(), native_commands, candidate_id, expected_preference_revision, @@ -779,9 +779,9 @@ impl ChatMode { .unwrap_or(0); let snapshot = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); openbitfun_core::external_sources::set_external_prompt_command_conflict_choice( - Some(&workspace), + workspace.as_deref(), provider_conflict_key, &projection.candidate_id, expected_preference_revision, @@ -900,9 +900,9 @@ impl ChatMode { ) -> Result> { let expanded = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); openbitfun_core::external_sources::expand_external_prompt_command( - Some(&workspace), + workspace.as_deref(), &invocation.command_name, &invocation.arguments, invocation.native_commands.clone(), diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index e1e68babd3..5dc182dd89 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -472,16 +472,19 @@ impl ChatMode { } let workspace = std::path::PathBuf::from(self.agent.project_workspace_path_string()); + let workspace_id = self.agent.workspace_id(); self.spawn_hook_management( async move { let imports = openbitfun_core::external_hook_import::external_hook_import_snapshot( - Some(&workspace), + workspace_id.as_deref(), refresh, ) .await?; let native = project_native_hook_overview( - openbitfun_core::native_hooks::overview(Some(&workspace)).await, + openbitfun_core::native_hooks::overview(workspace_id.as_deref()) + .await + .map_err(ExternalSourceOperationError::invalid_request)?, &workspace, ); Ok(HookManagementResult::Snapshot(HookManagementSnapshot { @@ -584,12 +587,12 @@ impl ChatMode { ); return; } - let workspace = std::path::PathBuf::from(self.agent.project_workspace_path_string()); + let workspace_id = self.agent.workspace_id(); if !confirm { self.spawn_hook_management( async move { openbitfun_core::external_hook_import::plan_external_hook_import( - Some(&workspace), + workspace_id.as_deref(), source, ) .await @@ -614,10 +617,11 @@ impl ChatMode { return; }; let workspace = std::path::PathBuf::from(self.agent.project_workspace_path_string()); + let workspace_id = self.agent.workspace_id(); self.spawn_hook_management( async move { let result = openbitfun_core::external_hook_import::apply_external_hook_import( - Some(&workspace), + workspace_id.as_deref(), ExternalHookImportApplyRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, source: source.clone(), @@ -636,7 +640,9 @@ impl ChatMode { crate::hook_import::completed_import_status(&snapshot, &source, applied) .to_string(); let native = project_native_hook_overview( - openbitfun_core::native_hooks::overview(Some(&workspace)).await, + openbitfun_core::native_hooks::overview(workspace_id.as_deref()) + .await + .map_err(ExternalSourceOperationError::invalid_request)?, &workspace, ); Ok(HookManagementResult::Changed { @@ -707,10 +713,11 @@ impl ChatMode { return; } let workspace = std::path::PathBuf::from(self.agent.project_workspace_path_string()); + let workspace_id = self.agent.workspace_id(); self.spawn_hook_management( async move { let imports = openbitfun_core::external_hook_import::mutate_external_hook_import( - Some(&workspace), + workspace_id.as_deref(), ExternalHookImportMutationRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, expected_revision, @@ -719,7 +726,7 @@ impl ChatMode { ) .await?; let native = project_native_hook_overview( - openbitfun_core::native_hooks::overview(Some(&workspace)).await, + openbitfun_core::native_hooks::overview(workspace_id.as_deref()).await.map_err(ExternalSourceOperationError::invalid_request)?, &workspace, ); let status = if remove { @@ -782,10 +789,11 @@ impl ChatMode { return; } let workspace = std::path::PathBuf::from(self.agent.project_workspace_path_string()); + let workspace_id = self.agent.workspace_id(); self.spawn_hook_management( async move { let imports = openbitfun_core::external_hook_import::mutate_external_hook_import( - Some(&workspace), + workspace_id.as_deref(), ExternalHookImportMutationRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, expected_revision, @@ -794,7 +802,7 @@ impl ChatMode { ) .await?; let native = project_native_hook_overview( - openbitfun_core::native_hooks::overview(Some(&workspace)).await, + openbitfun_core::native_hooks::overview(workspace_id.as_deref()).await.map_err(ExternalSourceOperationError::invalid_request)?, &workspace, ); Ok(HookManagementResult::Changed { diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index f37d021f61..6a3ba85119 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -420,14 +420,14 @@ impl ChatMode { )); return; } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = async { if matches!(&task_action, ExternalControlUiAction::Show) { let surface = openbitfun_core::external_sources::get_external_source_control_snapshot( - Some(&workspace), + workspace.as_deref(), false, openbitfun_product_domains::external_sources::ExternalSourceHostCapabilities::read_write(), ) @@ -463,7 +463,7 @@ impl ChatMode { ExternalControlUiAction::Show => unreachable!(), }; let surface = openbitfun_core::external_sources::apply_external_source_control_action( - Some(&workspace), + workspace.as_deref(), ExternalSourceControlRequestV1 { schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, operation_id: format!("tui-{}", uuid::Uuid::new_v4()), @@ -474,7 +474,7 @@ impl ChatMode { .await?; let snapshot = openbitfun_core::external_sources::get_external_source_control_snapshot( - Some(&workspace), + workspace.as_deref(), false, openbitfun_product_domains::external_sources::ExternalSourceHostCapabilities::read_write(), ) @@ -625,13 +625,13 @@ impl ChatMode { )); return; } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = match &task_action { ExternalToolReviewAction::Refresh => { openbitfun_core::external_sources::external_source_snapshot( - Some(&workspace), + workspace.as_deref(), true, ) .await @@ -642,7 +642,7 @@ impl ChatMode { approved, } => { openbitfun_core::external_sources::set_external_tool_target_decision( - Some(&workspace), + workspace.as_deref(), approval_key, decision_key, *approved, @@ -655,7 +655,7 @@ impl ChatMode { candidate_id, } => { openbitfun_core::external_sources::set_external_tool_conflict_choice( - Some(&workspace), + workspace.as_deref(), conflict_key, candidate_id, expected_preference_revision, @@ -831,13 +831,13 @@ impl ChatMode { )); return; } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = match &task_action { ExternalAgentReviewAction::Refresh => { openbitfun_core::external_sources::external_source_snapshot( - Some(&workspace), + workspace.as_deref(), true, ) .await @@ -850,7 +850,7 @@ impl ChatMode { expected_preference_revision, } => { openbitfun_core::external_sources::set_external_subagent_activation( - Some(&workspace), + workspace.as_deref(), candidate_id, *approved, *expected_subagent_generation, @@ -867,7 +867,7 @@ impl ChatMode { expected_preference_revision, } => { openbitfun_core::external_sources::choose_external_subagent_conflict( - Some(&workspace), + workspace.as_deref(), conflict_key, candidate_id, *approve_external, @@ -883,7 +883,7 @@ impl ChatMode { expected_preference_revision, } => { openbitfun_core::external_sources::set_external_subagent_model_binding( - Some(&workspace), + workspace.as_deref(), binding_key, target.clone(), *expected_subagent_generation, diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index 53e6d541a5..9dd47f0544 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -301,9 +301,9 @@ impl ChatMode { crate::ensure_cli_mcp_service(config) .ok_or_else(|| anyhow!("The current CLI Host has no MCP service"))? }; - let workspace = self.agent.workspace_path_string(); + let workspace = self.agent.workspace_id(); let external = openbitfun_core::external_sources::external_source_snapshot( - Some(std::path::Path::new(&workspace)), + workspace.as_deref(), false, ) .await @@ -513,7 +513,7 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let workspace_path = self.agent.workspace_path_string(); + let workspace_id = self.agent.workspace_id(); let action = item.action.clone(); let item_id = item.id.clone(); let item_name = item.name.clone(); @@ -526,7 +526,7 @@ impl ChatMode { expected_mcp_generation, expected_preference_revision, } => openbitfun_core::external_sources::set_external_mcp_server_decision( - Some(std::path::Path::new(&workspace_path)), + workspace_id.as_deref(), &candidate_id, &decision_key, approved, @@ -542,7 +542,7 @@ impl ChatMode { expected_mcp_generation, expected_preference_revision, } => openbitfun_core::external_sources::choose_external_mcp_conflict( - Some(std::path::Path::new(&workspace_path)), + workspace_id.as_deref(), &conflict_key, &candidate_id, approve_external, diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 01356338e7..55312038fa 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -473,15 +473,15 @@ impl ChatMode { ))); } let (initial_external_sources, updates) = tokio::task::block_in_place(|| { - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace = self.agent.workspace_id(); let updates = if self.agent.is_remote_workspace() { None } else { rt_handle .block_on( - openbitfun_core::external_sources::subscribe_external_source_updates(Some( - &workspace, - )), + openbitfun_core::external_sources::subscribe_external_source_updates( + workspace.as_deref(), + ), ) .ok() }; @@ -495,7 +495,7 @@ impl ChatMode { ); } let snapshot = openbitfun_core::external_sources::external_source_snapshot( - Some(&workspace), + workspace.as_deref(), false, ) .await diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 9643ba0e32..21ffaaf7bf 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -254,6 +254,20 @@ impl ChatMode { .clone() .or_else(|| self.workspace.clone()) .or_else(|| Some(self.agent.workspace_path_string())); + // Sessions are stored under their owning project workspace; the bound + // session names it by ID. Fall back to the client's own workspace ID only + // when the chat still points at that same workspace. + let workspace_id = chat_state + .project_workspace_id() + .map(str::to_string) + .or_else(|| { + let agent_path = self.agent.workspace_path_string(); + (workspace_path + .as_deref() + .is_none_or(|path| path == agent_path)) + .then(|| self.agent.workspace_id()) + .flatten() + }); let agent = self.agent.clone(); /* @@ -279,6 +293,7 @@ impl ChatMode { agent .generate_session_usage_report(AgentSessionUsageRequest { + workspace_id, session_id, workspace_path: Some(workspace_path), remote_connection_id: None, diff --git a/src/apps/cli/src/modes/chat/session_lineage.rs b/src/apps/cli/src/modes/chat/session_lineage.rs index ba0b0a3be0..1ae48da598 100644 --- a/src/apps/cli/src/modes/chat/session_lineage.rs +++ b/src/apps/cli/src/modes/chat/session_lineage.rs @@ -1094,6 +1094,7 @@ mod session_lineage_tests { parent_tool_call_id: None, subagent_type: Some("explore".to_string()), agent_id: Some(id.to_string()), + workspace_id: None, workspace_path: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/apps/cli/src/modes/chat/worktree.rs b/src/apps/cli/src/modes/chat/worktree.rs index e549a1fc1c..c091c4d1ae 100644 --- a/src/apps/cli/src/modes/chat/worktree.rs +++ b/src/apps/cli/src/modes/chat/worktree.rs @@ -94,6 +94,7 @@ impl ChatMode { } else { "Releasing worktree after prompt submission...".to_string() })); + let project_workspace_id = chat_state.project_workspace_id().map(str::to_string); let project_workspace_path = chat_state.project_workspace_path().map(str::to_string); if self.agent.is_remote_workspace() { return Err( @@ -107,6 +108,7 @@ impl ChatMode { openbitfun_core::service::worktree::WorktreeSessionBindingRequest { request_id: format!("tui-worktree-{}", uuid::Uuid::new_v4()), session_id: chat_state.core_session_id.clone(), + project_workspace_id, project_workspace_path, enabled, }, @@ -128,6 +130,8 @@ impl ChatMode { let execution_target = result.execution_target.clone(); let binding = openbitfun_runtime_ports::AgentSessionWorkspaceBinding { + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: result.project_workspace_id, workspace_id: result.workspace_id, workspace_path: result.workspace_path, project_workspace_path: Some(result.project_workspace_path), diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index cebabda2b6..91c87858df 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -451,10 +451,7 @@ impl ExecMode { | crate::runtime::approval::CliApprovalPolicy::DisableAuto | crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject, }; - let agent = Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), - workspace_path.clone(), - )); + let agent = Arc::new(CliAgentRuntimeClient::new(runtime.as_ref())); let (initial_diff_base, initial_untracked_files) = if super::verification::needs_change_baseline( output_patch.as_deref(), diff --git a/src/apps/cli/src/peer_host/commands/dialog.rs b/src/apps/cli/src/peer_host/commands/dialog.rs index 7a7a3db776..2657318f89 100644 --- a/src/apps/cli/src/peer_host/commands/dialog.rs +++ b/src/apps/cli/src/peer_host/commands/dialog.rs @@ -156,6 +156,11 @@ async fn submit_dialog_turn(state: &PeerHostState, args: &Value) -> Result Result ExternalSourceOperationResult> { - let Some(requested) = optional_string_field(request, "workspacePath")? else { +) -> ExternalSourceOperationResult> { + let id = optional_string_field(request, "workspaceId")?; + let legacy = optional_string_field(request, "workspacePath")?; + if id.is_none() && legacy.is_none() { return Ok(None); - }; - let requested = PathBuf::from(requested); - if !requested.is_absolute() { - return Err(ExternalSourceOperationError::invalid_request( - "External sources require an absolute workspace path", - )); } - let requested = requested.canonicalize().map_err(|_| { - ExternalSourceOperationError::invalid_request( - "Workspace path is not available on this Host", + let workspace = state + .workspace_service + .resolve_legacy_workspace_reference( + id.as_deref(), + legacy.as_deref().unwrap_or_default(), + None, + None, ) - })?; - let opened = state.workspace_service.get_opened_workspaces().await; - let known_local_workspace = opened.iter().any(|workspace| { - !matches!( - workspace.workspace_kind, - openbitfun_core::service::workspace::manager::WorkspaceKind::Remote - ) && workspace.root_path.canonicalize().ok().as_ref() == Some(&requested) - }); - if !known_local_workspace { - return Err(ExternalSourceOperationError::invalid_request( - "External compatibility requires an opened local Host workspace", + .await + .map_err(|error| ExternalSourceOperationError::invalid_request(error.to_string()))? + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Unknown workspace reference") + })?; + if workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote { + return Err(ExternalSourceOperationError::host_capability_unavailable( + "External sources do not support remote workspaces", )); } - Ok(Some(requested)) + Ok(Some(workspace.id)) } fn public_snapshot( @@ -167,7 +162,7 @@ async fn dispatch_inner( )); } let request = request_value(args); - let workspace = workspace_root(state, request).await?; + let workspace = workspace_id(state, request).await?; let workspace = workspace.as_deref(); if command == "get_external_source_discovery_snapshot" { let snapshot = external_source_discovery_snapshot( diff --git a/src/apps/cli/src/peer_host/commands/filesystem.rs b/src/apps/cli/src/peer_host/commands/filesystem.rs index a60e942bfc..7422cef735 100644 --- a/src/apps/cli/src/peer_host/commands/filesystem.rs +++ b/src/apps/cli/src/peer_host/commands/filesystem.rs @@ -5,22 +5,28 @@ use serde_json::{json, Value}; use crate::peer_host::args::{get_string, optional_string, request_value}; use crate::peer_host::state::PeerHostState; -// An explicit workspace without SSH identity denotes the runtime-local provider. -// Keep omitted scope available for existing runtime callers which use path routing. -fn remote_hint(request: &Value) -> Option { - request - .get("remoteConnectionId") - .or_else(|| request.get("remote_connection_id")) - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - request - .get("workspacePath") - .or_else(|| request.get("workspace_path")) - .and_then(Value::as_str) - .filter(|path| !path.is_empty()) - .map(|_| String::new()) - }) +async fn file_connection(state: &PeerHostState, request: &Value) -> Result, String> { + if let Some(id) = + optional_string(request, "workspaceId").or_else(|| optional_string(request, "workspace_id")) + { + let record = state + .workspace_service + .require_workspace(&id) + .await + .map_err(|e| e.to_string())?; + return record + .filesystem_connection_id() + .map(|id| id.map(str::to_owned)); + } + state + .workspace_service + .upgrade_legacy_file_connection( + &get_string(request, "path")?, + optional_string(request, "workspacePath").as_deref(), + optional_string(request, "remoteConnectionId").as_deref(), + ) + .await + .map_err(|e| e.to_string()) } fn directory_nodes_to_json( @@ -47,10 +53,10 @@ pub(crate) async fn get_directory_children( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let preferred = remote_hint(request); + let preferred = file_connection(state, request).await?; let nodes = state .filesystem_service - .get_directory_contents_with_remote_hint(&path, preferred.as_deref()) + .get_directory_contents_on_connection(&path, preferred.as_deref()) .await .map_err(|e| format!("Failed to get directory children: {e}"))?; Ok(json!(directory_nodes_to_json(nodes))) @@ -62,13 +68,13 @@ pub(crate) async fn get_directory_children_paginated( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let preferred = remote_hint(request); + let preferred = file_connection(state, request).await?; let offset = request.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let limit = request.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; let mut nodes = state .filesystem_service - .get_directory_contents_with_remote_hint(&path, preferred.as_deref()) + .get_directory_contents_on_connection(&path, preferred.as_deref()) .await .map_err(|e| format!("Failed to get paginated directory children: {e}"))?; openbitfun_services_core::filesystem::sort_directory_nodes( @@ -95,7 +101,7 @@ pub(crate) async fn check_path_exists( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; openbitfun_core::service::filesystem::path_operations::exists( &state.filesystem_service, &path, @@ -108,7 +114,7 @@ pub(crate) async fn check_path_exists( pub(crate) async fn create_directory(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; openbitfun_core::service::filesystem::path_operations::create_directory( &state.filesystem_service, &path, @@ -124,7 +130,7 @@ pub(crate) async fn read_file_content( ) -> Result { let request = request_value(args); let path = get_string(request, "filePath")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; if let Some(encoding) = optional_string(request, "encoding") { if !encoding.eq_ignore_ascii_case("utf-8") && !encoding.eq_ignore_ascii_case("utf8") { return Err(format!("Unsupported text encoding: {encoding}")); @@ -146,7 +152,7 @@ pub(crate) async fn write_file_content( let request = request_value(args); let path = get_string(request, "filePath")?; let content = get_string(request, "content")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; openbitfun_core::service::filesystem::path_operations::write_text_checked( &state.filesystem_service, &path, @@ -165,7 +171,7 @@ pub(crate) async fn rename_file(state: &PeerHostState, args: &Value) -> Result Result { let request = request_value(args); let path = get_string(request, "path")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; let recursive = request .get("recursive") .and_then(Value::as_bool) @@ -205,11 +211,11 @@ pub(crate) async fn list_directory_files( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let hint = remote_hint(request); + let hint = file_connection(state, request).await?; let extensions = request.get("extensions").and_then(Value::as_array); let nodes = state .filesystem_service - .get_directory_contents_with_remote_hint(&path, hint.as_deref()) + .get_directory_contents_on_connection(&path, hint.as_deref()) .await .map_err(|e| e.to_string())?; let mut files: Vec<_> = nodes diff --git a/src/apps/cli/src/peer_host/commands/git.rs b/src/apps/cli/src/peer_host/commands/git.rs index 98ccbb2b2f..16f3cd7889 100644 --- a/src/apps/cli/src/peer_host/commands/git.rs +++ b/src/apps/cli/src/peer_host/commands/git.rs @@ -4,11 +4,38 @@ use serde_json::{json, Value}; use openbitfun_core::service::git::GitService; -use crate::peer_host::args::{get_string, request_value}; +use crate::peer_host::args::{optional_string, request_value}; + +async fn repository_path_for_workspace(request: &Value) -> Result { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let path = optional_string(request, "repositoryPath").unwrap_or_default(); + let workspace = match optional_string(request, "workspaceId") { + Some(id) => service + .require_workspace(&id) + .await + .map_err(|e| e.to_string())?, + None => service + .resolve_legacy_workspace_reference(None, &path, None, None) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy Git workspace cannot be resolved; select a workspace by ID")?, + }; + if workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote { + return Err("CLI peer Git inspection does not support remote workspaces".into()); + } + Ok( + if optional_string(request, "workspaceId").is_some() || path.is_empty() { + workspace.root_path.to_string_lossy().into_owned() + } else { + path + }, + ) +} pub(crate) async fn git_is_repository(args: &Value) -> Result { let request = request_value(args); - let repository_path = get_string(request, "repositoryPath")?; + let repository_path = repository_path_for_workspace(request).await?; let is_repo = GitService::is_repository(&repository_path) .await .map_err(|e| { @@ -25,7 +52,7 @@ pub(crate) async fn git_is_repository(args: &Value) -> Result { /// only gets the diagnosis and the manual command. pub(crate) async fn git_get_repository_trust(args: &Value) -> Result { let request = request_value(args); - let repository_path = get_string(request, "repositoryPath")?; + let repository_path = repository_path_for_workspace(request).await?; let report = GitService::inspect_trust(&repository_path) .await .map_err(|e| { diff --git a/src/apps/cli/src/peer_host/commands/permission.rs b/src/apps/cli/src/peer_host/commands/permission.rs index 2c6d064980..c90c736a0d 100644 --- a/src/apps/cli/src/peer_host/commands/permission.rs +++ b/src/apps/cli/src/peer_host/commands/permission.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use openbitfun_agent_runtime::sdk::{PermissionGrantKey, PermissionReply}; -use openbitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; +use openbitfun_core::agentic::workspace::WorkspaceBinding; use openbitfun_core::service::workspace::WorkspaceKind; use crate::peer_host::args::{get_string, request_value}; @@ -50,20 +50,14 @@ async fn permission_project_id_for_workspace( .await .ok_or_else(|| format!("Workspace not found: {workspace_id}"))?; let is_remote = workspace.workspace_kind == WorkspaceKind::Remote; - let connection_id = workspace - .metadata - .get("connectionId") - .and_then(Value::as_str); - let ssh_host = workspace.metadata.get("sshHost").and_then(Value::as_str); - let identity = resolve_workspace_session_identity( - &workspace.root_path.to_string_lossy(), - connection_id, - ssh_host, - ) - .await - .ok_or_else(|| format!("Workspace identity is unavailable: {workspace_id}"))?; + // The record already names the workspace; its persistence identity is a + // projection of that record, never a path lookup. + let binding = WorkspaceBinding::resolve(&workspace.id) + .await + .map_err(|error| format!("Workspace identity is unavailable: {workspace_id}: {error}"))?; openbitfun_core::agentic::tools::pipeline::permission_project_id_for_workspace_identity( - &identity, is_remote, + &binding.session_identity, + is_remote, ) .map_err(|error| error.to_string()) } @@ -259,7 +253,9 @@ pub(crate) async fn session_permission_mode( if active_turn_only && turn_id.is_none() { return Err("turn_id is required".into()); } - if optional_string(request, "workspacePath").is_some() { + if optional_string(request, "workspaceId").is_some() + || optional_string(request, "workspacePath").is_some() + { super::session::ensure_coordinator_session(state, args).await?; } let manager = &state.compatibility; diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index 5d71ba893e..57d7af74ba 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -25,9 +25,45 @@ use crate::diagnostics::{OUTCOME_UNKNOWN_ERROR_CODE, SESSION_IN_USE_ERROR_CODE}; use crate::peer_host::args::{get_string, optional_bool, optional_string, request_value}; use crate::peer_host::state::PeerHostState; -use super::snapshot::{local_snapshot_session_stats, require_local_snapshot_workspace}; +use super::snapshot::{ + local_snapshot_session_stats, require_local_snapshot_workspace, resolve_snapshot_workspace, +}; -fn session_storage_request(request: &Value) -> Result { +async fn session_storage_request( + state: &PeerHostState, + request: &Value, +) -> Result { + if let Some(id) = optional_string(request, "workspaceId") { + let workspace = state + .workspace_service + .require_workspace(&id) + .await + .map_err(|error| error.to_string())?; + let remote = + workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; + return Ok(SessionStoragePathRequest { + workspace_path: workspace.root_path.clone(), + remote_connection_id: if remote { + Some( + workspace + .remote_ssh_connection_id() + .ok_or("Remote workspace is missing its saved SSH connection ID")? + .to_owned(), + ) + } else { + None + }, + remote_ssh_host: if remote { + workspace + .metadata + .get("sshHost") + .and_then(Value::as_str) + .map(str::to_owned) + } else { + None + }, + }); + } let workspace_path = get_string(request, "workspacePath")?; let workspace_path = workspace_path.trim(); if workspace_path.is_empty() { @@ -112,11 +148,29 @@ pub(crate) fn load_session_event_backfill( )) } -pub(super) fn ensure_session_workspace_runtime_ownership( +pub(super) async fn ensure_session_workspace_runtime_ownership( state: &PeerHostState, request: &Value, ) -> Result { - let scope = session_storage_request(request)?; + let scope = session_storage_request(state, request).await?; + if optional_string(request, "workspaceId").is_some() { + let coordinator = openbitfun_core::agentic::coordination::get_global_coordinator() + .ok_or("Conversation coordinator is unavailable")?; + if let Some(connection) = scope.remote_connection_id.as_deref() { + coordinator + .ensure_verified_remote_workspace_runtime_ownership( + &scope.workspace_path, + connection, + scope.remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; + } else { + coordinator + .ensure_workspace_runtime_ownership(&scope.workspace_path, None, None) + .map_err(|error| error.to_string())?; + } + return Ok(scope); + } state .compatibility .ensure_workspace_runtime_ownership(&scope) @@ -128,7 +182,15 @@ pub(super) async fn resolved_session_storage_path( state: &PeerHostState, request: &Value, ) -> Result { - resolved_session_storage_scope(state, session_storage_request(request)?).await + if let Some(id) = optional_string(request, "workspaceId") { + use openbitfun_runtime_ports::SessionStorePort; + return openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&id) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| error.to_string()); + } + resolved_session_storage_scope(state, session_storage_request(state, request).await?).await } pub(super) async fn resolved_session_storage_scope( @@ -142,6 +204,17 @@ pub(super) async fn resolved_session_storage_scope( .map_err(|error| format!("Failed to resolve session storage path: {error}")) } +/// Whether the controller named the session's workspace, by ID or by the +/// legacy path projection. Only such requests can (re)load the session. +fn has_session_workspace_scope(request: &Value) -> bool { + ["workspaceId", "workspacePath"].iter().any(|key| { + request + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + }) +} + fn validated_session_id(request: &Value) -> Result { let session_id = get_string(request, "sessionId")?; openbitfun_agent_runtime::session_control::validate_session_id(&session_id)?; @@ -395,7 +468,7 @@ pub(crate) async fn restore_session_view( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = session_storage_request(request)?; + let storage_request = session_storage_request(state, request).await?; let include_internal = request .get("includeInternal") .and_then(|v| v.as_bool()) @@ -457,7 +530,7 @@ pub(crate) async fn restore_session_with_turns( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = session_storage_request(request)?; + let storage_request = session_storage_request(state, request).await?; let include_internal = request .get("includeInternal") .and_then(|v| v.as_bool()) @@ -479,7 +552,7 @@ pub(crate) async fn restore_session_with_turns( pub(crate) async fn restore_session(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = session_storage_request(request)?; + let workspace = resolve_snapshot_workspace(state, request).await?; let include_internal = request .get("includeInternal") .and_then(|v| v.as_bool()) @@ -488,11 +561,12 @@ pub(crate) async fn restore_session(state: &PeerHostState, args: &Value) -> Resu let restored = state .agent_runtime .restore_session(AgentSessionRestoreRequest { - workspace_path: storage_request.workspace_path.to_string_lossy().to_string(), + workspace_id: Some(workspace.id), + workspace_path: String::new(), session_id, include_internal, - remote_connection_id: storage_request.remote_connection_id, - remote_ssh_host: storage_request.remote_ssh_host, + remote_connection_id: None, + remote_ssh_host: None, }) .await .map_err(|error| peer_runtime_session_error("Failed to restore session", error))?; @@ -504,9 +578,17 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul let request = request_value(args); let session_name = get_string(request, "sessionName")?; let agent_type = get_string(request, "agentType")?; - let workspace_path = get_string(request, "workspacePath")?; let session_id = optional_string(request, "sessionId"); - let workspace_id = optional_string(request, "workspaceId"); + let workspace_id = optional_string(request, "workspaceId") + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + // The workspace ID selects the owning workspace; the path is only the + // legacy projection a pre-ID controller sends, so it is required only when + // no ID names the workspace. + let workspace_path = match workspace_id.as_deref() { + Some(_) => optional_string(request, "workspacePath"), + None => Some(get_string(request, "workspacePath")?), + }; let remote_connection_id = optional_string(request, "remoteConnectionId"); let remote_ssh_host = optional_string(request, "remoteSshHost"); @@ -527,7 +609,7 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul session_name, agent_type, agent_route_key: None, - workspace_path: Some(workspace_path), + workspace_path, project_workspace_path: None, execution_target: None, workspace_id, @@ -557,10 +639,11 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul pub(crate) async fn delete_session(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let workspace_path = get_string(request, "workspacePath")?; + let workspace_path = optional_string(request, "workspacePath").unwrap_or_default(); state .agent_runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: optional_string(request, "workspaceId"), workspace_path, session_id, remote_connection_id: optional_string(request, "remoteConnectionId"), @@ -574,13 +657,14 @@ pub(crate) async fn delete_session(state: &PeerHostState, args: &Value) -> Resul pub(crate) async fn rename_session(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = session_storage_request(request)?; + let storage_request = session_storage_request(state, request).await?; let title = get_string(request, "sessionName") .or_else(|_| get_string(request, "title")) .or_else(|_| get_string(request, "name"))?; state .agent_runtime .rename_session(AgentSessionRenameRequest { + workspace_id: optional_string(request, "workspaceId"), workspace_path: storage_request.workspace_path.to_string_lossy().to_string(), session_id, session_name: title, @@ -595,10 +679,11 @@ pub(crate) async fn rename_session(state: &PeerHostState, args: &Value) -> Resul pub(crate) async fn archive_session(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = session_storage_request(request)?; + let storage_request = session_storage_request(state, request).await?; state .agent_runtime .archive_session(AgentSessionArchiveRequest { + workspace_id: optional_string(request, "workspaceId"), workspace_path: storage_request.workspace_path.to_string_lossy().to_string(), session_id, remote_connection_id: storage_request.remote_connection_id, @@ -615,8 +700,8 @@ pub(crate) async fn touch_session_activity( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let scope = ensure_session_workspace_runtime_ownership(state, request)?; - let workspace_path = resolved_session_storage_scope(state, scope).await?; + ensure_session_workspace_runtime_ownership(state, request).await?; + let workspace_path = resolved_session_storage_path(state, request).await?; let _mutation = state .compatibility .begin_persisted_session_mutation(&workspace_path, &session_id) @@ -636,8 +721,8 @@ pub(crate) async fn get_session_thread_goal( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let storage_request = if optional_string(request, "workspacePath").is_some() { - session_storage_request(request)? + let storage_request = if has_session_workspace_scope(request) { + session_storage_request(state, request).await? } else { SessionStoragePathRequest { workspace_path: PathBuf::from("."), @@ -671,11 +756,7 @@ pub(crate) async fn update_session_model( let reasoning_preset = optional_string(request, "reasoningPreset") .map(|preset| preset.trim().to_string()) .filter(|preset| !preset.is_empty()); - if request - .get("workspacePath") - .and_then(Value::as_str) - .is_some_and(|path| !path.trim().is_empty()) - { + if has_session_workspace_scope(request) { ensure_coordinator_session(state, args).await?; } state @@ -699,11 +780,7 @@ pub(crate) async fn update_session_mode( let request = request_value(args); let session_id = validated_session_id(request)?; let mode_id = get_string(request, "modeId")?; - if request - .get("workspacePath") - .and_then(Value::as_str) - .is_some_and(|path| !path.trim().is_empty()) - { + if has_session_workspace_scope(request) { ensure_coordinator_session(state, args).await?; } state @@ -724,7 +801,7 @@ pub(crate) async fn ensure_coordinator_session( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let scope = ensure_session_workspace_runtime_ownership(state, request)?; + ensure_session_workspace_runtime_ownership(state, request).await?; if state .compatibility .is_session_loaded_in_memory(&session_id) @@ -732,7 +809,7 @@ pub(crate) async fn ensure_coordinator_session( { return Ok(Value::Null); } - let storage = resolved_session_storage_scope(state, scope).await?; + let storage = resolved_session_storage_path(state, request).await?; let include_internal = optional_bool(request, "includeInternal").unwrap_or(false); state @@ -748,29 +825,34 @@ pub(crate) async fn get_available_modes( args: &Value, ) -> Result { let request = request_value(args); - let workspace = super::external_sources::workspace_root(state, request) + let workspace_id = super::external_sources::workspace_id(state, request) .await .map_err(|error| error.encode())?; - if let Some(workspace) = workspace.as_deref() { + // Plugin and external-source snapshots are keyed by the workspace ID; the + // record lookup only proves the ID names an open workspace on this host. + if let Some(id) = workspace_id.as_deref() { + state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())?; if let Err(error) = openbitfun_core::plugin_host::ensure_configured_plugin_instance( crate::PLUGIN_HOST_LAUNCH_POLICY, - workspace.to_path_buf(), - workspace.to_path_buf(), - optional_string(request, "workspaceId"), + id, ) .await { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "CLI Peer mode catalog", - Some(workspace), + workspace_id.as_deref(), error, ) .await; } if let Err(error) = - openbitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - workspace, - )) + openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( + workspace_id.as_deref(), + ) .await { tracing::warn!( @@ -779,7 +861,7 @@ pub(crate) async fn get_available_modes( } } let mode_infos = get_agent_registry() - .get_modes_info_for_workspace(workspace.as_deref(), workspace.is_some()) + .get_modes_info_for_workspace(workspace_id.as_deref(), workspace_id.is_some()) .await; let dtos: Vec = mode_infos .into_iter() @@ -814,12 +896,12 @@ pub(crate) async fn get_session_stats( ) -> Result { let request = request_value(args); let session_id = get_string(request, "sessionId")?; - let workspace_path = get_string(request, "workspacePath")?; + let workspace = resolve_snapshot_workspace(state, request).await?; openbitfun_agent_runtime::session_control::validate_session_id(&session_id) .map_err(session_stats_validation_error)?; - require_local_snapshot_workspace(request, &workspace_path).await?; + require_local_snapshot_workspace(&workspace)?; - let scope = ensure_session_workspace_runtime_ownership(state, request)?; + let scope = ensure_session_workspace_runtime_ownership(state, request).await?; let storage_path = resolved_session_storage_scope(state, scope).await?; let read = state .compatibility @@ -829,7 +911,7 @@ pub(crate) async fn get_session_stats( let stats = local_snapshot_session_stats( state.local_workspace_snapshot.as_ref(), - PathBuf::from(&workspace_path), + workspace.id, session_id, read.visible_turn_end(), ) @@ -852,8 +934,8 @@ pub(crate) async fn save_session_turn( args: &Value, ) -> Result { let request = request_value(args); - let scope = ensure_session_workspace_runtime_ownership(state, request)?; - let workspace_path = resolved_session_storage_scope(state, scope).await?; + ensure_session_workspace_runtime_ownership(state, request).await?; + let workspace_path = resolved_session_storage_path(state, request).await?; let turn_data = request .get("turnData") .or_else(|| request.get("turn_data")) diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs index da9f924e75..da28bcbedb 100644 --- a/src/apps/cli/src/peer_host/commands/snapshot.rs +++ b/src/apps/cli/src/peer_host/commands/snapshot.rs @@ -4,9 +4,9 @@ use std::path::PathBuf; use serde_json::{json, Value}; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use openbitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use openbitfun_runtime_ports::{ - AgentSessionRollbackToTurnRequest, AgentSessionWorkspaceLocation, LocalWorkspaceSnapshotPort, + AgentSessionRollbackToTurnRequest, LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, PortError, PortErrorKind, }; @@ -15,34 +15,44 @@ use crate::peer_host::state::PeerHostState; use super::session::{ensure_session_workspace_runtime_ownership, resolved_session_storage_scope}; -pub(super) async fn require_local_snapshot_workspace( +pub(super) async fn resolve_snapshot_workspace( + state: &PeerHostState, request: &Value, - workspace_path: &str, -) -> Result<(), String> { - let is_remote = optional_string(request, "remoteConnectionId").is_some() - || optional_string(request, "remoteSshHost").is_some() - || is_remote_path(workspace_path).await; - if is_remote { +) -> Result { + if let Some(id) = optional_string(request, "workspaceId") { + return state + .workspace_service + .require_workspace(&id) + .await + .map_err(|e| e.to_string()); + } + // Temporary pre-ID protocol adapter; all downstream owners receive IDs. + state + .workspace_service + .resolve_legacy_workspace_reference( + None, + &get_string(request, "workspacePath")?, + optional_string(request, "remoteConnectionId").as_deref(), + optional_string(request, "remoteSshHost").as_deref(), + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Legacy snapshot workspace cannot be resolved".into()) +} + +pub(super) fn require_local_snapshot_workspace(workspace: &WorkspaceInfo) -> Result<(), String> { + if workspace.workspace_kind == WorkspaceKind::Remote { return Err(format!( - "Snapshot system not supported for remote workspace: {workspace_path}" + "Snapshot system not supported for remote workspace: {}", + workspace.id )); } Ok(()) } -async fn require_complete_rollback_workspace( - request: &Value, - workspace_path: &str, - explicit_location: Option, -) -> Result<(), String> { - let is_remote = optional_string(request, "remoteConnectionId").is_some() - || optional_string(request, "remoteSshHost").is_some() - || explicit_location == Some(AgentSessionWorkspaceLocation::Remote) - || (explicit_location.is_none() && is_remote_path(workspace_path).await); - if is_remote { - return Err(format!( - "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}" - )); +fn require_complete_rollback_workspace(workspace: &WorkspaceInfo) -> Result<(), String> { + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(format!("Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {}", workspace.id)); } Ok(()) } @@ -57,12 +67,12 @@ pub(super) fn snapshot_compatibility_error(error: PortError) -> String { pub(super) async fn local_snapshot_session_files( port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, + workspace_id: String, session_id: String, max_turn_exclusive: Option, ) -> Result, String> { port.get_session_files(LocalWorkspaceSnapshotSessionRequest { - workspace_path, + workspace_id, session_id, max_turn_exclusive, }) @@ -77,12 +87,12 @@ pub(super) async fn local_snapshot_session_files( pub(super) async fn local_snapshot_session_stats( port: &dyn LocalWorkspaceSnapshotPort, - workspace_path: PathBuf, + workspace_id: String, session_id: String, max_turn_exclusive: Option, ) -> Result { port.get_session_stats(LocalWorkspaceSnapshotSessionRequest { - workspace_path, + workspace_id, session_id, max_turn_exclusive, }) @@ -101,11 +111,11 @@ pub(crate) async fn get_session_files( ) -> Result { let request = request_value(args); let session_id = get_string(request, "sessionId")?; - let workspace_path = get_string(request, "workspacePath")?; + let workspace = resolve_snapshot_workspace(state, request).await?; openbitfun_agent_runtime::session_control::validate_session_id(&session_id)?; - require_local_snapshot_workspace(request, &workspace_path).await?; - let scope = ensure_session_workspace_runtime_ownership(state, request)?; + require_local_snapshot_workspace(&workspace)?; + let scope = ensure_session_workspace_runtime_ownership(state, request).await?; let storage_path = resolved_session_storage_scope(state, scope).await?; let read = state .compatibility @@ -114,7 +124,7 @@ pub(crate) async fn get_session_files( .map_err(|error| format!("Failed to open a consistent snapshot view: {error}"))?; let files = local_snapshot_session_files( state.local_workspace_snapshot.as_ref(), - PathBuf::from(&workspace_path), + workspace.id, session_id, read.visible_turn_end(), ) @@ -131,18 +141,38 @@ pub(crate) async fn rollback_session_to_turn( args: &Value, ) -> Result { let request = request_value(args); - let rollback_request: AgentSessionRollbackToTurnRequest = + let mut rollback_request: AgentSessionRollbackToTurnRequest = serde_json::from_value(request.clone()) .map_err(|error| format!("Invalid targeted Session rollback request: {error}"))?; openbitfun_agent_runtime::session_control::validate_session_id(&rollback_request.session_id)?; - require_complete_rollback_workspace( - request, - &rollback_request.workspace_path, - rollback_request.explicit_workspace_location(), - ) - .await?; - ensure_session_workspace_runtime_ownership(state, request)?; + let workspace = if let Some(id) = rollback_request.workspace_id.as_deref() { + state + .workspace_service + .require_workspace(id) + .await + .map_err(|e| e.to_string())? + } else { + state + .workspace_service + .resolve_legacy_workspace_reference( + None, + &rollback_request.workspace_path, + rollback_request.remote_connection_id.as_deref(), + rollback_request.remote_ssh_host.as_deref(), + ) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy rollback workspace cannot be resolved")? + }; + require_complete_rollback_workspace(&workspace)?; + rollback_request.remote_connection_id = None; + rollback_request.remote_ssh_host = None; + rollback_request.workspace_id = Some(workspace.id); + rollback_request.workspace_path = workspace.root_path.to_string_lossy().into_owned(); + let resolved_request = serde_json::to_value(&rollback_request).map_err(|e| e.to_string())?; + let request = &resolved_request; + ensure_session_workspace_runtime_ownership(state, request).await?; let outcome = state .agent_runtime .rollback_session_to_turn(rollback_request) @@ -180,7 +210,7 @@ mod tests { #[async_trait::async_trait] impl LocalWorkspaceSnapshotPort for RecordingSnapshotPort { - async fn prepare_local_workspace(&self, _workspace_path: PathBuf) -> PortResult<()> { + async fn prepare_local_workspace(&self, _workspace_id: String) -> PortResult<()> { Ok(()) } @@ -217,77 +247,35 @@ mod tests { } #[tokio::test] - async fn explicit_remote_snapshot_identity_returns_an_honest_unsupported_error() { - for request in [ - json!({ "remoteConnectionId": "remote-1" }), - json!({ "remoteSshHost": "host-1" }), - ] { - let error = require_local_snapshot_workspace(&request, "local-looking-path") - .await - .expect_err("remote snapshot requests must not report no-op success"); - assert_eq!( - error, - "Snapshot system not supported for remote workspace: local-looking-path" - ); - } - - let rollback_error = require_complete_rollback_workspace( - &json!({ "remoteConnectionId": "remote-1" }), - "/root/repos", - None, - ) - .await - .expect_err("complete remote rollback must report missing snapshot coverage"); - assert_eq!( - rollback_error, - "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: /root/repos" - ); - - let source = include_str!("snapshot.rs"); - let rollback_source = &source[source - .find("pub(crate) async fn rollback_session_to_turn") - .expect("rollback handler must exist")..]; - let remote_guard = rollback_source - .find("require_complete_rollback_workspace(") - .expect("rollback must have an explicit remote guard"); - let runtime_call = rollback_source - .find(".rollback_session_to_turn(") - .expect("rollback must delegate to the Agent Session runtime"); - assert!(remote_guard < runtime_call); - } - - #[tokio::test] - async fn explicit_local_rollback_identity_wins_over_a_remote_path_collision() { - let workspace = tempfile::tempdir().expect("create local workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let remote = - openbitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); - remote - .register_remote_workspace( - workspace_path.clone(), - "peer-rollback-path-collision".to_string(), - "Peer rollback collision test".to_string(), - "remote.example".to_string(), + async fn snapshot_kind_is_authoritative_without_transport_inference() { + use openbitfun_core::service::workspace::WorkspaceInfoRuntimeExt; + let directory = tempfile::tempdir().unwrap(); + let mut workspace = + openbitfun_core::service::workspace::WorkspaceInfo::new_without_worktree( + directory.path().to_path_buf(), + Default::default(), ) - .await; - - require_complete_rollback_workspace( - &json!({ "workspaceId": "local_workspace-1" }), - &workspace_path, - Some(openbitfun_runtime_ports::AgentSessionWorkspaceLocation::Local), - ) - .await - .expect("explicit local identity must disambiguate the registered remote path"); - - remote - .unregister_remote_workspace("peer-rollback-path-collision", &workspace_path) - .await; + .await + .unwrap(); + workspace + .metadata + .insert("connectionId".into(), json!("stale-ssh")); + require_local_snapshot_workspace(&workspace).unwrap(); + require_complete_rollback_workspace(&workspace).unwrap(); + workspace.workspace_kind = super::WorkspaceKind::Remote; + workspace.metadata.clear(); + assert!(require_local_snapshot_workspace(&workspace) + .unwrap_err() + .contains("not supported")); + assert!(require_complete_rollback_workspace(&workspace) + .unwrap_err() + .contains("remote file snapshots")); } #[tokio::test] async fn local_snapshot_adapter_calls_each_port_operation_once_with_typed_requests() { let port = RecordingSnapshotPort::default(); - let workspace = PathBuf::from("workspace"); + let workspace = "workspace-id".to_string(); let files = local_snapshot_session_files( &port, @@ -315,7 +303,7 @@ mod tests { .expect("file request lock") .as_ref() .expect("file request") - .workspace_path, + .workspace_id, workspace ); assert_eq!( diff --git a/src/apps/cli/src/peer_host/commands/terminal.rs b/src/apps/cli/src/peer_host/commands/terminal.rs index 8915502fad..981a11c8e8 100644 --- a/src/apps/cli/src/peer_host/commands/terminal.rs +++ b/src/apps/cli/src/peer_host/commands/terminal.rs @@ -19,7 +19,7 @@ fn decode(args: &Value) -> Result { serde_json::from_value(request_value(args).clone()).map_err(|e| e.to_string()) } fn response(session: openbitfun_core::service::remote_ssh::RemoteTerminalSession) -> Value { - json!({"id":session.id,"name":session.name,"cwd":session.cwd,"initialCwd":session.cwd,"shellType":"Remote","status":format!("{:?}",session.status),"cols":session.cols,"rows":session.rows,"connectionId":session.connection_id,"source":"user"}) + json!({"workspaceId":session.workspace_id,"id":session.id,"name":session.name,"cwd":session.cwd,"initialCwd":session.cwd,"shellType":"Remote","status":format!("{:?}",session.status),"cols":session.cols,"rows":session.rows,"connectionId":session.connection_id,"source":"user"}) } async fn remote_session(id: &str) -> Option { let manager = get_remote_workspace_manager()? @@ -32,23 +32,38 @@ pub(crate) async fn create(state: &PeerHostState, args: &Value) -> Result Some( + workspace + .remote_ssh_connection_id() + .ok_or("Remote workspace is missing its saved SSH connection ID")? + .to_owned(), + ), + _ => None, + } } else { - None + // Explicit connection targets are used by the deployment wizard, which + // can open a terminal before any workspace has been registered. + explicit.filter(|id| !id.is_empty()) }; let publisher = state .account_routing @@ -105,6 +120,11 @@ pub(crate) async fn create(state: &PeerHostState, args: &Value) -> Result Result Result Result { - !openbitfun_core::service::remote_ssh::workspace_state::is_remote_path(path).await - } - None => true, + let workspace_id = optional_string(request, "workspaceId"); + let legacy_path = optional_string(request, "workspacePath"); + let workspace = if workspace_id.is_some() || legacy_path.is_some() { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + Some( + service + .resolve_legacy_workspace_reference( + workspace_id.as_deref(), + legacy_path.as_deref().unwrap_or_default(), + None, + None, + ) + .await + .map_err(|error| error.to_string())? + .ok_or("Unknown workspace reference")?, + ) + } else { + None }; - let workspace = workspace.map(std::path::PathBuf::from); + let external_sources_supported = workspace.as_ref().is_none_or(|record| { + record.workspace_kind != openbitfun_core::service::workspace::WorkspaceKind::Remote + }); let mut agents = openbitfun_core::agentic::get_agent_registry() .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: parent.as_deref(), - workspace_root: external_sources_supported - .then_some(workspace.as_deref()) + workspace_id: external_sources_supported + .then_some(workspace.as_ref().map(|record| record.id.as_str())) .flatten(), list_scope: if visible { SubagentListScope::TaskVisible diff --git a/src/apps/cli/src/peer_host/commands/workspace.rs b/src/apps/cli/src/peer_host/commands/workspace.rs index 1b7fbae8c8..07388a423c 100644 --- a/src/apps/cli/src/peer_host/commands/workspace.rs +++ b/src/apps/cli/src/peer_host/commands/workspace.rs @@ -69,6 +69,16 @@ pub(crate) async fn set_active_workspace( pub(crate) async fn open_workspace(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); + if let Some(id) = crate::peer_host::args::optional_string(request, "workspaceId") { + let coordinator = openbitfun_core::agentic::coordination::get_global_coordinator() + .ok_or("Conversation coordinator is unavailable")?; + let info = coordinator + .select_workspace_with_runtime_ownership(&state.workspace_service, &id) + .await + .map_err(|error| error.to_string())?; + return Ok(workspace_info_to_json(&info)); + } + // Explicit new-folder/old-protocol ingress. Existing-workspace selection uses ID. let path = get_string(request, "path")?; state .compatibility @@ -87,7 +97,7 @@ pub(crate) async fn open_workspace(state: &PeerHostState, args: &Value) -> Resul // Best-effort snapshot init for agent tools (mirrors server bootstrap). if let Err(error) = state .local_workspace_snapshot - .prepare_local_workspace(info.root_path.clone()) + .prepare_local_workspace(info.id.clone()) .await { tracing::warn!("Failed to initialize snapshot system: {}", error.message); @@ -107,12 +117,11 @@ pub(crate) async fn open_remote_workspace( let coordinator = openbitfun_core::agentic::coordination::get_global_coordinator() .ok_or("Conversation coordinator is unavailable")?; let info = coordinator - .open_workspace_with_runtime_ownership( + .create_remote_workspace_with_runtime_ownership( &state.workspace_service, - PathBuf::from(path), - Some(&connection_id), + &path, + &connection_id, host.as_deref(), - "peer workspace open", ) .await .map_err(|e| e.to_string())?; diff --git a/src/apps/cli/src/plugin_host_activation.rs b/src/apps/cli/src/plugin_host_activation.rs index 2a115cbed6..4d4eb5f970 100644 --- a/src/apps/cli/src/plugin_host_activation.rs +++ b/src/apps/cli/src/plugin_host_activation.rs @@ -1,28 +1,11 @@ -use std::path::PathBuf; - use openbitfun_core::OpenBitFunResult; use openbitfun_runtime_ports::AgentSessionWorkspaceBinding; -#[derive(Debug, Clone, PartialEq, Eq)] -struct PluginWorkspaceActivationTarget { - directory: PathBuf, - worktree: PathBuf, - project_id: Option, -} - -fn activation_target( - binding: &AgentSessionWorkspaceBinding, -) -> Option { - if binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some() { - return None; +fn activation_target(binding: &AgentSessionWorkspaceBinding) -> Option<&str> { + match binding.workspace_kind.as_ref()? { + openbitfun_core::service::workspace::WorkspaceKind::Remote => None, + _ => binding.workspace_id.as_deref(), } - - let workspace = PathBuf::from(&binding.workspace_path); - Some(PluginWorkspaceActivationTarget { - directory: workspace.clone(), - worktree: workspace, - project_id: binding.workspace_id.clone(), - }) } pub(crate) async fn ensure_configured_plugin_execution_supported() -> OpenBitFunResult { @@ -49,7 +32,7 @@ pub(crate) async fn ensure_plugin_workspace_ready( if let Err(error) = try_ensure_plugin_workspace_ready(binding).await { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "CLI workspace activation", - Some(std::path::Path::new(&binding.workspace_path)), + binding.workspace_id.as_deref(), error, ) .await; @@ -73,9 +56,7 @@ async fn try_ensure_plugin_workspace_ready( openbitfun_core::plugin_host::ensure_configured_plugin_instance( crate::PLUGIN_HOST_LAUNCH_POLICY, - target.directory, - target.worktree, - target.project_id, + target, ) .await .map(|_| ()) @@ -83,12 +64,12 @@ async fn try_ensure_plugin_workspace_ready( #[cfg(test)] mod tests { - use super::{activation_target, PluginWorkspaceActivationTarget}; + use super::activation_target; use openbitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; - use std::path::PathBuf; - fn binding() -> AgentSessionWorkspaceBinding { AgentSessionWorkspaceBinding { + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: None, workspace_id: Some("workspace-1".to_string()), workspace_path: "C:/workspace/project".to_string(), project_workspace_path: Some("C:/workspace/project".to_string()), @@ -100,24 +81,31 @@ mod tests { #[test] fn local_binding_maps_to_plugin_workspace_target() { - assert_eq!( - activation_target(&binding()), - Some(PluginWorkspaceActivationTarget { - directory: PathBuf::from("C:/workspace/project"), - worktree: PathBuf::from("C:/workspace/project"), - project_id: Some("workspace-1".to_string()), - }) - ); + assert_eq!(activation_target(&binding()), Some("workspace-1")); } #[test] fn remote_binding_skips_local_plugin_host() { let mut binding = binding(); - binding.remote_connection_id = Some("remote-1".to_string()); + binding.workspace_kind = Some(openbitfun_core::service::workspace::WorkspaceKind::Remote); assert_eq!(activation_target(&binding), None); } + #[test] + fn local_kind_ignores_stale_ssh_fields() { + let mut binding = binding(); + binding.remote_connection_id = Some("stale-ssh-id".into()); + assert_eq!(activation_target(&binding), Some("workspace-1")); + } + + #[test] + fn unresolved_binding_cannot_activate_plugins() { + let mut binding = binding(); + binding.workspace_kind = None; + assert_eq!(activation_target(&binding), None); + } + #[test] fn cli_enables_configured_plugin_execution_after_core_authorization() { assert_eq!( diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 4a0957749c..bad3c9d32b 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -282,7 +282,8 @@ pub(crate) async fn handle_session_action( match action { SessionAction::List => { - let sessions = list_cli_sessions(runtime.agent_runtime(), &workspace_path).await?; + let sessions = + list_cli_sessions(runtime.agent_runtime(), &runtime.workspace().id).await?; if sessions.is_empty() { println!( @@ -318,12 +319,14 @@ pub(crate) async fn handle_session_action( SessionAction::Show { id } => { let session_id = - resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; + resolve_cli_session_id(runtime.agent_runtime(), &runtime.workspace().id, &id) + .await?; let restored = runtime .agent_runtime() .restore_session(AgentSessionRestoreRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(runtime.workspace().id.clone()), + workspace_path: String::new(), session_id: session_id.clone(), include_internal: false, remote_connection_id: None, @@ -370,7 +373,8 @@ pub(crate) async fn handle_session_action( runtime .agent_runtime() .delete_session(openbitfun_runtime_ports::AgentSessionDeleteRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(runtime.workspace().id.clone()), + workspace_path: String::new(), session_id: id.clone(), remote_connection_id: None, remote_ssh_host: None, @@ -382,23 +386,27 @@ pub(crate) async fn handle_session_action( SessionAction::Resume { id } => { let session_id = - resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; + resolve_cli_session_id(runtime.agent_runtime(), &runtime.workspace().id, &id) + .await?; return Ok(Some((session_id, runtime))); } SessionAction::Continue => { let session_id = - resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, "last").await?; + resolve_cli_session_id(runtime.agent_runtime(), &runtime.workspace().id, "last") + .await?; return Ok(Some((session_id, runtime))); } SessionAction::Fork { id, id_only } => { let session_id = - resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; + resolve_cli_session_id(runtime.agent_runtime(), &runtime.workspace().id, &id) + .await?; let result = runtime .agent_runtime() .fork_session(openbitfun_agent_runtime::sdk::AgentSessionForkRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(runtime.workspace().id.clone()), + workspace_path: String::new(), source_session_id: session_id.clone(), remote_connection_id: None, remote_ssh_host: None, @@ -423,11 +431,11 @@ pub(crate) async fn handle_session_action( async fn resolve_cli_session_id( runtime: &openbitfun_agent_runtime::sdk::AgentRuntime, - workspace_path: &Path, + workspace_id: &str, id: &str, ) -> Result { if id == "last" { - let sessions = list_cli_sessions(runtime, workspace_path).await?; + let sessions = list_cli_sessions(runtime, workspace_id).await?; return sessions .first() .map(|session| session.session_id.clone()) @@ -441,11 +449,12 @@ async fn resolve_cli_session_id( async fn list_cli_sessions( runtime: &openbitfun_agent_runtime::sdk::AgentRuntime, - workspace_path: &Path, + workspace_id: &str, ) -> Result> { runtime .list_sessions(openbitfun_runtime_ports::AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), + workspace_id: Some(workspace_id.to_owned()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) @@ -686,7 +695,11 @@ fn select_external_ecosystem( } async fn resolve_external_ecosystem(requested: Option) -> Result { - let workspace = std::env::current_dir().context("Failed to resolve current workspace")?; + let workspace = crate::create_cli_local_workspace( + &std::env::current_dir().context("Failed to resolve current workspace")?, + ) + .await? + .id; let snapshot = external_source_snapshot(Some(&workspace), false) .await .map_err(external_cli_operation_error)?; @@ -747,7 +760,11 @@ async fn update_external_policy( scope: ExternalPolicyScopeArg, change: ExternalIntegrationPolicyOperation, ) -> Result<()> { - let workspace = std::env::current_dir().context("Failed to resolve current workspace")?; + let workspace = crate::create_cli_local_workspace( + &std::env::current_dir().context("Failed to resolve current workspace")?, + ) + .await? + .id; let snapshot = external_source_snapshot(Some(&workspace), false) .await .map_err(external_cli_operation_error)?; @@ -782,8 +799,11 @@ async fn update_external_policy( async fn handle_external_config_action(action: ExternalConfigAction) -> Result<()> { match action { ExternalConfigAction::Status => { - let workspace = - std::env::current_dir().context("Failed to resolve current workspace")?; + let workspace = crate::create_cli_local_workspace( + &std::env::current_dir().context("Failed to resolve current workspace")?, + ) + .await? + .id; let snapshot = external_source_snapshot(Some(&workspace), false) .await .map_err(external_cli_operation_error)?; @@ -908,6 +928,14 @@ pub(crate) async fn serve_acp_stdio() -> Result<()> { crate::initialize_terminal_service().await; + // The ACP host owns workspace records for the directories its clients + // name by `cwd`; the process cwd is opened first so the fixed runtime + // ownership below and the session records share one workspace identity. + let workspace = crate::create_cli_local_workspace(&workspace_root) + .await + .context("Failed to open the ACP workspace record")?; + tracing::info!(workspace_id = %workspace.id, "ACP workspace record opened"); + let path_manager = openbitfun_core::infrastructure::try_get_path_manager_arc() .map_err(|error| anyhow::anyhow!(error.to_string()))?; let deployment = openbitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded; diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index b9e0ca9459..c3b34e1c4c 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -49,6 +49,7 @@ impl CliProductRuntimeState { #[derive(Clone)] pub(crate) struct CliRuntimeContext { + workspace: openbitfun_core::service::workspace::WorkspaceInfo, workspace_root: PathBuf, agent_runtime: AgentRuntime, local_workspace_snapshot: Arc, @@ -65,12 +66,12 @@ pub(crate) struct CliRuntimeContext { impl CliRuntimeContext { pub(crate) fn build( agentic_system: AgenticSystem, - workspace_root: impl AsRef, + workspace: openbitfun_core::service::workspace::WorkspaceInfo, approval_policy: CliApprovalPolicy, ) -> Result { let scheduler = ensure_product_dialog_scheduler(&agentic_system); let (workspace_root, services) = - build_local_runtime_services(workspace_root, RUNTIME_EVENT_BUFFER)?; + build_local_runtime_services(&workspace.root_path, RUNTIME_EVENT_BUFFER)?; let parts = assemble_cli_runtime_parts(services) .context("Failed to assemble CLI product runtime")?; @@ -99,6 +100,7 @@ impl CliRuntimeContext { let token_usage_service = agentic_system.token_usage_service.clone(); Ok(Self { + workspace, workspace_root, _agent_event_queue_owner: agent_event_queue_owner, agent_runtime, @@ -113,6 +115,10 @@ impl CliRuntimeContext { }) } + pub(crate) fn workspace(&self) -> &openbitfun_core::service::workspace::WorkspaceInfo { + &self.workspace + } + pub(crate) fn workspace_root(&self) -> &Path { &self.workspace_root } diff --git a/src/apps/cli/src/server_host.rs b/src/apps/cli/src/server_host.rs index 4fd0f5f5e8..40f3e94e35 100644 --- a/src/apps/cli/src/server_host.rs +++ b/src/apps/cli/src/server_host.rs @@ -269,6 +269,7 @@ pub(crate) async fn serve() -> Result<()> { .await .context("Failed to initialize global config service")?; tracing::info!("Global config service initialized"); + let workspace = crate::create_cli_local_workspace(&workspace_root).await?; use openbitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() @@ -341,7 +342,7 @@ pub(crate) async fn serve() -> Result<()> { )) .await; - cancel_active_turns(&disconnect_runtime, policy.workspace_root()).await; + cancel_active_turns(&disconnect_runtime, &workspace.id).await; match served { Ok(()) => { @@ -360,11 +361,12 @@ pub(crate) async fn serve() -> Result<()> { /// ends, so a client that disconnected mid-turn cannot leave work running. async fn cancel_active_turns( runtime: &openbitfun_agent_runtime::sdk::AgentRuntime, - workspace_root: &std::path::Path, + workspace_id: &str, ) { use openbitfun_agent_runtime::sdk::{AgentSessionListRequest, AgentTurnCancellationRequest}; let request = AgentSessionListRequest { - workspace_path: workspace_root.to_string_lossy().into_owned(), + workspace_id: Some(workspace_id.to_owned()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }; diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 531848da8d..8523c70337 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -46,6 +46,7 @@ struct SubagentRoute { type SubagentRoutes = Mutex>; pub(crate) struct SharedRuntimeHandler { + workspace_id: Option, runtime: AgentRuntime, compatibility: Option, workspace: PathBuf, @@ -59,20 +60,30 @@ impl SharedRuntimeHandler { pub(crate) fn build( runtime: AgentRuntime, compatibility: CoreAgentRuntimeCompatibility, - workspace: &Path, + workspace: &openbitfun_core::service::workspace::WorkspaceInfo, ) -> Result { - Self::build_optional(runtime, Some(compatibility), workspace) + Self::build_optional( + runtime, + Some(compatibility), + &workspace.root_path, + Some(workspace.id.clone()), + ) } #[cfg(test)] - pub(crate) fn build_for_test(runtime: AgentRuntime, workspace: &Path) -> Result { - Self::build_optional(runtime, None, workspace) + pub(crate) fn build_for_test( + runtime: AgentRuntime, + workspace: &Path, + workspace_id: &str, + ) -> Result { + Self::build_optional(runtime, None, workspace, Some(workspace_id.to_string())) } fn build_optional( runtime: AgentRuntime, compatibility: Option, workspace: &Path, + workspace_id: Option, ) -> Result { let mut agent_events = runtime .subscribe_events() @@ -207,6 +218,7 @@ impl SharedRuntimeHandler { }); Ok(Self { + workspace_id, runtime, compatibility, workspace: dunce::canonicalize(workspace) @@ -242,7 +254,11 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let binding = match session_id { Some(session_id) => self.session_workspace_binding(&session_id).await?, None => AgentSessionWorkspaceBinding { - workspace_id: None, + workspace_kind: Some( + openbitfun_core::service::workspace::WorkspaceKind::Normal, + ), + project_workspace_id: self.workspace_id.clone(), + workspace_id: self.workspace_id.clone(), workspace_path: self.workspace.to_string_lossy().to_string(), project_workspace_path: Some(self.workspace.to_string_lossy().to_string()), execution_target: Some( @@ -260,10 +276,9 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { error ); } - let workspace = PathBuf::from(&binding.workspace_path); if let Err(error) = openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( - Some(&workspace), + binding.workspace_id.as_deref(), ) .await { @@ -275,7 +290,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let modes = self .runtime .list_agent_modes(AgentModeCatalogQuery { - workspace_root: Some(workspace.to_string_lossy().to_string()), + workspace_id: binding.workspace_id.clone(), + workspace_root: None, include_external: true, }) .await @@ -312,6 +328,7 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let restored = self .runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: request.workspace_id, workspace_path: request.workspace_path, session_id: request.session_id, include_internal: false, @@ -355,12 +372,12 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }) } RuntimeIpcOperation::ForkSession { request } => { - let workspace_path = self.workspace.to_string_lossy().into_owned(); let forked = match request.before_turn_id { Some(source_turn_id) => { self.runtime .fork_session_before_turn(AgentSessionForkBeforeTurnRequest { - workspace_path: workspace_path.clone(), + workspace_id: self.workspace_id.clone(), + workspace_path: String::new(), source_session_id: request.session_id, source_turn_id, remote_connection_id: None, @@ -371,7 +388,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { None => { self.runtime .fork_session(AgentSessionForkRequest { - workspace_path: workspace_path.clone(), + workspace_id: self.workspace_id.clone(), + workspace_path: String::new(), source_session_id: request.session_id, remote_connection_id: None, remote_ssh_host: None, @@ -383,7 +401,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let restored = self .runtime .restore_session(AgentSessionRestoreRequest { - workspace_path, + workspace_id: self.workspace_id.clone(), + workspace_path: String::new(), session_id: forked.session_id.clone(), include_internal: false, remote_connection_id: None, @@ -411,7 +430,14 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }) } RuntimeIpcOperation::DeleteSession { session_id } => { - delete_owned_session(&self.runtime, &self.workspace, session_id).await?; + delete_owned_session( + &self.runtime, + self.workspace_id + .as_deref() + .ok_or_else(workspace_mismatch_error)?, + session_id, + ) + .await?; Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::UpdateSessionMode { request } => { @@ -429,7 +455,14 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::RenameSession { request } => { - rename_owned_session(&self.runtime, &self.workspace, request).await?; + rename_owned_session( + &self.runtime, + self.workspace_id + .as_deref() + .ok_or_else(workspace_mismatch_error)?, + request, + ) + .await?; Ok(RuntimeIpcOperationResult::Unit) } RuntimeIpcOperation::ReloadSessionContext { request } => { @@ -708,11 +741,12 @@ fn runtime_session_state( } fn owned_session_rename_request( - workspace: &Path, + workspace_id: &str, request: RuntimeSessionRenameRequest, ) -> AgentSessionRenameRequest { AgentSessionRenameRequest { - workspace_path: workspace.to_string_lossy().to_string(), + workspace_id: Some(workspace_id.to_owned()), + workspace_path: String::new(), session_id: request.session_id, session_name: request.session_name, remote_connection_id: None, @@ -722,23 +756,24 @@ fn owned_session_rename_request( async fn rename_owned_session( runtime: &AgentRuntime, - workspace: &Path, + workspace_id: &str, request: RuntimeSessionRenameRequest, ) -> std::result::Result<(), RuntimeIpcError> { runtime - .rename_session(owned_session_rename_request(workspace, request)) + .rename_session(owned_session_rename_request(workspace_id, request)) .await .map_err(runtime_ipc_error) } async fn delete_owned_session( runtime: &AgentRuntime, - workspace: &Path, + workspace_id: &str, session_id: String, ) -> std::result::Result<(), RuntimeIpcError> { runtime .delete_session(AgentSessionDeleteRequest { - workspace_path: workspace.to_string_lossy().to_string(), + workspace_id: Some(workspace_id.to_owned()), + workspace_path: String::new(), session_id, remote_connection_id: None, remote_ssh_host: None, @@ -850,7 +885,17 @@ impl SharedRuntimeHandler { operation: &RuntimeIpcOperation, ) -> std::result::Result<(), RuntimeIpcError> { let requested = match operation { - RuntimeIpcOperation::ListSessions { request } => Some(request.workspace_path.as_str()), + RuntimeIpcOperation::ListSessions { request } => { + if let Some(id) = request.workspace_id.as_deref() { + return if self.workspace_id.as_deref() == Some(id) { + Ok(()) + } else { + Err(workspace_mismatch_error()) + }; + } + // Pre-ID IPC compatibility; current clients send an ID. + Some(request.workspace_path.as_str()) + } RuntimeIpcOperation::CreateSession { request } => Some( request .workspace_path @@ -858,6 +903,13 @@ impl SharedRuntimeHandler { .ok_or_else(workspace_mismatch_error)?, ), RuntimeIpcOperation::RestoreSession { request } => { + if let Some(id) = request.workspace_id.as_deref() { + return if self.workspace_id.as_deref() == Some(id) { + Ok(()) + } else { + Err(workspace_mismatch_error()) + }; + } Some(request.workspace_path.as_str()) } RuntimeIpcOperation::SubmitTurn { request } => Some( @@ -1096,7 +1148,7 @@ pub(crate) async fn run_service(workspace: PathBuf, expected_identity: String) - let handler = Arc::new(SharedRuntimeHandler::build( runtime.agent_runtime().clone(), runtime.compatibility().clone(), - &workspace, + runtime.workspace(), )?); let server = RuntimeIpcServer::bind_with_handler( &ipc_root()?, @@ -1584,14 +1636,15 @@ mod tests { #[test] fn shared_rename_uses_the_server_workspace_and_no_remote_identity() { let request = owned_session_rename_request( - std::path::Path::new("D:/workspace/project"), + "workspace-1", RuntimeSessionRenameRequest { session_id: "session-1".to_string(), session_name: "Auth refactor".to_string(), }, ); - assert_eq!(request.workspace_path, "D:/workspace/project"); + assert_eq!(request.workspace_id.as_deref(), Some("workspace-1")); + assert!(request.workspace_path.is_empty()); assert_eq!(request.session_id, "session-1"); assert_eq!(request.session_name, "Auth refactor"); assert!(request.remote_connection_id.is_none()); @@ -1610,6 +1663,7 @@ mod tests { .unwrap(); let (available, _) = watch::channel(true); let handler = super::SharedRuntimeHandler { + workspace_id: Some("test-shared-workspace".into()), runtime, compatibility: None, workspace: std::env::temp_dir(), @@ -1667,9 +1721,6 @@ mod tests { #[tokio::test] async fn embedded_and_shared_rename_reach_the_same_runtime_owner() { - let workspace = tempfile::tempdir().expect("workspace"); - let canonical_workspace = dunce::canonicalize(workspace.path()).expect("workspace path"); - let workspace_path = canonical_workspace.to_string_lossy().to_string(); let port = Arc::new(RecordingSessionPort::default()); let runtime = AgentRuntimeBuilder::new() .with_submission_port(port.clone()) @@ -1677,7 +1728,8 @@ mod tests { .build() .expect("runtime"); let expected = AgentSessionRenameRequest { - workspace_path: workspace_path.clone(), + workspace_id: Some("workspace-1".into()), + workspace_path: String::new(), session_id: "session-1".to_string(), session_name: "Auth refactor".to_string(), remote_connection_id: None, @@ -1691,7 +1743,7 @@ mod tests { rename_owned_session( &runtime, - &canonical_workspace, + "workspace-1", RuntimeSessionRenameRequest { session_id: "session-1".to_string(), session_name: "Auth refactor".to_string(), @@ -1708,9 +1760,6 @@ mod tests { #[tokio::test] async fn embedded_and_shared_delete_reach_the_same_runtime_owner() { - let workspace = tempfile::tempdir().expect("workspace"); - let canonical_workspace = dunce::canonicalize(workspace.path()).expect("workspace path"); - let workspace_path = canonical_workspace.to_string_lossy().to_string(); let port = Arc::new(RecordingSessionPort::default()); let runtime = AgentRuntimeBuilder::new() .with_submission_port(port.clone()) @@ -1718,7 +1767,8 @@ mod tests { .build() .expect("runtime"); let expected = AgentSessionDeleteRequest { - workspace_path, + workspace_id: Some("workspace-1".into()), + workspace_path: String::new(), session_id: "session-2".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -1729,7 +1779,7 @@ mod tests { .await .expect("embedded delete"); - delete_owned_session(&runtime, &canonical_workspace, "session-2".to_string()) + delete_owned_session(&runtime, "workspace-1", "session-2".to_string()) .await .expect("shared delete"); diff --git a/src/apps/cli/src/ui/session_lineage_selector.rs b/src/apps/cli/src/ui/session_lineage_selector.rs index 1e84ab7019..6282f2e774 100644 --- a/src/apps/cli/src/ui/session_lineage_selector.rs +++ b/src/apps/cli/src/ui/session_lineage_selector.rs @@ -143,6 +143,7 @@ mod tests { parent_tool_call_id: None, subagent_type: Some("explore".to_string()), agent_id: Some(id.to_string()), + workspace_id: None, workspace_path: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 1b7038c196..d858fb39e9 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1942,7 +1942,13 @@ impl StartupPage { if self.agent.is_remote_workspace() { anyhow::bail!("Skill management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; + let workspace = + openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&workspace_id) + .await?; let values = openbitfun_core::agentic::tools::implementations::skills::get_skill_registry() .get_user_invocable_skills_for_workspace( @@ -1993,10 +1999,19 @@ impl StartupPage { if self.agent.is_remote_workspace() { anyhow::bail!("Skill management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; + let workspace = + openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&workspace_id) + .await?; let values = openbitfun_core::agentic::tools::implementations::skills::get_skill_registry() - .get_mode_skill_infos_for_workspace(Some(&workspace), &self.agent_type) + .get_mode_skill_infos_for_workspace( + openbitfun_core::agentic::tools::implementations::skills::mode_overrides::SkillPolicyWorkspace::from_binding(&workspace), + &self.agent_type, + ) .await; Ok::<_, anyhow::Error>( values @@ -2106,12 +2121,15 @@ impl StartupPage { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; let values = openbitfun_core::agentic::agents::get_agent_registry() .get_subagents_for_query( &openbitfun_core::agentic::agents::SubagentQueryContext { parent_agent_type: Some(&self.agent_type), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace_id), list_scope: openbitfun_core::agentic::agents::SubagentListScope::TaskVisible, include_disabled: false, @@ -2162,11 +2180,11 @@ impl StartupPage { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self.agent.workspace_id().ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; let values = openbitfun_core::agentic::agents::get_agent_registry() .get_subagents_for_query(&openbitfun_core::agentic::agents::SubagentQueryContext { parent_agent_type: Some(&self.agent_type), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace_id), list_scope: openbitfun_core::agentic::agents::SubagentListScope::RegistryManagement, include_disabled: true, @@ -2237,9 +2255,12 @@ impl StartupPage { if self.agent.is_remote_workspace() { anyhow::bail!("Subagent management is unavailable for a Remote workspace") } - let workspace = std::path::PathBuf::from(self.agent.workspace_path_string()); + let workspace_id = self + .agent + .workspace_id() + .ok_or_else(|| anyhow::anyhow!("Workspace ID is unavailable"))?; openbitfun_core::agentic::agents::get_agent_registry() - .update_subagent_override(&mode_id, &subagent.id, enabled, Some(&workspace)) + .update_subagent_override(&mode_id, &subagent.id, enabled, Some(&workspace_id)) .await .map_err(anyhow::Error::msg) }) diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index d1de0d6502..63d11b46d4 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -121,6 +121,10 @@ For side-question request compatibility, initial model selection, and optional message metadata, use `cargo test -p openbitfun-desktop --no-default-features --lib api::btw_api::tests`. +For snapshot workspace identity and remote rollback admission, use +`cargo test --locked -p openbitfun-desktop --lib api::snapshot_service::tests` and +`pnpm --dir src/web-ui run test:run src/infrastructure/api/service-api/SnapshotAPI.test.ts`. + For skill discovery response compatibility and timeouts, use `cargo test -p openbitfun-desktop --lib api::skill_api::tests`. For companion pet manifest versions and package metadata, use diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 6dcafd0277..c2ab7a062a 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -1,7 +1,6 @@ //! ACP client API use crate::api::app_state::AppState; -use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::startup_trace::DesktopStartupTrace; use openbitfun_acp::client::{ AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, @@ -9,10 +8,112 @@ use openbitfun_acp::client::{ SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; +use openbitfun_core::service::workspace::WorkspaceInfo; +use openbitfun_runtime_ports::SessionStorePort; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::time::Instant; use tauri::{AppHandle, Emitter, State}; +/// Workspace facts an ACP session command executes against, derived from the +/// authoritative workspace record rather than from caller-supplied paths. +struct AcpWorkspaceScope { + workspace_id: String, + /// Execution root handed to the ACP client as its cwd (IO operand). + workspace_path: String, + remote_connection_id: Option, + remote_ssh_host: Option, + session_storage_path: PathBuf, +} + +impl AcpWorkspaceScope { + async fn from_workspace(workspace: WorkspaceInfo) -> Result { + let session_storage_path = + openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| error.to_string())?; + let remote_ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|host| host.as_str()) + .map(str::to_string); + Ok(Self { + remote_connection_id: workspace.remote_ssh_connection_id().map(str::to_string), + remote_ssh_host, + workspace_path: workspace.root_path.to_string_lossy().into_owned(), + workspace_id: workspace.id, + session_storage_path, + }) + } +} + +/// Resolve the workspace an ACP request targets. `workspace_id` is +/// authoritative; the path and SSH fields are consulted only for pre-ID +/// clients and are resolved through the workspace legacy-compat boundary. +async fn resolve_acp_workspace_scope( + app_state: &AppState, + workspace_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + let workspace_id = workspace_id.map(str::trim).filter(|id| !id.is_empty()); + let workspace = match workspace_id { + Some(id) => app_state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())?, + None => { + let path = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "workspace_id is required for ACP session commands".to_string())?; + app_state + .workspace_service + .resolve_legacy_workspace_reference( + None, + path, + remote_connection_id, + remote_ssh_host, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Workspace ID is unavailable".to_string())? + } + }; + AcpWorkspaceScope::from_workspace(workspace).await +} + +/// Optional variant for session commands whose pre-ID clients may omit every +/// workspace field; those requests keep the ACP service's in-memory session. +async fn resolve_optional_acp_workspace_scope( + app_state: &AppState, + workspace_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result, String> { + let has_id = workspace_id.map(str::trim).is_some_and(|id| !id.is_empty()); + let has_path = workspace_path + .map(str::trim) + .is_some_and(|path| !path.is_empty()); + if !has_id && !has_path { + return Ok(None); + } + resolve_acp_workspace_scope( + app_state, + workspace_id, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await + .map(Some) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpClientIdRequest { @@ -27,6 +128,11 @@ pub struct CreateAcpFlowSessionRequest { pub client_id: String, #[serde(default)] pub session_name: Option, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, + /// Legacy execution root for pre-ID clients; the record's root is used otherwise. + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -45,6 +151,9 @@ pub struct StartAcpDialogTurnRequest { #[serde(default)] pub original_user_input: Option, pub turn_id: String, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -60,6 +169,9 @@ pub struct StartAcpDialogTurnRequest { pub struct CancelAcpDialogTurnRequest { pub session_id: String, pub client_id: String, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -73,6 +185,9 @@ pub struct CancelAcpDialogTurnRequest { pub struct GetAcpSessionOptionsRequest { pub session_id: String, pub client_id: String, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -207,17 +322,19 @@ pub async fn create_acp_flow_session( .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; - let session_storage_path = desktop_effective_session_storage_path( + let scope = resolve_acp_workspace_scope( &state, - &request.workspace_path, + request.workspace_id.as_deref(), + Some(&request.workspace_path), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), ) - .await; + .await?; let response = service .create_flow_session_record( - &session_storage_path, - &request.workspace_path, + &scope.session_storage_path, + &scope.workspace_id, + &scope.workspace_path, &request.client_id, request.session_name, ) @@ -227,13 +344,13 @@ pub async fn create_acp_flow_session( .start_client_for_session( &request.client_id, &response.session_id, - Some(&request.workspace_path), - request.remote_connection_id.as_deref(), + Some(&scope.workspace_path), + scope.remote_connection_id.as_deref(), ) .await { if let Err(cleanup_error) = service - .delete_flow_session_record(&session_storage_path, &response.session_id) + .delete_flow_session_record(&scope.session_storage_path, &response.session_id) .await { log::warn!( @@ -251,9 +368,10 @@ pub async fn create_acp_flow_session( "sessionId": response.session_id.clone(), "sessionName": response.session_name.clone(), "agentType": response.agent_type.clone(), - "workspacePath": request.workspace_path, - "remoteConnectionId": request.remote_connection_id, - "remoteSshHost": request.remote_ssh_host, + "workspaceId": scope.workspace_id, + "workspacePath": scope.workspace_path, + "remoteConnectionId": scope.remote_connection_id, + "remoteSshHost": scope.remote_ssh_host, }), ); @@ -279,17 +397,21 @@ pub async fn start_acp_dialog_turn( .original_user_input .clone() .unwrap_or_else(|| request.user_input.clone()); - let session_storage_path = match request.workspace_path.as_deref() { - Some(workspace_path) => Some( - desktop_effective_session_storage_path( - &state, - workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await, + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let (workspace_path, remote_connection_id, session_storage_path) = match scope { + Some(scope) => ( + Some(scope.workspace_path), + scope.remote_connection_id, + Some(scope.session_storage_path), ), - None => None, + None => (None, None, None), }; app_handle @@ -313,8 +435,8 @@ pub async fn start_acp_dialog_turn( .prompt_agent_stream( &request.client_id, request.user_input, - request.workspace_path, - request.remote_connection_id, + workspace_path, + remote_connection_id, request.session_id.clone(), session_storage_path, request.timeout_seconds, @@ -580,10 +702,18 @@ pub async fn cancel_acp_dialog_turn( .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; service .cancel_agent_session( &request.client_id, - request.workspace_path, + scope.map(|scope| scope.workspace_path), request.session_id, ) .await @@ -599,23 +729,27 @@ pub async fn get_acp_session_options( .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; - let session_storage_path = match request.workspace_path.as_deref() { - Some(workspace_path) => Some( - desktop_effective_session_storage_path( - &state, - workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await, + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let (workspace_path, remote_connection_id, session_storage_path) = match scope { + Some(scope) => ( + Some(scope.workspace_path), + scope.remote_connection_id, + Some(scope.session_storage_path), ), - None => None, + None => (None, None, None), }; service .get_session_options( &request.client_id, - request.workspace_path, - request.remote_connection_id, + workspace_path, + remote_connection_id, session_storage_path, request.session_id, ) @@ -632,23 +766,27 @@ pub async fn get_acp_session_commands( .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; - let session_storage_path = match request.workspace_path.as_deref() { - Some(workspace_path) => Some( - desktop_effective_session_storage_path( - &state, - workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await, + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let (workspace_path, remote_connection_id, session_storage_path) = match scope { + Some(scope) => ( + Some(scope.workspace_path), + scope.remote_connection_id, + Some(scope.session_storage_path), ), - None => None, + None => (None, None, None), }; service .get_session_commands( &request.client_id, - request.workspace_path, - request.remote_connection_id, + workspace_path, + remote_connection_id, session_storage_path, request.session_id, ) @@ -665,18 +803,20 @@ pub async fn set_acp_session_model( .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; - let session_storage_path = match request.workspace_path.as_deref() { - Some(workspace_path) => Some( - desktop_effective_session_storage_path( - &state, - workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await, - ), - None => None, - }; + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let mut request = request; + let session_storage_path = scope.map(|scope| { + request.workspace_path = Some(scope.workspace_path); + request.remote_connection_id = scope.remote_connection_id; + scope.session_storage_path + }); service .set_session_model(request, session_storage_path) .await @@ -692,18 +832,20 @@ pub async fn set_acp_session_config_option( .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; - let session_storage_path = match request.workspace_path.as_deref() { - Some(workspace_path) => Some( - desktop_effective_session_storage_path( - &state, - workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await, - ), - None => None, - }; + let scope = resolve_optional_acp_workspace_scope( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let mut request = request; + let session_storage_path = scope.map(|scope| { + request.workspace_path = Some(scope.workspace_path); + request.remote_connection_id = scope.remote_connection_id; + scope.session_storage_path + }); service .set_session_config_option(request, session_storage_path) .await diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index e9124c2195..105b5cb4f1 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -9,7 +9,9 @@ use std::time::Instant; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; -use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::api::session_storage_path::{ + desktop_effective_session_storage_path, desktop_session_storage_root, +}; use crate::runtime::{ DesktopRuntimeContext, DesktopSessionApplicationError, DesktopSessionScopeRequest, }; @@ -52,8 +54,6 @@ use openbitfun_core::service::config::project_permission_store::{ deserialize_project_permission_config, project_permission_file_path, project_permission_file_path_for_remote, ProjectPermissionConfig, }; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; -use openbitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use openbitfun_core::service::session::{ DialogTurnData, SessionContextUsage, SessionMemoryMode, SessionMetadata, SessionRelationship, SessionRelationshipKind, SessionTurnCatalog, SessionTurnWindowResponse, @@ -93,12 +93,19 @@ fn worktree_error( }) } +/// Builds the session scope carried by Desktop session commands. The ID is the +/// workspace identity; the path and SSH fields are only consulted for pre-ID +/// clients and are otherwise IO projections. fn desktop_session_scope( + workspace_id: Option, workspace_path: String, remote_connection_id: Option, remote_ssh_host: Option, ) -> DesktopSessionScopeRequest { DesktopSessionScopeRequest { + workspace_id: workspace_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()), workspace_path, remote_connection_id, remote_ssh_host, @@ -236,6 +243,9 @@ pub struct UpdateSessionModelRequest { pub model_name: String, #[serde(default, deserialize_with = "deserialize_present_nullable")] pub reasoning_preset: Option>, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -256,6 +266,9 @@ pub struct UpdateSessionPermissionModeRequest { pub mode: Option, #[serde(default)] pub turn_id: Option, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -275,6 +288,9 @@ pub struct UpdateActiveTurnPermissionModeRequest { /// session mode at the next model-round boundary. #[serde(default)] pub mode: Option, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -308,6 +324,9 @@ where pub struct UpdateSessionModeRequest { pub session_id: String, pub mode_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -323,6 +342,9 @@ pub struct UpdateSessionModeRequest { pub struct UpdateSessionTitleRequest { pub session_id: String, pub title: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -337,6 +359,9 @@ pub struct StartDialogTurnRequest { pub user_input: String, pub original_user_input: Option, pub agent_type: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, /// Concrete execution root retained for backward compatibility and /// non-native transports. pub workspace_path: Option, @@ -366,6 +391,9 @@ pub struct StartDialogTurnResponse { #[serde(rename_all = "camelCase")] pub struct CompactSessionRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -379,6 +407,9 @@ pub struct ActivateSessionGoalRequest { pub session_id: String, #[serde(default)] pub user_hint: Option, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -397,6 +428,9 @@ pub struct ActivateSessionGoalResponse { #[serde(rename_all = "camelCase")] pub struct GetSessionThreadGoalRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -427,6 +461,9 @@ pub struct MemoryPathsResponse { pub struct SetSessionMemoryModeRequest { pub session_id: String, pub mode: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -445,6 +482,9 @@ pub struct SetSessionMemoryModeResponse { #[serde(rename_all = "camelCase")] pub struct ClearSessionThreadGoalRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -457,6 +497,9 @@ pub struct ClearSessionThreadGoalRequest { pub struct SetSessionThreadGoalStatusRequest { pub session_id: String, pub status: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -469,6 +512,9 @@ pub struct SetSessionThreadGoalStatusRequest { pub struct UpdateSessionThreadGoalObjectiveRequest { pub session_id: String, pub objective: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -480,6 +526,9 @@ pub struct UpdateSessionThreadGoalObjectiveRequest { #[serde(rename_all = "camelCase")] pub struct EnsureCoordinatorSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -493,6 +542,10 @@ pub struct EnsureCoordinatorSessionRequest { #[serde(rename_all = "camelCase")] pub struct EnsureAssistantBootstrapRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, } @@ -500,6 +553,9 @@ pub struct EnsureAssistantBootstrapRequest { #[serde(rename_all = "camelCase")] pub struct RunInitAgentsMdRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -866,6 +922,9 @@ pub struct RecoverInterruptedDialogTurnRequest { pub session_id: String, pub dialog_turn_id: String, pub execution_generation: u32, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -970,6 +1029,9 @@ pub struct CancelToolRequest { #[serde(rename_all = "camelCase")] pub struct DeleteSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -981,6 +1043,9 @@ pub struct DeleteSessionRequest { #[serde(rename_all = "camelCase")] pub struct RestoreSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -994,10 +1059,25 @@ pub struct RestoreSessionRequest { pub tail_turn_count: Option, } +impl RestoreSessionRequest { + fn scope(&self) -> DesktopSessionScopeRequest { + DesktopSessionScopeRequest { + workspace_id: self.workspace_id.clone(), + workspace_path: self.workspace_path.clone(), + remote_connection_id: self.remote_connection_id.clone(), + remote_ssh_host: self.remote_ssh_host.clone(), + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LoadSessionTurnWindowRequest { pub session_id: String, + /// Owning workspace ID; `workspacePath` and SSH fields are an upgrade-only projection. + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default)] pub include_internal: bool, @@ -1019,6 +1099,9 @@ pub struct LoadSessionTurnWindowRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListSessionsRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -1105,23 +1188,14 @@ async fn permission_project_id_for_workspace( .await .ok_or_else(|| format!("Workspace not found: {workspace_id}"))?; let remote = workspace.workspace_kind == WorkspaceKind::Remote; - let connection_id = workspace - .metadata - .get("connectionId") - .and_then(|value| value.as_str()); - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()); - let identity = resolve_workspace_session_identity( - &workspace.root_path.to_string_lossy(), - connection_id, - ssh_host, - ) - .await - .ok_or_else(|| format!("Workspace identity is unavailable: {workspace_id}"))?; + // The record already names the workspace; its persistence identity is a + // projection of that record, never a path lookup. + let binding = openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&workspace.id) + .await + .map_err(|error| format!("Workspace identity is unavailable: {workspace_id}: {error}"))?; openbitfun_core::agentic::tools::pipeline::permission_project_id_for_workspace_identity( - &identity, remote, + &binding.session_identity, + remote, ) .map_err(|error| error.to_string()) } @@ -1518,23 +1592,70 @@ pub async fn create_session( .as_ref() .and_then(|c| norm_conn(c.remote_connection_id.clone())) }); - let remote_ssh_host = norm_conn(request.remote_ssh_host.clone()).or_else(|| { + let mut remote_ssh_host = norm_conn(request.remote_ssh_host.clone()).or_else(|| { request .config .as_ref() .and_then(|c| norm_conn(c.remote_ssh_host.clone())) }); - if remote_conn.is_some() { + // The source workspace record is the identity authority for this command. + // ID-aware clients send `workspaceId`; pre-ID clients are upgraded once + // through the legacy adapter. Remote-ness comes from `workspace_kind`, never + // from whether the request happened to carry an SSH connection. + let source_workspace = if let Some(id) = norm_conn(request.workspace_id.clone()) { + app_state + .workspace_service + .require_workspace(&id) + .await + .map_err(|error| error.to_string())? + } else { + app_state + .workspace_service + .resolve_legacy_workspace_reference( + None, + &request.workspace_path, + remote_conn.as_deref(), + remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!( + "Workspace ID is unavailable for session creation: {}", + request.workspace_path + ) + })? + }; + request.workspace_id = Some(source_workspace.id.clone()); + let source_is_remote = source_workspace.workspace_kind == WorkspaceKind::Remote; + let remote_conn = if source_is_remote { + source_workspace + .remote_ssh_connection_id() + .map(str::to_owned) + .or(remote_conn) + } else { + None + }; + if source_is_remote { + remote_ssh_host = source_workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::to_owned) + .or(remote_ssh_host); runtime .session_application() .ensure_workspace_runtime_ownership(desktop_session_scope( + request.workspace_id.clone(), request.workspace_path.clone(), remote_conn.clone(), remote_ssh_host.clone(), )) .await .map_err(|error| error.to_string())?; + } else { + remote_ssh_host = None; } let source_workspace_path = request.workspace_path.clone(); @@ -1569,7 +1690,7 @@ pub async fn create_session( base_ref, copy_local_changes, } => { - if remote_conn.is_some() { + if source_is_remote { return Err(worktree_error( WorktreeErrorCode::RemoteUnsupported, "Managed worktrees are not supported for remote SSH workspaces yet", @@ -1581,6 +1702,7 @@ pub async fn create_session( .request_id .clone() .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + project_workspace_id: None, project_workspace_path: project_workspace_path.clone(), source_workspace_path: Some(source_workspace_path.clone()), base_ref, @@ -1599,7 +1721,7 @@ pub async fn create_session( result.execution_target } SessionExecutionTargetRequest::ExistingWorktree { worktree_id } => { - if remote_conn.is_some() { + if source_is_remote { return Err(worktree_error( WorktreeErrorCode::RemoteUnsupported, "Managed worktrees are not supported for remote SSH workspaces yet", @@ -1607,6 +1729,7 @@ pub async fn create_session( )); } let worktree = WorktreeService::list(WorktreeListRequest { + project_workspace_id: None, project_workspace_path: project_workspace_path.clone(), }) .await @@ -1641,6 +1764,12 @@ pub async fn create_session( } }; request.project_workspace_path = Some(project_workspace_path.clone()); + // The project (storage) workspace is the source record's owning project: + // itself for a main checkout, or `main_workspace_id` for a linked worktree. + let project_workspace_id = source_workspace + .project_workspace_id() + .ok() + .map(str::to_owned); let tracked_worktree_workspace_id = if resolved_execution_target.kind != SessionExecutionTargetKind::Local @@ -1686,19 +1815,17 @@ pub async fn create_session( if let Err(error) = runtime .session_application() - .ensure_configured_plugin_instance( - desktop_session_scope( - request.workspace_path.clone(), - remote_conn.clone(), - remote_ssh_host.clone(), - ), + .ensure_configured_plugin_instance(desktop_session_scope( request.workspace_id.clone(), - ) + request.workspace_path.clone(), + remote_conn.clone(), + remote_ssh_host.clone(), + )) .await { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "Desktop session creation", - Some(std::path::Path::new(&resolved_execution_target.root_path)), + request.workspace_id.as_deref(), error, ) .await; @@ -1709,13 +1836,14 @@ pub async fn create_session( .session_id .as_deref() .ok_or_else(|| "Idempotent worktree session requires a session ID".to_string())?; - let effective_path = desktop_effective_session_storage_path( - &app_state, - &project_workspace_path, + let effective_path = desktop_session_storage_root( + &coordinator, + project_workspace_id.as_deref(), + Some(&project_workspace_path), remote_conn.as_deref(), remote_ssh_host.as_deref(), ) - .await; + .await?; let existing = coordinator .get_session_manager() .load_session_metadata(&effective_path, session_id) @@ -1743,13 +1871,14 @@ pub async fn create_session( .session_id .as_deref() .ok_or_else(|| "Idempotent Review session requires a session ID".to_string())?; - let effective_path = desktop_effective_session_storage_path( - &app_state, - &project_workspace_path, + let effective_path = desktop_session_storage_root( + &coordinator, + project_workspace_id.as_deref(), + Some(&project_workspace_path), remote_conn.as_deref(), remote_ssh_host.as_deref(), ) - .await; + .await?; let existing = coordinator .get_session_manager() .load_session_metadata(&effective_path, session_id) @@ -1774,12 +1903,18 @@ pub async fn create_session( repaired = true; } if repaired { - coordinator - .ensure_workspace_runtime_ownership( - Path::new(&project_workspace_path), - remote_conn.as_deref(), - remote_ssh_host.as_deref(), - ) + // Repairing persisted metadata is a session mutation; it must + // hold runtime ownership of the project workspace, resolved by + // record ID first like every other desktop mutation. + runtime + .session_application() + .ensure_workspace_runtime_ownership(desktop_session_scope( + project_workspace_id.clone(), + project_workspace_path.clone(), + remote_conn.clone(), + remote_ssh_host.clone(), + )) + .await .map_err(|error| error.to_string())?; let relationship = request.relationship.clone(); let deep_review_run_manifest = request.deep_review_run_manifest.clone(); @@ -1940,6 +2075,7 @@ pub async fn update_session_mode( ensure_session_loaded_for_selector_update( runtime.inner(), &session_id, + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -1969,6 +2105,7 @@ pub async fn update_session_model( ensure_session_loaded_for_selector_update( runtime.inner(), &session_id, + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -2033,6 +2170,7 @@ pub async fn update_session_permission_mode( ensure_session_loaded_for_selector_update( runtime.inner(), &session_id, + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -2104,6 +2242,7 @@ pub async fn update_active_turn_permission_mode( ensure_session_loaded_for_selector_update( runtime.inner(), &session_id, + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -2161,6 +2300,7 @@ pub async fn get_session_permission_mode( ensure_session_loaded_for_selector_update( runtime.inner(), &session_id, + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -2197,23 +2337,27 @@ pub async fn get_session_permission_mode( async fn ensure_session_loaded_for_selector_update( runtime: &DesktopRuntimeContext, session_id: &str, + workspace_id: Option, workspace_path: Option, remote_connection_id: Option, remote_ssh_host: Option, include_internal: bool, ) -> Result<(), String> { - let Some(workspace_path) = workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - else { + let workspace_id = workspace_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + let workspace_path = workspace_path + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()); + if workspace_id.is_none() && workspace_path.is_none() { return Ok(()); - }; + } runtime .session_application() .ensure_session_loaded( desktop_session_scope( - workspace_path.to_string(), + workspace_id, + workspace_path.unwrap_or_default(), remote_connection_id, remote_ssh_host, ), @@ -2247,16 +2391,22 @@ pub async fn update_session_title( return Err("session_id is required".to_string()); } - let scope = request + let workspace_id = request + .workspace_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + let workspace_path = request .workspace_path - .filter(|workspace_path| !workspace_path.trim().is_empty()) - .map(|workspace_path| { - desktop_session_scope( - workspace_path, - request.remote_connection_id, - request.remote_ssh_host, - ) - }); + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()); + let scope = (workspace_id.is_some() || workspace_path.is_some()).then(|| { + desktop_session_scope( + workspace_id, + workspace_path.unwrap_or_default(), + request.remote_connection_id, + request.remote_ssh_host, + ) + }); runtime .session_application() .rename_session(scope, session_id.to_string(), request.title) @@ -2291,11 +2441,12 @@ pub async fn ensure_coordinator_session( runtime .session_application() .ensure_session_loaded( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), - ), + DesktopSessionScopeRequest { + workspace_id: request.workspace_id.clone(), + workspace_path: request.workspace_path.clone(), + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: request.remote_ssh_host.clone(), + }, session_id, request.include_internal, ) @@ -2331,6 +2482,7 @@ fn desktop_dialog_turn_request( user_input, original_user_input, agent_type, + workspace_id, workspace_path, project_workspace_path, remote_connection_id, @@ -2360,6 +2512,9 @@ fn desktop_dialog_turn_request( execution, agent_type, workspace_path: project_workspace_path.or(workspace_path), + workspace_id: workspace_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()), remote_connection_id, remote_ssh_host, policy, @@ -2409,7 +2564,6 @@ fn desktop_image_attachment(image: ImageContextData) -> AgentInputAttachment { #[tauri::command] pub async fn compact_session( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, request: CompactSessionRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2422,28 +2576,14 @@ pub async fn compact_session( .get_session(session_id) .is_none() { - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - "workspace_path is required when the session is not loaded".to_string() - })?; - coordinator - .ensure_workspace_runtime_ownership( - Path::new(workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(|error| error.to_string())?; - let effective = desktop_effective_session_storage_path( - &app_state, - workspace_path, + let effective = desktop_session_storage_root( + &coordinator, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), ) - .await; + .await?; coordinator .restore_session_from_storage_path(&effective, session_id) .await @@ -2464,7 +2604,6 @@ pub async fn compact_session( #[tauri::command] pub async fn activate_session_goal( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, request: ActivateSessionGoalRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2477,28 +2616,14 @@ pub async fn activate_session_goal( .get_session(session_id) .is_none() { - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - "workspace_path is required when the session is not loaded".to_string() - })?; - coordinator - .ensure_workspace_runtime_ownership( - Path::new(workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(|error| error.to_string())?; - let effective = desktop_effective_session_storage_path( - &app_state, - workspace_path, + let effective = desktop_session_storage_root( + &coordinator, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), ) - .await; + .await?; coordinator .restore_session_from_storage_path(&effective, session_id) .await @@ -2525,8 +2650,8 @@ pub async fn activate_session_goal( async fn ensure_session_for_thread_goal( coordinator: &Arc, - app_state: &AppState, session_id: &str, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, @@ -2536,26 +2661,14 @@ async fn ensure_session_for_thread_goal( .get_session(session_id) .is_none() { - let workspace_path = workspace_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - "workspace_path is required when the session is not loaded".to_string() - })?; - coordinator - .ensure_workspace_runtime_ownership( - Path::new(workspace_path), - remote_connection_id, - remote_ssh_host, - ) - .map_err(|error| error.to_string())?; - let effective = desktop_effective_session_storage_path( - app_state, + let effective = desktop_session_storage_root( + coordinator, + workspace_id, workspace_path, remote_connection_id, remote_ssh_host, ) - .await; + .await?; coordinator .restore_session_from_storage_path(&effective, session_id) .await @@ -2572,8 +2685,8 @@ async fn ensure_session_for_thread_goal( async fn resolve_thread_goal_storage_path( coordinator: &Arc, - app_state: &AppState, session_id: &str, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, @@ -2595,24 +2708,19 @@ async fn resolve_thread_goal_storage_path( return Ok(PathBuf::from(workspace_path)); } - let workspace_path = workspace_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "workspace_path is required when the session is not loaded".to_string())?; - - Ok(desktop_effective_session_storage_path( - app_state, + desktop_session_storage_root( + coordinator, + workspace_id, workspace_path, remote_connection_id, remote_ssh_host, ) - .await) + .await } #[tauri::command] pub async fn get_session_thread_goal( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: GetSessionThreadGoalRequest, ) -> Result { @@ -2624,8 +2732,8 @@ pub async fn get_session_thread_goal( } let storage_path = resolve_thread_goal_storage_path( coordinator.inner(), - app_state.inner(), session_id, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -2672,7 +2780,6 @@ pub async fn get_memory_paths(state: State<'_, AppState>) -> Result>, - app_state: State<'_, AppState>, request: SetSessionMemoryModeRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2695,27 +2802,13 @@ pub async fn set_session_memory_mode( coordinator .ensure_session_runtime_ownership(session_id, None) .map_err(|error| error.to_string())?; - } else { - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - "workspace_path is required when the session is not loaded".to_string() - })?; - coordinator - .ensure_workspace_runtime_ownership( - Path::new(workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(|error| error.to_string())?; } + // For an unloaded session the storage root resolution below also takes + // runtime ownership of the owning workspace. let storage_path = resolve_thread_goal_storage_path( coordinator.inner(), - app_state.inner(), session_id, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -2737,7 +2830,6 @@ pub async fn set_session_memory_mode( #[tauri::command] pub async fn clear_session_thread_goal( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, request: ClearSessionThreadGoalRequest, ) -> Result<(), String> { let session_id = request.session_id.trim(); @@ -2746,8 +2838,8 @@ pub async fn clear_session_thread_goal( } let workspace_path = ensure_session_for_thread_goal( coordinator.inner(), - app_state.inner(), session_id, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -2762,7 +2854,6 @@ pub async fn clear_session_thread_goal( #[tauri::command] pub async fn set_session_thread_goal_status( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, request: SetSessionThreadGoalStatusRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2780,8 +2871,8 @@ pub async fn set_session_thread_goal_status( }; let workspace_path = ensure_session_for_thread_goal( coordinator.inner(), - app_state.inner(), session_id, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -2796,7 +2887,6 @@ pub async fn set_session_thread_goal_status( #[tauri::command] pub async fn update_session_thread_goal_objective( coordinator: State<'_, Arc>, - app_state: State<'_, AppState>, request: UpdateSessionThreadGoalObjectiveRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2809,8 +2899,8 @@ pub async fn update_session_thread_goal_objective( } let workspace_path = ensure_session_for_thread_goal( coordinator.inner(), - app_state.inner(), session_id, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -2825,10 +2915,41 @@ pub async fn update_session_thread_goal_objective( #[tauri::command] pub async fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, request: EnsureAssistantBootstrapRequest, ) -> Result { + // The assistant workspace is selected by ID; its root is only the IO + // operand handed to the bootstrap. Pre-ID clients are upgraded once. + let workspace = if let Some(id) = request + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + app_state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())? + } else { + app_state + .workspace_service + .resolve_legacy_workspace_reference(None, &request.workspace_path, None, None) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Assistant workspace ID is unavailable".to_string())? + }; + if workspace.workspace_kind != WorkspaceKind::Assistant { + return Err(format!( + "Workspace {} is not an assistant workspace", + workspace.id + )); + } let outcome = coordinator - .ensure_assistant_bootstrap(request.session_id, request.workspace_path) + .ensure_assistant_bootstrap( + request.session_id, + workspace.root_path.to_string_lossy().into_owned(), + ) .await .map_err(|e| format!("Failed to ensure assistant bootstrap: {}", e))?; @@ -2839,7 +2960,6 @@ pub async fn ensure_assistant_bootstrap( pub async fn run_init_agents_md( coordinator: State<'_, Arc>, scheduler: State<'_, Arc>, - app_state: State<'_, AppState>, request: RunInitAgentsMdRequest, ) -> Result { let session_id = request.session_id.trim(); @@ -2852,40 +2972,32 @@ pub async fn run_init_agents_md( .get_session(session_id) .is_none() { - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - "workspace_path is required when the session is not loaded".to_string() - })?; - coordinator - .ensure_workspace_runtime_ownership( - Path::new(workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(|error| error.to_string())?; - let effective = desktop_effective_session_storage_path( - &app_state, - workspace_path, + let effective = desktop_session_storage_root( + &coordinator, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), ) - .await; + .await?; coordinator .restore_session_from_storage_path(&effective, session_id) .await .map_err(|e| format!("Failed to restore session before running /init: {e}"))?; } - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); + // The session is loaded and bound by ID above; an omitted path locator makes + // the scheduler reuse that binding. Only pre-ID clients still pass a path. + let workspace_path = if request.workspace_id.is_some() { + None + } else { + request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; scheduler .submit_init_agents_md( @@ -3111,6 +3223,7 @@ pub async fn recover_interrupted_dialog_turn( turn_id: request.dialog_turn_id.clone(), execution_generation: request.execution_generation, workspace_path: request.workspace_path, + workspace_id: request.workspace_id, remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, }) @@ -3499,11 +3612,12 @@ pub async fn delete_session( runtime .session_application() .delete_session( - desktop_session_scope( - request.workspace_path, - request.remote_connection_id, - request.remote_ssh_host, - ), + DesktopSessionScopeRequest { + workspace_id: request.workspace_id, + workspace_path: request.workspace_path, + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + }, request.session_id, ) .await @@ -3518,11 +3632,7 @@ pub async fn restore_session( let session = runtime .session_application() .restore_session( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), - ), + request.scope(), &request.session_id, request.include_internal, ) @@ -3552,11 +3662,7 @@ pub async fn restore_session_view( let restored = runtime .session_application() .restore_session_view( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), - ), + request.scope(), &request.session_id, request.include_internal, tail_turn_count, @@ -3696,6 +3802,7 @@ pub async fn load_session_turn_window( .session_application() .load_session_turn_window( desktop_session_scope( + request.workspace_id.clone(), request.workspace_path.clone(), request.remote_connection_id.clone(), request.remote_ssh_host.clone(), @@ -3770,11 +3877,7 @@ pub async fn restore_session_with_turns( let restored = runtime .session_application() .restore_session_with_turns( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), - ), + request.scope(), &request.session_id, request.include_internal, |resolve_storage_path_duration_ms| { @@ -3831,13 +3934,22 @@ pub async fn list_sessions( app_state: State<'_, AppState>, request: ListSessionsRequest, ) -> Result, String> { - let effective_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; + let effective_path = if let Some(id) = request.workspace_id.as_deref() { + use openbitfun_runtime_ports::SessionStorePort; + openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(id) + .await + .map_err(|error| error.to_string())? + .effective_storage_path + } else { + desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await? + }; let summaries = coordinator .list_sessions(&effective_path) .await @@ -3886,29 +3998,42 @@ pub async fn get_available_modes( ) -> Result, String> { let trace_started = Instant::now(); let request = request.unwrap_or_default(); - let workspace_path = request - .workspace_path - .as_deref() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from); - let local_scope = local_mode_catalog_scope(&request, workspace_path.as_deref()).await; - let external_sources_supported = local_scope.is_some(); + let workspace = if request.workspace_id.is_some() || request.workspace_path.is_some() { + state + .workspace_service + .resolve_legacy_workspace_reference( + request.workspace_id.as_deref(), + request.workspace_path.as_deref().unwrap_or_default(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| error.to_string())? + .ok_or("Unknown workspace reference") + .map(Some)? + } else { + None + }; + let external_sources_supported = workspace.as_ref().is_some_and(|record| { + record.workspace_kind != openbitfun_core::service::workspace::WorkspaceKind::Remote + }); + let local_scope = workspace.as_ref().and_then(local_mode_catalog_scope); if let Some(scope) = local_scope { if let Err(error) = runtime .session_application() - .ensure_configured_plugin_instance(scope, None) + .ensure_configured_plugin_instance(scope) .await { openbitfun_core::plugin_host::report_configured_plugin_activation_failure( "Desktop mode catalog", - workspace_path.as_deref(), + workspace.as_ref().map(|record| record.id.as_str()), error, ) .await; } if let Err(error) = openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( - workspace_path.as_deref(), + workspace.as_ref().map(|record| record.id.as_str()), ) .await { @@ -3917,7 +4042,10 @@ pub async fn get_available_modes( } let mode_infos = state .agent_registry - .get_modes_info_for_workspace(workspace_path.as_deref(), external_sources_supported) + .get_modes_info_for_workspace( + workspace.as_ref().map(|record| record.id.as_str()), + external_sources_supported, + ) .await; let dtos: Vec = mode_infos @@ -3952,49 +4080,26 @@ pub async fn get_available_modes( #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetAvailableModesRequest { + pub workspace_id: Option, pub workspace_path: Option, pub remote_connection_id: Option, pub remote_ssh_host: Option, } -async fn local_mode_catalog_scope( - request: &GetAvailableModesRequest, - workspace_path: Option<&Path>, +fn local_mode_catalog_scope( + record: &openbitfun_core::service::workspace::WorkspaceInfo, ) -> Option { - if !mode_catalog_supports_external_sources(request, workspace_path).await { + if record.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote { return None; } - workspace_path.map(|path| { - desktop_session_scope( - path.to_string_lossy().into_owned(), - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), - ) + Some(DesktopSessionScopeRequest { + workspace_id: Some(record.id.clone()), + workspace_path: record.root_path.to_string_lossy().into_owned(), + remote_connection_id: None, + remote_ssh_host: None, }) } -async fn mode_catalog_supports_external_sources( - request: &GetAvailableModesRequest, - workspace_path: Option<&Path>, -) -> bool { - let has_remote_identity = request - .remote_connection_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || request - .remote_ssh_host - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - if has_remote_identity { - return false; - } - - match workspace_path { - Some(path) => path.is_absolute() && !is_remote_path(&path.to_string_lossy()).await, - None => false, - } -} - #[tauri::command] pub async fn get_default_review_team_definition() -> Result { Ok(default_review_team_definition()) @@ -4114,33 +4219,23 @@ mod tests { use serde_json::json; #[tokio::test] - async fn remote_mode_catalog_never_scans_an_absolute_desktop_host_path() { - let desktop_host_path = std::env::current_dir().expect("desktop host working directory"); - assert!(desktop_host_path.is_absolute()); - let request = GetAvailableModesRequest { - workspace_path: Some(desktop_host_path.to_string_lossy().into_owned()), - remote_connection_id: Some("remote-1".to_string()), - remote_ssh_host: Some("build-host".to_string()), + async fn mode_catalog_uses_record_kind_even_when_local_and_remote_paths_are_identical() { + use openbitfun_core::service::workspace::{ + WorkspaceInfo, WorkspaceInfoRuntimeExt, WorkspaceKind, WorkspaceOpenOptions, }; - - assert!(local_mode_catalog_scope(&request, Some(&desktop_host_path)) - .await - .is_none()); - } - - #[tokio::test] - async fn local_mode_catalog_builds_the_plugin_activation_scope() { - let workspace = tempfile::tempdir().expect("workspace"); - let request = GetAvailableModesRequest { - workspace_path: Some(workspace.path().to_string_lossy().into_owned()), - remote_connection_id: None, - remote_ssh_host: None, - }; - - let scope = local_mode_catalog_scope(&request, Some(workspace.path())) - .await - .expect("local mode catalog scope"); - assert_eq!(scope.workspace_path, request.workspace_path.unwrap()); + let folder = tempfile::tempdir().unwrap(); + let local = WorkspaceInfo::new_without_worktree( + folder.path().to_owned(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(); + let scope = local_mode_catalog_scope(&local).unwrap(); + assert_eq!(scope.workspace_id.as_deref(), Some(local.id.as_str())); + let mut remote = local.clone(); + remote.workspace_kind = WorkspaceKind::Remote; + remote.id = "remote-record".into(); + assert!(local_mode_catalog_scope(&remote).is_none()); } #[test] diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index cc115b422c..a2dd3cf1cd 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -71,7 +71,7 @@ pub struct AppState { pub tool_registry: Arc>>, pub workspace_service: Arc, pub workspace_identity_watch_service: Arc, - pub workspace_path: Arc>>, + pub workspace_id: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, pub workspace_search_service: Arc, @@ -229,9 +229,9 @@ impl AppState { restored_workspaces.push(initial_workspace.clone()); } } - let initial_workspace_path = initial_workspace + let initial_workspace_id = initial_workspace .as_ref() - .map(|workspace| workspace.root_path.clone()); + .map(|workspace| workspace.id.clone()); let mut index_budget_roots = workspace_service .get_recent_workspaces() .await @@ -340,7 +340,7 @@ impl AppState { tool_registry, workspace_service, workspace_identity_watch_service, - workspace_path: Arc::new(RwLock::new(initial_workspace_path)), + workspace_id: Arc::new(RwLock::new(initial_workspace_id)), config_service, filesystem_service, workspace_search_service, diff --git a/src/apps/desktop/src/api/canvas_api.rs b/src/apps/desktop/src/api/canvas_api.rs index 7bd1f57aba..2d4743ac81 100644 --- a/src/apps/desktop/src/api/canvas_api.rs +++ b/src/apps/desktop/src/api/canvas_api.rs @@ -1,11 +1,11 @@ //! Canvas artifact Tauri commands. use crate::api::app_state::AppState; -use crate::api::session_storage_path::desktop_effective_session_storage_path; use openbitfun_product_domains::canvas::{ parse_canvas_artifact_ref, CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, CanvasRevision, CanvasSnapshot, CanvasState, CanvasStoragePort, }; +use openbitfun_runtime_ports::SessionStorePort; use openbitfun_services_integrations::canvas::CanvasService; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -19,6 +19,8 @@ pub struct CanvasStateRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, @@ -38,6 +40,8 @@ pub struct SaveCanvasStateRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, @@ -65,6 +69,8 @@ pub struct ReportCanvasRuntimeErrorRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, @@ -80,6 +86,8 @@ pub struct ReportCanvasRuntimeReadyRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, @@ -228,6 +236,7 @@ pub async fn report_canvas_runtime_ready( .map_err(|error| format!("Invalid Canvas artifact reference: {:?}", error))?; let service = canvas_service_for_workspace( &state, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -259,6 +268,7 @@ async fn canvas_service_for_runtime_error_request( ) -> Result { canvas_service_for_workspace( state, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -272,6 +282,7 @@ async fn canvas_service_for_save_request( ) -> Result { canvas_service_for_workspace( state, + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -286,6 +297,7 @@ async fn canvas_service_for_request( ) -> Result { canvas_service_for_workspace( state, + request.workspace_id.as_deref(), workspace_path, request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -295,26 +307,35 @@ async fn canvas_service_for_request( async fn canvas_service_for_workspace( state: &AppState, - workspace_path: Option<&str>, + workspace_id: Option<&str>, + legacy_workspace_path: Option<&str>, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> Result { - let workspace = match workspace_path { - Some(path) if !path.trim().is_empty() => path.trim().to_string(), - _ => state - .workspace_path - .read() + let workspace = if let Some(id) = workspace_id { + state + .workspace_service + .require_workspace(id) .await - .as_ref() - .map(|path| path.to_string_lossy().to_string()) - .ok_or_else(|| "No active workspace is available for Canvas state".to_string())?, + .map_err(|e| e.to_string())? + } else if let Some(path) = legacy_workspace_path.filter(|path| !path.is_empty()) { + state + .workspace_service + .resolve_legacy_workspace_reference(None, path, remote_connection_id, remote_ssh_host) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy Canvas workspace cannot be resolved")? + } else { + // Canvas state is keyed by the owning workspace. Falling through to the + // active workspace would silently store a session's Canvas under the + // wrong workspace, so reject requests that carry neither an ID nor a + // legacy path. + return Err("Canvas requests must identify the owning workspace".to_string()); }; - let sessions_dir = desktop_effective_session_storage_path( - state, - &workspace, - remote_connection_id, - remote_ssh_host, - ) - .await; + let sessions_dir = openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map_err(|e| e.to_string())? + .effective_storage_path; Ok(CanvasService::persistent(sessions_dir)) } diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index cad230f7ba..4ffeb52a05 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -1,17 +1,27 @@ //! Clipboard File API -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use serde::{Deserialize, Serialize}; use std::path::Path; /// Returns the first path in `paths` that belongs to a registered remote workspace. -async fn first_remote_path<'a>(paths: impl Iterator) -> Option { +async fn first_remote_path<'a>( + workspace_id: Option<&str>, + controller_local: bool, + paths: impl Iterator, +) -> Result, String> { for path in paths { - if is_remote_path(path.trim()).await { - return Some(path.to_string()); + if openbitfun_core::service::workspace::remote_io_for_legacy_or_id( + workspace_id, + controller_local, + path.trim(), + ) + .await + .map_err(|e| e.to_string())? + { + return Ok(Some(path.to_string())); } } - None + Ok(None) } #[derive(Debug, Serialize)] @@ -23,6 +33,10 @@ pub struct ClipboardFilesResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasteFilesRequest { + #[serde(default)] + pub controller_local: bool, + #[serde(default)] + pub workspace_id: Option, pub source_paths: Vec, pub target_directory: String, pub is_cut: bool, @@ -317,10 +331,12 @@ pub async fn get_clipboard_files() -> Result { #[tauri::command] pub async fn paste_files(request: PasteFilesRequest) -> Result { if let Some(remote_path) = first_remote_path( + request.workspace_id.as_deref(), + request.controller_local, std::iter::once(request.target_directory.as_str()) .chain(request.source_paths.iter().map(String::as_str)), ) - .await + .await? { return Err(format!( "paste_files cannot copy remote workspace path '{}': the remote file provider has no copy primitive; local filesystem fallback was not attempted", @@ -602,6 +618,8 @@ mod remote_guard_tests { let _ = std::fs::remove_file(&sentinel); let error = paste_files(PasteFilesRequest { + controller_local: false, + workspace_id: None, source_paths: vec![source.to_string_lossy().to_string()], target_directory: format!("{REMOTE_ROOT}/src"), is_cut: true, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index c04b7985cb..350fb95786 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -23,14 +23,10 @@ use openbitfun_core::infrastructure::{ }; use openbitfun_core::service::file_watch; use openbitfun_core::service::remote_ssh::get_remote_workspace_manager; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use openbitfun_core::service::remote_ssh::{ search_remote_file_names, shell_quote_posix, RemoteFileNameSearch, }; -use openbitfun_core::service::workspace::WorkspaceInfoRuntimeExt; -use openbitfun_core::service::workspace::{ - ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, -}; +use openbitfun_core::service::workspace::{ScanOptions, WorkspaceInfo, WorkspaceKind}; use openbitfun_core_types::product_identity::hidden_data_directory; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; @@ -368,6 +364,13 @@ fn serialize_search_response( #[derive(Debug, Deserialize)] pub struct OpenWorkspaceRequest { + /// Explicit folder creation; never resolve its environment through a path. + #[serde(default, rename = "createLocal")] + pub create_local: bool, + #[serde(default, rename = "workspaceId")] + pub workspace_id: Option, + /// New-folder creation or upgrade-only payload from a pre-ID client. + #[serde(default)] pub path: String, /// Optional SSH connection scope for a remote root that is already known to OpenBitFun. Path-only /// callers may omit it; the workspace service then resolves the connection from history. @@ -392,6 +395,10 @@ pub struct CreateAssistantWorkspaceRequest {} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScanWorkspaceInfoRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only: removed after pre-ID clients are no longer supported. + #[serde(default)] pub workspace_path: String, } @@ -469,6 +476,8 @@ pub struct UpdateAppStatusRequest { #[derive(Debug, Deserialize)] pub struct ReadFileContentRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, #[serde(rename = "filePath")] pub file_path: String, pub encoding: Option, @@ -515,7 +524,10 @@ pub struct ListAgentCompanionPetsResponse { #[derive(Debug, Deserialize)] pub struct WriteFileContentRequest { - #[serde(rename = "workspacePath")] + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, + /// Temporary legacy wire field; current clients select by ID. + #[serde(default, rename = "workspacePath")] pub workspace_path: String, #[serde(rename = "filePath")] pub file_path: String, @@ -529,11 +541,18 @@ pub struct WriteFileContentRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResetWorkspacePersonaFilesRequest { - pub workspace_path: String, + /// Owning assistant workspace ID; authoritative when present. + #[serde(default)] + pub workspace_id: Option, + /// Legacy path reference for pre-ID clients; the root is only an IO operand. + #[serde(default)] + pub workspace_path: Option, } #[derive(Debug, Deserialize)] pub struct CheckPathExistsRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -541,6 +560,8 @@ pub struct CheckPathExistsRequest { #[derive(Debug, Deserialize)] pub struct GetFileMetadataRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -548,6 +569,8 @@ pub struct GetFileMetadataRequest { #[derive(Debug, Deserialize)] pub struct GetFileTreeRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, pub max_depth: Option, #[serde(default, rename = "remoteConnectionId")] @@ -556,6 +579,8 @@ pub struct GetFileTreeRequest { #[derive(Debug, Deserialize)] pub struct GetDirectoryChildrenRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -564,6 +589,8 @@ pub struct GetDirectoryChildrenRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetDirectoryChildrenPaginatedRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, pub offset: Option, pub limit: Option, @@ -582,6 +609,10 @@ pub type ExplorerGetChildrenPaginatedRequest = GetDirectoryChildrenPaginatedRequ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchFilesRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input. Current callers send workspaceId. + #[serde(default)] pub root_path: String, pub pattern: String, pub search_content: bool, @@ -604,6 +635,10 @@ pub struct SearchFilesRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchFilenamesRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input. Current callers send workspaceId. + #[serde(default)] pub root_path: String, pub pattern: String, #[serde(default)] @@ -657,6 +692,10 @@ mod search_filenames_request_tests { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchFileContentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input. Current callers send workspaceId. + #[serde(default)] pub root_path: String, pub pattern: String, #[serde(default)] @@ -694,6 +733,8 @@ fn resolve_search_limit(requested: Option, fallback: usize) -> usize { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameFileRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub old_path: String, pub new_path: String, #[serde(default)] @@ -703,12 +744,18 @@ pub struct RenameFileRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportLocalFileRequest { + #[serde(default)] + pub controller_local: bool, + #[serde(default)] + pub workspace_id: Option, pub source_path: String, pub destination_path: String, } #[derive(Debug, Deserialize)] pub struct DeleteFileRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -716,6 +763,8 @@ pub struct DeleteFileRequest { #[derive(Debug, Deserialize)] pub struct DeleteDirectoryRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, pub recursive: Option, #[serde(default, rename = "remoteConnectionId")] @@ -724,6 +773,8 @@ pub struct DeleteDirectoryRequest { #[derive(Debug, Deserialize)] pub struct CreateFileRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -731,6 +782,8 @@ pub struct CreateFileRequest { #[derive(Debug, Deserialize)] pub struct CreateDirectoryRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, @@ -738,6 +791,8 @@ pub struct CreateDirectoryRequest { #[derive(Debug, Deserialize)] pub struct RevealInExplorerRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, } @@ -750,8 +805,7 @@ async fn clear_active_workspace_context( let _ = app; let step_started = Instant::now(); - let previous_workspace_path = state.workspace_path.read().await.clone(); - *state.workspace_path.write().await = None; + let previous_workspace_id = state.workspace_id.write().await.take(); if let Some(trace) = startup_trace { trace.record_elapsed_step( "tauri_command", @@ -760,13 +814,18 @@ async fn clear_active_workspace_context( ); } - if let Some(previous_workspace_path) = previous_workspace_path { + if let Some(previous_workspace_id) = previous_workspace_id { let step_started = Instant::now(); - let root_str = previous_workspace_path.to_string_lossy().to_string(); - if !is_remote_path(root_str.trim()).await { - state - .workspace_search_service - .schedule_repo_release(previous_workspace_path); + if let Some(previous) = state + .workspace_service + .get_workspace(&previous_workspace_id) + .await + { + if previous.workspace_kind != WorkspaceKind::Remote { + state + .workspace_search_service + .schedule_repo_release(previous.root_path); + } } if let Some(trace) = startup_trace { trace.record_elapsed_step( @@ -844,7 +903,7 @@ async fn apply_active_workspace_context( } let step_started = Instant::now(); - *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); + *state.workspace_id.write().await = Some(workspace_info.id.clone()); if let Some(trace) = startup_trace { trace.record_elapsed_step( "tauri_command", @@ -1227,19 +1286,25 @@ pub async fn open_workspace( app: tauri::AppHandle, request: OpenWorkspaceRequest, ) -> Result { - // A remote root does not exist on the controller filesystem, so a plain local open would either - // fail with a misleading "path does not exist" or, on a same-shaped controller path, silently - // open the wrong directory. `open_workspace_resolving_known` restores the SSH scope first and - // falls back to the local open only when no remote workspace owns this path. - match state - .workspace_service - .open_workspace_resolving_known( - request.path.clone().into(), - request.remote_connection_id.as_deref(), - None, - ) - .await - { + let result = if let Some(id) = request.workspace_id.as_deref() { + state.workspace_service.open_workspace_by_id(id).await + } else if request.create_local { + state + .workspace_service + .open_workspace(request.path.into()) + .await + } else { + // Upgrade-only request adapter; normal selection always supplies ID. + state + .workspace_service + .upgrade_legacy_workspace_open( + request.path.into(), + request.remote_connection_id.as_deref(), + None, + ) + .await + }; + match result { Ok(workspace_info) => { apply_active_workspace_context(&state, &app, &workspace_info, None).await; @@ -2052,14 +2117,9 @@ async fn cleanup_invalid_workspaces_impl( format!("{trace_step_prefix}.local_workspace_cleanup"), cleanup_started, ); - let prune_remote_started = Instant::now(); - let remote_removed_count = prune_unrecoverable_remote_workspaces(state).await; - startup_trace.record_elapsed_step( - "tauri_command", - format!("{trace_step_prefix}.remote_workspace_prune"), - prune_remote_started, - ); - let removed_count = local_removed_count + remote_removed_count; + // Missing SSH profiles are recoverable upgrade/offline states. + // Preserve workspace IDs and sessions until explicit user removal. + let removed_count = local_removed_count; let apply_context_started = Instant::now(); if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { @@ -2109,81 +2169,6 @@ async fn cleanup_invalid_workspaces_impl( } } -async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> usize { - let saved_connection_ids: std::collections::HashSet = - match state.get_ssh_manager_async().await { - Ok(manager) => manager - .get_saved_connections() - .await - .into_iter() - .map(|connection| connection.id) - .collect(), - Err(error) => { - warn!( - "Skipping remote workspace cleanup because SSH manager is unavailable: {}", - error - ); - return 0; - } - }; - - let workspaces = state.workspace_service.list_workspace_infos().await; - let mut removed = 0usize; - - for workspace in workspaces { - if workspace.workspace_kind != WorkspaceKind::Remote { - continue; - } - - let connection_id = workspace - .metadata - .get("connectionId") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - - let should_remove = connection_id - .as_deref() - .map(|id| !saved_connection_ids.contains(id)) - .unwrap_or(true); - - if !should_remove { - continue; - } - - if let Some(id) = connection_id.as_deref() { - state - .unregister_remote_workspace_entry(id, &workspace.root_path.to_string_lossy()) - .await; - } - - match state - .workspace_service - .remove_workspace(&workspace.id) - .await - { - Ok(()) => { - removed += 1; - info!( - "Removed unrecoverable remote workspace: workspace_id={}, connection_id={:?}, path={}", - workspace.id, - connection_id, - workspace.root_path.display() - ); - } - Err(error) => { - warn!( - "Failed to remove unrecoverable remote workspace: workspace_id={}, error={}", - workspace.id, error - ); - } - } - } - - removed -} - #[tauri::command] pub async fn get_opened_workspaces( state: State<'_, AppState>, @@ -2206,54 +2191,33 @@ pub async fn scan_workspace_info( state: State<'_, AppState>, request: ScanWorkspaceInfoRequest, ) -> Result, String> { - let workspace_path = std::path::PathBuf::from(&request.workspace_path); - - if let Some(existing_workspace) = state - .workspace_service - .get_workspace_by_path(&workspace_path) - .await - { - return state - .workspace_service - .rescan_workspace(&existing_workspace.id) + let service = &state.workspace_service; + let record = if let Some(id) = request.workspace_id.as_deref() { + service.require_workspace(id).await + } else { + service + .resolve_legacy_workspace_reference(None, &request.workspace_path, None, None) .await - .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) - .map_err(|e| format!("Failed to rescan workspace: {}", e)); - } - - // An unknown remote path cannot be scanned as a normal workspace: that would canonicalize and - // stat the controller filesystem and report a local directory under the remote root's name. - if is_remote_path(request.workspace_path.trim()).await { - return Err(format!( - "scan_workspace_info cannot scan remote workspace path '{}' because it is not an opened workspace; open it through open_remote_workspace first. Local filesystem fallback was not attempted", - request.workspace_path - )); + .and_then(|record| { + record.ok_or_else(|| { + openbitfun_core::OpenBitFunError::service( + "Workspace ID is required for scanning", + ) + }) + }) } - - WorkspaceInfo::new( - workspace_path, - WorkspaceOpenOptions { - scan_options: ScanOptions::default(), - auto_set_current: false, - add_to_recent: false, - workspace_kind: WorkspaceKind::Normal, - assistant_id: None, - display_name: None, - remote_connection_id: None, - remote_ssh_host: None, - stable_workspace_id: None, - }, - ) - .await - .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) - .map_err(|e| format!("Failed to scan workspace info: {}", e)) + .map_err(|error| error.to_string())?; + service + .rescan_workspace(&record.id) + .await + .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) + .map_err(|error| error.to_string()) } async fn ensure_directory_request_path( path: &str, remote_connection_id: Option<&str>, ) -> Result<(), String> { - use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use std::path::Path; if let Some(id) = remote_connection_id.filter(|id| !id.trim().is_empty()) { @@ -2264,9 +2228,6 @@ async fn ensure_directory_request_path( .await?; return Ok(()); } - if remote_connection_id != Some("") && is_remote_path(path).await { - return Ok(()); - } let path_buf = Path::new(path); if !path_buf.exists() { @@ -2313,18 +2274,47 @@ fn directory_nodes_to_json(nodes: Vec) -> Vec { .collect() } +async fn directory_connection( + state: &State<'_, AppState>, + workspace_id: Option<&str>, + path: &str, + legacy_connection: Option<&str>, +) -> Result, String> { + if let Some(id) = workspace_id { + let record = state + .workspace_service + .require_workspace(id) + .await + .map_err(|e| e.to_string())?; + return record + .filesystem_connection_id() + .map(|id| id.map(str::to_owned)); + } + state + .workspace_service + .upgrade_legacy_file_connection(path, None, legacy_connection) + .await + .map_err(|e| e.to_string()) +} + async fn get_file_tree_response( state: &State<'_, AppState>, request: &GetFileTreeRequest, ) -> Result { use std::path::Path; - ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?; - - let preferred = request.remote_connection_id.as_deref(); + let connection = directory_connection( + state, + request.workspace_id.as_deref(), + &request.path, + request.remote_connection_id.as_deref(), + ) + .await?; + let preferred = connection.as_deref(); + ensure_directory_request_path(&request.path, preferred).await?; let filesystem_service = &state.filesystem_service; match filesystem_service - .build_file_tree_with_remote_hint(&request.path, preferred) + .build_file_tree_on_connection(&request.path, preferred) .await { Ok(nodes) => { @@ -2356,12 +2346,18 @@ async fn get_directory_children_response( state: &State<'_, AppState>, request: &GetDirectoryChildrenRequest, ) -> Result { - ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?; - - let preferred = request.remote_connection_id.as_deref(); + let connection = directory_connection( + state, + request.workspace_id.as_deref(), + &request.path, + request.remote_connection_id.as_deref(), + ) + .await?; + let preferred = connection.as_deref(); + ensure_directory_request_path(&request.path, preferred).await?; let filesystem_service = &state.filesystem_service; match filesystem_service - .get_directory_contents_with_remote_hint(&request.path, preferred) + .get_directory_contents_on_connection(&request.path, preferred) .await { Ok(nodes) => Ok(serde_json::json!(directory_nodes_to_json(nodes))), @@ -2379,12 +2375,18 @@ async fn get_directory_children_paginated_response( let offset = request.offset.unwrap_or(0); let limit = request.limit.unwrap_or(100); - ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?; - - let preferred = request.remote_connection_id.as_deref(); + let connection = directory_connection( + state, + request.workspace_id.as_deref(), + &request.path, + request.remote_connection_id.as_deref(), + ) + .await?; + let preferred = connection.as_deref(); + ensure_directory_request_path(&request.path, preferred).await?; let filesystem_service = &state.filesystem_service; match filesystem_service - .get_directory_contents_with_remote_hint(&request.path, preferred) + .get_directory_contents_on_connection(&request.path, preferred) .await { Ok(mut nodes) => { @@ -2478,6 +2480,7 @@ pub async fn read_file_content( &request.file_path, request.encoding.as_deref(), request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -2893,6 +2896,7 @@ pub async fn write_file_content( &state, &request.file_path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await?; use openbitfun_core::service::filesystem::path_operations::{ @@ -2929,31 +2933,59 @@ pub async fn reset_workspace_persona_files( state: State<'_, AppState>, request: ResetWorkspacePersonaFilesRequest, ) -> Result<(), String> { - let workspace_path = std::path::PathBuf::from(&request.workspace_path); + let service = &state.workspace_service; + let workspace_id = request + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let legacy_path = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()); + let workspace = match (workspace_id, legacy_path) { + (Some(id), _) => service + .require_workspace(id) + .await + .map_err(|error| error.to_string())?, + (None, Some(path)) => service + .resolve_legacy_workspace_reference(None, path, None, None) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!( + "Workspace path {} is not an open workspace; select the assistant by its ID", + path + ) + })?, + (None, None) => { + return Err("A workspace ID is required to reset persona files".to_string()); + } + }; - if !state - .workspace_service - .is_assistant_workspace_path(&workspace_path) + if workspace.workspace_kind != WorkspaceKind::Assistant + || !service.is_assistant_workspace_path(&workspace.root_path) { return Err(format!( - "Workspace is not a managed assistant workspace: {}", - request.workspace_path + "Workspace {} is not a managed assistant workspace", + workspace.id )); } - openbitfun_core::service::reset_workspace_persona_files_to_default(&workspace_path) + openbitfun_core::service::reset_workspace_persona_files_to_default(&workspace.root_path) .await .map_err(|e| { error!( - "Failed to reset workspace persona files: path={} error={}", - request.workspace_path, e + "Failed to reset workspace persona files: workspace_id={} error={}", + workspace.id, e ); format!("Failed to reset workspace persona files: {}", e) })?; info!( - "Workspace persona files reset to defaults: path={}", - request.workspace_path + "Workspace persona files reset to defaults: workspace_id={}", + workspace.id ); Ok(()) @@ -2968,6 +3000,7 @@ pub async fn check_path_exists( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -2981,6 +3014,7 @@ pub async fn get_file_metadata( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -2996,6 +3030,7 @@ pub async fn get_file_editor_sync_hash( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await? { @@ -3045,6 +3080,7 @@ pub async fn rename_file( &request.old_path, &request.new_path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -3059,7 +3095,14 @@ pub async fn export_local_file_to_path(request: ExportLocalFileRequest) -> Resul ("source", request.source_path.as_str()), ("destination", request.destination_path.as_str()), ] { - if is_remote_path(path.trim()).await { + if openbitfun_core::service::workspace::remote_io_for_legacy_or_id( + request.workspace_id.as_deref(), + request.controller_local, + path.trim(), + ) + .await + .map_err(|e| e.to_string())? + { return Err(format!( "export_local_file_to_path cannot use remote workspace path '{}' as {}: this command only copies between controller-local paths; local filesystem fallback was not attempted", path, role @@ -3139,6 +3182,7 @@ pub async fn delete_file( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -3154,6 +3198,7 @@ pub async fn delete_directory( &request.path, recursive, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -3167,6 +3212,7 @@ pub async fn create_file( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -3180,6 +3226,7 @@ pub async fn create_directory( &state, &request.path, request.remote_connection_id.as_deref(), + request.workspace_id.as_deref(), ) .await } @@ -4386,6 +4433,8 @@ mod archive_tests { #[derive(Debug, Deserialize)] pub struct ListDirectoryFilesRequest { + #[serde(default, rename = "workspaceId", alias = "workspace_id")] + pub workspace_id: Option, pub path: String, pub extensions: Option>, } @@ -4397,7 +4446,9 @@ pub async fn list_directory_files( ) -> Result, String> { use std::path::Path; - match resolve_desktop_path_target(&state, &request.path, None).await? { + match resolve_desktop_path_target(&state, &request.path, None, request.workspace_id.as_deref()) + .await? + { DesktopPathTarget::Remote { requested_path, entry, @@ -4473,7 +4524,9 @@ pub async fn reveal_in_explorer( state: State<'_, AppState>, request: RevealInExplorerRequest, ) -> Result<(), String> { - let target = resolve_desktop_path_target(&state, &request.path, None).await?; + let target = + resolve_desktop_path_target(&state, &request.path, None, request.workspace_id.as_deref()) + .await?; let path = match target.as_local_path() { Some(path) => path, None => { @@ -4584,9 +4637,23 @@ pub(crate) fn reveal_local_path_in_explorer( #[tauri::command] pub async fn search_files( state: State<'_, AppState>, - request: SearchFilesRequest, + mut request: SearchFilesRequest, ) -> Result { use openbitfun_core::service::filesystem::FileSearchOptions; + let workspace = crate::api::search_api::resolve_search_workspace( + &state, + request.workspace_id.as_deref(), + &request.root_path, + request.remote_connection_id.as_deref(), + ) + .await?; + request.root_path = workspace.root_path.to_string_lossy().into_owned(); + request.remote_connection_id = Some( + workspace + .filesystem_connection_id()? + .unwrap_or("") + .to_owned(), + ); let search_id = request.search_id.clone(); let cancel_flag = register_search(&state, search_id.as_deref()); @@ -4609,12 +4676,12 @@ pub async fn search_files( }; let use_workspace_search = - request.search_content && should_use_workspace_search(&state, &request.root_path).await; + request.search_content && should_use_workspace_search(&state, &workspace).await; let result = if request.search_content { - if is_remote_path(request.root_path.trim()).await { + if workspace.workspace_kind == WorkspaceKind::Remote { if !use_workspace_search { Err( - remote_content_search_refusal(&state, "search_files", &request.root_path) + remote_content_search_refusal(&state, "search_files", &workspace) .await .unwrap_or_else(|| { remote_content_search_refusal_message( @@ -4627,7 +4694,7 @@ pub async fn search_files( } else { search_file_contents_via_workspace_search( &state, - &request.root_path, + &workspace, &request.pattern, request.case_sensitive, request.use_regex, @@ -4660,7 +4727,7 @@ pub async fn search_files( let mut content_outcome = if use_workspace_search { search_file_contents_via_workspace_search( &state, - &request.root_path, + &workspace, &request.pattern, request.case_sensitive, request.use_regex, @@ -4699,11 +4766,7 @@ pub async fn search_files( } } } else { - match crate::api::path_target::resolve_desktop_workspace_path_target( - &state, - &request.root_path, - request.remote_connection_id.as_deref(), - ) + match crate::api::path_target::resolve_desktop_workspace_target(&state, &workspace.id) .await { Ok(DesktopPathTarget::Remote { @@ -4765,9 +4828,23 @@ pub async fn search_files( #[tauri::command] pub async fn search_filenames( state: State<'_, AppState>, - request: SearchFilenamesRequest, + mut request: SearchFilenamesRequest, ) -> Result { use openbitfun_core::service::filesystem::FileSearchOptions; + let workspace = crate::api::search_api::resolve_search_workspace( + &state, + request.workspace_id.as_deref(), + &request.root_path, + request.remote_connection_id.as_deref(), + ) + .await?; + request.root_path = workspace.root_path.to_string_lossy().into_owned(); + request.remote_connection_id = Some( + workspace + .filesystem_connection_id()? + .unwrap_or("") + .to_owned(), + ); let search_id = request.search_id.clone(); let cancel_flag = register_search(&state, search_id.as_deref()); @@ -4782,10 +4859,9 @@ pub async fn search_filenames( include_directories: request.include_directories, }; - let result = match crate::api::path_target::resolve_desktop_workspace_path_target( + let result = match crate::api::path_target::resolve_desktop_workspace_target( &state, - &request.root_path, - request.remote_connection_id.as_deref(), + &workspace.id, ) .await { @@ -4849,12 +4925,20 @@ pub async fn search_filenames( #[tauri::command] pub async fn search_file_contents( state: State<'_, AppState>, - request: SearchFileContentsRequest, + mut request: SearchFileContentsRequest, ) -> Result { use openbitfun_core::service::filesystem::FileSearchOptions; + let workspace = crate::api::search_api::resolve_search_workspace( + &state, + request.workspace_id.as_deref(), + &request.root_path, + None, + ) + .await?; + request.root_path = workspace.root_path.to_string_lossy().into_owned(); if let Some(message) = - remote_content_search_refusal(&state, "search_file_contents", &request.root_path).await + remote_content_search_refusal(&state, "search_file_contents", &workspace).await { error!("Content search refused: {}", message); return Err(message); @@ -4873,10 +4957,10 @@ pub async fn search_file_contents( include_directories: false, }; - let result = if should_use_workspace_search(&state, &request.root_path).await { + let result = if should_use_workspace_search(&state, &workspace).await { search_file_contents_via_workspace_search( &state, - &request.root_path, + &workspace, &request.pattern, request.case_sensitive, request.use_regex, @@ -4924,9 +5008,23 @@ pub async fn search_file_contents( pub async fn start_search_filenames_stream( app_handle: AppHandle, state: State<'_, AppState>, - request: SearchFilenamesRequest, + mut request: SearchFilenamesRequest, ) -> Result { use openbitfun_core::service::filesystem::FileSearchOptions; + let workspace = crate::api::search_api::resolve_search_workspace( + &state, + request.workspace_id.as_deref(), + &request.root_path, + request.remote_connection_id.as_deref(), + ) + .await?; + request.root_path = workspace.root_path.to_string_lossy().into_owned(); + request.remote_connection_id = Some( + workspace + .filesystem_connection_id()? + .unwrap_or("") + .to_owned(), + ); let search_id = ensure_search_id(request.search_id.clone(), "filenames-stream"); let cancel_flag = register_search(&state, Some(&search_id)); @@ -4941,10 +5039,9 @@ pub async fn start_search_filenames_stream( include_directories: request.include_directories, }; - let remote_search_target = match crate::api::path_target::resolve_desktop_workspace_path_target( + let remote_search_target = match crate::api::path_target::resolve_desktop_workspace_target( &state, - &request.root_path, - request.remote_connection_id.as_deref(), + &workspace.id, ) .await { @@ -5070,16 +5167,20 @@ pub async fn start_search_filenames_stream( pub async fn start_search_file_contents_stream( app_handle: AppHandle, state: State<'_, AppState>, - request: SearchFileContentsRequest, + mut request: SearchFileContentsRequest, ) -> Result { use openbitfun_core::service::filesystem::FileSearchOptions; - - if let Some(message) = remote_content_search_refusal( + let workspace = crate::api::search_api::resolve_search_workspace( &state, - "start_search_file_contents_stream", + request.workspace_id.as_deref(), &request.root_path, + None, ) - .await + .await?; + request.root_path = workspace.root_path.to_string_lossy().into_owned(); + + if let Some(message) = + remote_content_search_refusal(&state, "start_search_file_contents_stream", &workspace).await { error!("Content search stream refused: {}", message); return Err(message); @@ -5105,10 +5206,10 @@ pub async fn start_search_file_contents_stream( let case_sensitive = request.case_sensitive; let use_regex = request.use_regex; let whole_word = request.whole_word; - let use_workspace_search = should_use_workspace_search(&state, &root_path).await; + let use_workspace_search = should_use_workspace_search(&state, &workspace).await; let workspace_search_runner = if use_workspace_search { Some( - prepare_content_search_runner(&state, &root_path) + prepare_content_search_runner(&state, &workspace) .await .map_err(|error| format!("Failed to prepare workspace search: {}", error))?, ) @@ -5330,22 +5431,67 @@ pub async fn report_ide_control_result(request: IdeControlResultRequest) -> Resu Ok(()) } -/// The file watcher is a controller-side notify backend; it cannot observe an SSH host. A remote -/// workspace path is refused so callers see the gap instead of a watch on a same-named local path. -#[tauri::command] -pub async fn start_file_watch(path: String, recursive: Option) -> Result<(), String> { - if is_remote_path(path.trim()).await { - return Err(format!( - "start_file_watch cannot watch remote workspace path '{}': filesystem watching is not available over SSH; local filesystem fallback was not attempted", - path - )); +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceFileWatchRequest { + pub workspace_id: String, + pub path: String, + pub recursive: Option, +} + +fn ensure_workspace_watch_supported(kind: WorkspaceKind) -> Result<(), String> { + if kind == WorkspaceKind::Remote { + return Err("Filesystem watching is not available for remote workspaces".into()); } + Ok(()) +} - file_watch::start_file_watch(path, recursive).await +async fn file_watch_target( + state: &State<'_, AppState>, + request: Option, + legacy_path: Option, +) -> Result<(String, Option), String> { + if let Some(request) = request { + let record = state + .workspace_service + .require_workspace(&request.workspace_id) + .await + .map_err(|e| e.to_string())?; + ensure_workspace_watch_supported(record.workspace_kind)?; + return Ok((request.path, request.recursive)); + } + // Temporary 1.0.0 wire ingress; current callers must send the structured ID request. + let path = legacy_path.ok_or("Workspace ID is required for filesystem watching")?; + if state + .workspace_service + .upgrade_legacy_file_connection(&path, None, None) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("Filesystem watching is not available for remote workspaces".into()); + } + Ok((path, None)) +} + +#[tauri::command] +pub async fn start_file_watch( + state: State<'_, AppState>, + request: Option, + path: Option, + recursive: Option, +) -> Result<(), String> { + let (path, scoped_recursive) = file_watch_target(&state, request, path).await?; + file_watch::start_file_watch(path, scoped_recursive.or(recursive)).await } #[tauri::command] -pub async fn stop_file_watch(path: String) -> Result<(), String> { +pub async fn stop_file_watch( + state: State<'_, AppState>, + request: Option, + path: Option, +) -> Result<(), String> { + let (path, _) = file_watch_target(&state, request, path).await?; file_watch::stop_file_watch(path).await } @@ -5463,7 +5609,7 @@ pub async fn refresh_subscription_account( #[cfg(test)] mod remote_guard_tests { use super::{ - export_local_file_to_path, start_file_watch, CheckPathExistsRequest, + ensure_workspace_watch_supported, export_local_file_to_path, CheckPathExistsRequest, ExportLocalFileRequest, GetFileMetadataRequest, OpenWorkspaceRequest, SearchFilesRequest, }; use openbitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager; @@ -5517,6 +5663,8 @@ mod remote_guard_tests { let destination = controller_sentinel("export-source-destination.txt"); let error = export_local_file_to_path(ExportLocalFileRequest { + controller_local: false, + workspace_id: None, source_path: fixture.child("main.rs"), destination_path: destination.to_string_lossy().to_string(), }) @@ -5540,6 +5688,8 @@ mod remote_guard_tests { std::fs::write(&source, b"local bytes").expect("write controller source"); let error = export_local_file_to_path(ExportLocalFileRequest { + controller_local: false, + workspace_id: None, source_path: source.to_string_lossy().to_string(), destination_path: fixture.child("copy.txt"), }) @@ -5553,18 +5703,11 @@ mod remote_guard_tests { fixture.unregister().await; } - #[tokio::test] - async fn start_file_watch_refuses_remote_path() { - let fixture = RemoteRootFixture::register("file-watch").await; - - let error = start_file_watch(fixture.child("src"), Some(true)) - .await - .expect_err("remote watch target must be refused"); - - assert!(error.starts_with("start_file_watch cannot watch remote workspace path")); - assert!(error.contains("local filesystem fallback was not attempted")); - - fixture.unregister().await; + #[test] + fn file_watch_routing_uses_record_kind() { + ensure_workspace_watch_supported(super::WorkspaceKind::Normal).unwrap(); + let error = ensure_workspace_watch_supported(super::WorkspaceKind::Remote).unwrap_err(); + assert!(error.contains("not available for remote workspaces")); } #[test] @@ -5642,3 +5785,27 @@ pub async fn workspace_file_upload( .await?; serde_json::to_value(status).map_err(|error| error.to_string()) } + +#[cfg(test)] +mod file_workspace_id_contract_tests { + #[test] + fn file_requests_accept_ids_and_preserve_pre_id_wire_inputs() { + let write: super::WriteFileContentRequest = serde_json::from_value(serde_json::json!({ + "workspaceId":"workspace-1", "filePath":"/srv/file.txt", "content":"text" + })) + .unwrap(); + assert_eq!(write.workspace_id.as_deref(), Some("workspace-1")); + assert!(write.workspace_path.is_empty()); + let read: super::ReadFileContentRequest = serde_json::from_value(serde_json::json!({ + "workspaceId":"workspace-1", "filePath":"openbitfun-runtime://current/plans/plan.md" + })) + .unwrap(); + assert_eq!(read.workspace_id.as_deref(), Some("workspace-1")); + let legacy: super::WriteFileContentRequest = serde_json::from_value(serde_json::json!({ + "workspacePath":"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/legacy/project", "filePath":"/legacy/project/file.txt", "content":"old", "remoteConnectionId":"ssh-old" + })).unwrap(); + assert!(legacy.workspace_id.is_none()); + assert_eq!(legacy.workspace_path, "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/legacy/project"); + assert_eq!(legacy.remote_connection_id.as_deref(), Some("ssh-old")); + } +} diff --git a/src/apps/desktop/src/api/cron_api.rs b/src/apps/desktop/src/api/cron_api.rs index c2ec9637f9..57c8094d4b 100644 --- a/src/apps/desktop/src/api/cron_api.rs +++ b/src/apps/desktop/src/api/cron_api.rs @@ -46,11 +46,32 @@ pub async fn list_cron_jobs(request: ListCronJobsRequest) -> Result ); let service = cron_service()?; + // Legacy wire ingress only. Filtering below has no path-based API. + let workspace_id = if request.workspace_id.is_some() + || request.workspace_path.is_some() + || request.remote_connection_id.is_some() + { + let workspaces = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + Some( + workspaces + .resolve_legacy_workspace_reference( + request.workspace_id.as_deref(), + request.workspace_path.as_deref().unwrap_or_default(), + request.remote_connection_id.as_deref(), + None, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Scheduled job workspace is unavailable".to_string())? + .id, + ) + } else { + None + }; Ok(service .list_jobs_filtered( - request.workspace_path.as_deref(), - request.workspace_id.as_deref(), - request.remote_connection_id.as_deref(), + workspace_id.as_deref(), request.session_id.as_deref(), request.target_kind, ) diff --git a/src/apps/desktop/src/api/custom_agent_api.rs b/src/apps/desktop/src/api/custom_agent_api.rs index 1c7eab7ea2..11166217ae 100644 --- a/src/apps/desktop/src/api/custom_agent_api.rs +++ b/src/apps/desktop/src/api/custom_agent_api.rs @@ -1,3 +1,4 @@ +use super::subagent_api::local_workspace_from_request; use crate::api::app_state::AppState; use log::{debug, warn}; use openbitfun_core::agentic::agents::{ @@ -7,21 +8,14 @@ use openbitfun_core::agentic::agents::{ }; use serde::Deserialize; use std::collections::HashSet; -use std::path::PathBuf; use tauri::State; const AGENT_ID_REGEX: &str = "^[a-zA-Z][a-zA-Z0-9_-]*$"; -fn workspace_root_from_request(workspace_path: Option<&str>) -> Option { - workspace_path - .filter(|path| !path.is_empty()) - .map(PathBuf::from) -} - fn reject_external_agent_mutation( state: &AppState, agent_id: &str, - workspace: Option<&std::path::Path>, + workspace: Option<&str>, ) -> Result<(), String> { if state .agent_registry @@ -98,12 +92,9 @@ fn ensure_review_tools_are_readonly( )) } -async fn existing_agent_ids(state: &AppState, workspace: Option<&PathBuf>) -> HashSet { +async fn existing_agent_ids(state: &AppState, workspace: Option<&str>) -> HashSet { let modes = state.agent_registry.get_modes_info().await; - let subagents = state - .agent_registry - .get_subagents_info(workspace.map(PathBuf::as_path)) - .await; + let subagents = state.agent_registry.get_subagents_info(workspace).await; modes .iter() .map(|mode| mode.id.to_lowercase()) @@ -115,6 +106,9 @@ async fn existing_agent_ids(state: &AppState, workspace: Option<&PathBuf>) -> Ha #[serde(rename_all = "camelCase")] pub struct GetCustomAgentDetailRequest { pub agent_id: String, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -123,7 +117,12 @@ pub async fn get_custom_agent_detail( state: State<'_, AppState>, request: GetCustomAgentDetailRequest, ) -> Result { - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_agent_mutation(&state, &request.agent_id, workspace.as_deref())?; state .agent_registry @@ -146,6 +145,9 @@ pub struct CreateCustomAgentRequest { pub review: Option, pub model: Option, pub user_context_policy: Option>, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -164,7 +166,12 @@ pub async fn create_custom_agent( return Err("Prompt cannot be empty".to_string()); } - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let level = request.level.unwrap_or(CustomAgentLevel::User); if request.kind == CustomAgentKind::Mode && level == CustomAgentLevel::Project { @@ -179,7 +186,7 @@ pub async fn create_custom_agent( .load_custom_agents(workspace.as_deref()) .await; - let existing_ids = existing_agent_ids(&state, workspace.as_ref()).await; + let existing_ids = existing_agent_ids(&state, workspace.as_deref()).await; if existing_ids.contains(&id.to_lowercase()) { return Err(format!("Id '{}' conflicts with an existing agent", id)); } @@ -188,8 +195,13 @@ pub async fn create_custom_agent( let agents_dir = match level { CustomAgentLevel::User => path_manager.user_agents_dir(), CustomAgentLevel::Project => { - let root = workspace.as_deref().ok_or("Workspace path not available")?; - path_manager.project_agents_dir(root) + let id = workspace.as_deref().ok_or("Workspace ID not available")?; + let record = state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())?; + path_manager.project_agents_dir(&record.root_path) } }; std::fs::create_dir_all(&agents_dir) @@ -300,6 +312,9 @@ pub struct UpdateCustomAgentRequest { pub review: Option, pub model: Option, pub user_context_policy: Option>, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -316,7 +331,12 @@ pub async fn update_custom_agent( return Err("Prompt cannot be empty".to_string()); } - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_agent_mutation(&state, &request.agent_id, workspace.as_deref())?; let current = state .agent_registry @@ -367,6 +387,9 @@ pub async fn update_custom_agent( #[serde(rename_all = "camelCase")] pub struct DeleteCustomAgentRequest { pub agent_id: String, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -376,7 +399,12 @@ pub async fn delete_custom_agent( request: DeleteCustomAgentRequest, ) -> Result<(), String> { let agent_id = request.agent_id; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_agent_mutation(&state, &agent_id, workspace.as_deref())?; if let Some(path) = state @@ -425,6 +453,9 @@ pub async fn delete_custom_agent( #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReloadCustomAgentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -433,7 +464,12 @@ pub async fn reload_custom_agents( state: State<'_, AppState>, request: ReloadCustomAgentsRequest, ) -> Result<(), String> { - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; state .agent_registry .load_custom_agents(workspace.as_deref()) diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 18b3ba0506..f5f973940d 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -48,6 +48,8 @@ pub struct WorkspaceIdentityDto { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceWorktreeInfoDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub main_workspace_id: Option, pub path: String, pub branch: Option, pub main_repo_path: String, @@ -181,6 +183,7 @@ impl WorkspaceWorktreeInfoDto { path: info.path.clone(), branch: info.branch.clone(), main_repo_path: info.main_repo_path.clone(), + main_workspace_id: info.main_workspace_id.clone(), is_main: info.is_main, } } diff --git a/src/apps/desktop/src/api/external_hooks_api.rs b/src/apps/desktop/src/api/external_hooks_api.rs index f1f97c5735..a7c0992d89 100644 --- a/src/apps/desktop/src/api/external_hooks_api.rs +++ b/src/apps/desktop/src/api/external_hooks_api.rs @@ -14,6 +14,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookCatalogRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only reference from pre-ID clients. pub workspace_path: Option, #[serde(default)] pub force_refresh: bool, @@ -24,6 +27,9 @@ pub type ExternalHookCatalogResponse = ExternalHookCatalogSnapshotV1; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookImportSnapshotRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only reference from pre-ID clients. pub workspace_path: Option, #[serde(default)] pub refresh_updates: bool, @@ -32,6 +38,9 @@ pub struct ExternalHookImportSnapshotRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PlanExternalHookImportRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only reference from pre-ID clients. pub workspace_path: Option, pub source: SourceKey, } @@ -39,6 +48,9 @@ pub struct PlanExternalHookImportRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApplyExternalHookImportRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only reference from pre-ID clients. pub workspace_path: Option, pub import_request: ExternalHookImportApplyRequestV1, } @@ -46,6 +58,9 @@ pub struct ApplyExternalHookImportRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct MutateExternalHookImportRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only reference from pre-ID clients. pub workspace_path: Option, pub mutation: ExternalHookImportMutationRequestV1, } @@ -54,9 +69,12 @@ pub struct MutateExternalHookImportRequest { pub async fn get_external_hook_catalog( request: ExternalHookCatalogRequest, ) -> ExternalSourceOperationResult { - let workspace = - super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) - .await?; + let workspace_id = super::external_sources_api::require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); local_external_hook_catalog_snapshot(workspace, request.force_refresh).await } @@ -64,9 +82,12 @@ pub async fn get_external_hook_catalog( pub async fn get_external_hook_import_snapshot( request: ExternalHookImportSnapshotRequest, ) -> ExternalSourceOperationResult { - let workspace = - super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) - .await?; + let workspace_id = super::external_sources_api::require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); openbitfun_core::external_hook_import::external_hook_import_snapshot( workspace, request.refresh_updates, @@ -78,9 +99,12 @@ pub async fn get_external_hook_import_snapshot( pub async fn plan_external_hook_import_command( request: PlanExternalHookImportRequest, ) -> ExternalSourceOperationResult { - let workspace = - super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) - .await?; + let workspace_id = super::external_sources_api::require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); openbitfun_core::external_hook_import::plan_external_hook_import(workspace, request.source) .await } @@ -89,9 +113,12 @@ pub async fn plan_external_hook_import_command( pub async fn apply_external_hook_import_command( request: ApplyExternalHookImportRequest, ) -> ExternalSourceOperationResult { - let workspace = - super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) - .await?; + let workspace_id = super::external_sources_api::require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); openbitfun_core::external_hook_import::apply_external_hook_import( workspace, request.import_request, @@ -103,9 +130,12 @@ pub async fn apply_external_hook_import_command( pub async fn mutate_external_hook_import_command( request: MutateExternalHookImportRequest, ) -> ExternalSourceOperationResult { - let workspace = - super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) - .await?; + let workspace_id = super::external_sources_api::require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); openbitfun_core::external_hook_import::mutate_external_hook_import(workspace, request.mutation) .await } diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 0ea9cbd431..d1ce40479f 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -21,17 +21,12 @@ use openbitfun_core::external_sources::{ NativePromptCommandDescriptor, PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, }; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; -use openbitfun_core::service::remote_ssh::workspace_state::{ - canonicalize_local_workspace_root, local_workspace_roots_equal, -}; use openbitfun_core::service::workspace::manager::WorkspaceKind; use openbitfun_product_domains::external_sources::{ ExternalMcpImportApplyRequestV1, ExternalMcpImportApplyResultV1, ExternalMcpImportPlanV1, }; use openbitfun_product_domains::workspace_references::WorkspaceReferenceSnapshot; use serde::{Deserialize, Serialize}; -use std::path::Path; use tauri::State; use super::AppState; @@ -39,6 +34,9 @@ use super::AppState; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, #[serde(default)] pub force_refresh: bool, @@ -47,6 +45,7 @@ pub struct ExternalSourceSnapshotRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WorkspaceReferenceSnapshotRequest { + #[serde(default)] pub workspace_path: String, #[serde(default)] pub workspace_id: Option, @@ -57,6 +56,9 @@ pub struct WorkspaceReferenceSnapshotRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceControlCommandRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub control: ExternalSourceControlRequestV1, } @@ -64,6 +66,9 @@ pub struct ExternalSourceControlCommandRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSourceEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub source_key: String, pub enabled: bool, @@ -73,6 +78,9 @@ pub struct SetExternalSourceEnabledRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RevealExternalSourceLocationRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub source_key: String, } @@ -80,6 +88,9 @@ pub struct RevealExternalSourceLocationRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalEcosystemAwarenessRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -92,6 +103,9 @@ pub struct ExternalEcosystemAwarenessResponse { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AcknowledgeExternalEcosystemsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub ecosystem_ids: Vec, } @@ -99,6 +113,9 @@ pub struct AcknowledgeExternalEcosystemsRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateExternalIntegrationPolicyRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub mutation: ExternalIntegrationPolicyMutation, } @@ -106,6 +123,9 @@ pub struct UpdateExternalIntegrationPolicyRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSourceConflictChoiceRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, @@ -115,6 +135,9 @@ pub struct SetExternalSourceConflictChoiceRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NativePromptCommandConflictsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub native_commands: Vec, } @@ -122,6 +145,9 @@ pub struct NativePromptCommandConflictsRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetNativePromptCommandConflictChoiceRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub native_commands: Vec, pub selected_candidate_id: String, @@ -131,6 +157,9 @@ pub struct SetNativePromptCommandConflictChoiceRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpandExternalPromptCommandRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub name: String, #[serde(default)] @@ -149,6 +178,9 @@ pub struct ExpandExternalPromptCommandRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolTargetDecisionRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub approval_key: String, pub decision_key: String, @@ -166,6 +198,9 @@ pub struct ExternalToolDecisionRef { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolTargetsEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub decisions: Vec, pub enabled: bool, @@ -176,6 +211,9 @@ pub struct SetExternalToolTargetsEnabledRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolConflictChoiceRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, @@ -185,6 +223,9 @@ pub struct SetExternalToolConflictChoiceRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentActivationRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub candidate_id: String, pub approved: bool, @@ -203,6 +244,9 @@ pub struct ExternalCandidateDecisionRef { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentsEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub decisions: Vec, pub enabled: bool, @@ -213,6 +257,9 @@ pub struct SetExternalSubagentsEnabledRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentModelBindingRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub binding_key: String, pub target: Option, @@ -223,6 +270,9 @@ pub struct SetExternalSubagentModelBindingRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalSubagentConflictRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, @@ -235,6 +285,9 @@ pub struct ChooseExternalSubagentConflictRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalMcpServerDecisionRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub candidate_id: String, pub decision_key: String, @@ -246,6 +299,9 @@ pub struct SetExternalMcpServerDecisionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalMcpServersEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub decisions: Vec, pub enabled: bool, @@ -256,6 +312,9 @@ pub struct SetExternalMcpServersEnabledRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalMcpConflictRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, @@ -268,12 +327,18 @@ pub struct ChooseExternalMcpConflictRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PlanExternalMcpImportRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApplyExternalMcpImportRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub import_request: ExternalMcpImportApplyRequestV1, } @@ -288,9 +353,11 @@ pub type WorkspaceReferenceResponse = WorkspaceReferenceSnapshot; pub async fn plan_external_mcp_import_command( request: PlanExternalMcpImportRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()) - .await? - .map(Path::to_path_buf); + let workspace = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; openbitfun_core::external_mcp_import::plan_external_mcp_import(workspace).await } @@ -298,9 +365,11 @@ pub async fn plan_external_mcp_import_command( pub async fn apply_external_mcp_import_command( request: ApplyExternalMcpImportRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()) - .await? - .map(Path::to_path_buf); + let workspace = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; openbitfun_core::external_mcp_import::apply_external_mcp_import( workspace, request.import_request, @@ -309,32 +378,42 @@ pub async fn apply_external_mcp_import_command( } pub(super) async fn require_local_workspace( - workspace_path: Option<&str>, -) -> ExternalSourceOperationResult> { - let Some(workspace_path) = workspace_path else { + workspace_id: Option<&str>, + legacy_path: Option<&str>, +) -> ExternalSourceOperationResult> { + if workspace_id.is_none() && legacy_path.is_none() { return Ok(None); - }; - if is_remote_path(workspace_path).await { - return Err(ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::HostUnavailable, - "The remote workspace is not running the external compatibility service", - true, - )); } - let path = Path::new(workspace_path); - if !path.is_absolute() { - return Err(ExternalSourceOperationError::invalid_request( - "External AI application sources require an absolute workspace path", - )); - } - Ok(Some(path)) + let service = + openbitfun_core::service::workspace::get_global_workspace_service().ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Workspace service is unavailable") + })?; + let record = service + .resolve_legacy_workspace_reference( + workspace_id, + legacy_path.unwrap_or_default(), + None, + None, + ) + .await + .map_err(|error| ExternalSourceOperationError::invalid_request(error.to_string()))? + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Unknown workspace reference") + })?; + ensure_registered_workspace_reference_kind(Some(&record.workspace_kind))?; + Ok(Some(record.id)) } #[tauri::command] pub async fn update_external_integration_policy_command( request: UpdateExternalIntegrationPolicyRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); update_external_integration_policy(workspace, request.mutation) .await .map(Into::into) @@ -345,7 +424,12 @@ pub async fn update_external_integration_policy_command( pub async fn get_external_source_snapshot( request: ExternalSourceSnapshotRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); external_source_snapshot(workspace, request.force_refresh) .await .map(|snapshot| ExternalSourcePublicSnapshot::from(snapshot).into_legacy_v0_compatible()) @@ -356,8 +440,28 @@ pub async fn get_external_source_snapshot( pub async fn get_instruction_source_catalog( request: ExternalSourceSnapshotRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; - Ok(openbitfun_core::external_sources::instruction_source_catalog(workspace).await) + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); + let root = if let Some(id) = workspace { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Workspace service is unavailable") + })?; + Some( + service + .require_workspace(id) + .await + .map_err(|error| ExternalSourceOperationError::invalid_request(error.to_string()))? + .root_path, + ) + } else { + None + }; + Ok(openbitfun_core::external_sources::instruction_source_catalog(root.as_deref()).await) } #[tauri::command] @@ -365,63 +469,24 @@ pub async fn get_workspace_reference_snapshot( state: State<'_, AppState>, request: WorkspaceReferenceSnapshotRequest, ) -> ExternalSourceOperationResult { - let requested_workspace = Path::new(&request.workspace_path); - if !requested_workspace.is_absolute() { - return Err(ExternalSourceOperationError::invalid_request( - "Workspace references require an absolute workspace path", - )); - } - let workspace_id = request - .workspace_id - .as_deref() - .map(str::trim) - .filter(|workspace_id| !workspace_id.is_empty()); - let workspace_info = match workspace_id { - Some(workspace_id) => state.workspace_service.get_workspace(workspace_id).await, - None => { - state - .workspace_service - .get_workspace_by_path(requested_workspace) - .await - } - }; - ensure_registered_workspace_reference_kind( - workspace_info - .as_ref() - .map(|workspace| &workspace.workspace_kind), - )?; - let workspace_info = workspace_info.expect("registered workspace was validated above"); - let path_matches_registered_workspace = if workspace_reference_path_matches_registered_root( - &workspace_info.root_path, - requested_workspace, - ) { - true - } else { - state - .workspace_service - .is_live_worktree_root_in_same_repository( - &workspace_info.root_path, - requested_workspace, - ) - .await - .unwrap_or(false) - }; - if !path_matches_registered_workspace { - return Err(ExternalSourceOperationError::invalid_request( - "Workspace reference path does not match the registered workspace or one of its Git worktrees", - )); - } - let workspace = require_local_workspace(Some(&request.workspace_path)) - .await? - .ok_or_else(|| { - ExternalSourceOperationError::invalid_request( - "Workspace references require a local workspace path", - ) - })?; - let native_related_paths = workspace_info.related_paths; - workspace_reference_snapshot(workspace, &native_related_paths, request.force_refresh) + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + (!request.workspace_path.is_empty()).then_some(request.workspace_path.as_str()), + ) + .await? + .ok_or_else(|| ExternalSourceOperationError::invalid_request("Workspace ID is required"))?; + let workspace = state + .workspace_service + .require_workspace(&workspace_id) .await - .map_err(openbitfun_core::external_sources::sanitize_external_source_operation_error) + .map_err(|error| ExternalSourceOperationError::invalid_request(error.to_string()))?; + workspace_reference_snapshot( + &workspace.id, + &workspace.related_paths, + request.force_refresh, + ) + .await + .map_err(openbitfun_core::external_sources::sanitize_external_source_operation_error) } fn ensure_registered_workspace_reference_kind( @@ -442,21 +507,16 @@ fn ensure_registered_workspace_reference_kind( } } -fn workspace_reference_path_matches_registered_root( - registered_root: &Path, - requested_path: &Path, -) -> bool { - let Ok((requested_path, _)) = canonicalize_local_workspace_root(requested_path) else { - return false; - }; - local_workspace_roots_equal(registered_root, &requested_path) -} - #[tauri::command] pub async fn reveal_external_source_location( request: RevealExternalSourceLocationRequest, ) -> ExternalSourceOperationResult<()> { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); let path = external_source_location_for_host_action(workspace, &request.source_key) .await .map_err(openbitfun_core::external_sources::sanitize_external_source_operation_error)?; @@ -468,7 +528,12 @@ pub async fn reveal_external_source_location( pub async fn get_external_source_control_snapshot( request: ExternalSourceSnapshotRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); core_get_external_source_control_snapshot( workspace, request.force_refresh, @@ -481,7 +546,12 @@ pub async fn get_external_source_control_snapshot( pub async fn get_external_source_discovery_snapshot( request: ExternalSourceSnapshotRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); external_source_discovery_snapshot( workspace, request.force_refresh, @@ -495,7 +565,12 @@ pub async fn get_external_source_discovery_snapshot( pub async fn apply_external_source_control_action_command( request: ExternalSourceControlCommandRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); apply_external_source_control_action(workspace, request.control).await } @@ -505,7 +580,12 @@ pub async fn apply_external_source_control_action_command( pub async fn get_external_ecosystem_awareness_command( request: ExternalEcosystemAwarenessRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); unacknowledged_external_ecosystems(workspace) .await .map( @@ -524,7 +604,12 @@ pub async fn get_external_ecosystem_awareness_command( pub async fn acknowledge_external_ecosystems_command( request: AcknowledgeExternalEcosystemsRequest, ) -> ExternalSourceOperationResult<()> { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); acknowledge_external_ecosystems(workspace, request.ecosystem_ids) .await .map_err(openbitfun_core::external_sources::sanitize_external_source_operation_error) @@ -534,7 +619,12 @@ pub async fn acknowledge_external_ecosystems_command( pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_source_enabled( workspace, &request.source_key, @@ -550,7 +640,12 @@ pub async fn set_external_source_enabled_command( pub async fn set_external_source_conflict_choice_command( request: SetExternalSourceConflictChoiceRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_prompt_command_conflict_choice( workspace, &request.conflict_key, @@ -566,7 +661,12 @@ pub async fn set_external_source_conflict_choice_command( pub async fn get_native_prompt_command_conflicts_command( request: NativePromptCommandConflictsRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); native_prompt_command_conflicts(workspace, request.native_commands) .await .map_err(openbitfun_core::external_sources::sanitize_external_source_operation_error) @@ -576,7 +676,12 @@ pub async fn get_native_prompt_command_conflicts_command( pub async fn set_native_prompt_command_conflict_choice_command( request: SetNativePromptCommandConflictChoiceRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_native_prompt_command_conflict_choice( workspace, request.native_commands, @@ -591,7 +696,12 @@ pub async fn set_native_prompt_command_conflict_choice_command( pub async fn expand_external_prompt_command_command( request: ExpandExternalPromptCommandRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); expand_external_prompt_command( workspace, &request.name, @@ -611,7 +721,12 @@ pub async fn expand_external_prompt_command_command( pub async fn set_external_tool_target_decision_command( request: SetExternalToolTargetDecisionRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_tool_target_decision( workspace, &request.approval_key, @@ -628,7 +743,12 @@ pub async fn set_external_tool_target_decision_command( pub async fn set_external_tool_targets_enabled_command( request: SetExternalToolTargetsEnabledRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_tool_targets_enabled( workspace, request @@ -649,7 +769,12 @@ pub async fn set_external_tool_targets_enabled_command( pub async fn set_external_tool_conflict_choice_command( request: SetExternalToolConflictChoiceRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_tool_conflict_choice( workspace, &request.conflict_key, @@ -665,7 +790,12 @@ pub async fn set_external_tool_conflict_choice_command( pub async fn set_external_subagent_activation_command( request: SetExternalSubagentActivationRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_subagent_activation( workspace, &request.candidate_id, @@ -683,7 +813,12 @@ pub async fn set_external_subagent_activation_command( pub async fn set_external_subagents_enabled_command( request: SetExternalSubagentsEnabledRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_subagents_enabled( workspace, request @@ -704,7 +839,12 @@ pub async fn set_external_subagents_enabled_command( pub async fn set_external_subagent_model_binding_command( request: SetExternalSubagentModelBindingRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_subagent_model_binding( workspace, &request.binding_key, @@ -721,7 +861,12 @@ pub async fn set_external_subagent_model_binding_command( pub async fn choose_external_subagent_conflict_command( request: ChooseExternalSubagentConflictRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); choose_external_subagent_conflict( workspace, &request.conflict_key, @@ -739,7 +884,12 @@ pub async fn choose_external_subagent_conflict_command( pub async fn set_external_mcp_server_decision_command( request: SetExternalMcpServerDecisionRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_mcp_server_decision( workspace, &request.candidate_id, @@ -757,7 +907,12 @@ pub async fn set_external_mcp_server_decision_command( pub async fn set_external_mcp_servers_enabled_command( request: SetExternalMcpServersEnabledRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); set_external_mcp_servers_enabled( workspace, request @@ -778,7 +933,12 @@ pub async fn set_external_mcp_servers_enabled_command( pub async fn choose_external_mcp_conflict_command( request: ChooseExternalMcpConflictRequest, ) -> ExternalSourceOperationResult { - let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + let workspace_id = require_local_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; + let workspace = workspace_id.as_deref(); choose_external_mcp_conflict( workspace, &request.conflict_key, @@ -798,6 +958,7 @@ mod tests { async fn instruction_catalog_rejects_nonlocal_request_paths_before_discovery() { for path in ["relative/workspace", "ssh://host/workspace"] { let request = super::ExternalSourceSnapshotRequest { + workspace_id: None, workspace_path: Some(path.into()), force_refresh: false, }; @@ -830,29 +991,6 @@ mod tests { ); } - #[test] - fn workspace_reference_paths_do_not_accept_unrelated_or_stale_local_paths() { - let directory = tempfile::tempdir().unwrap(); - let registered_root = directory.path().join("registered"); - let unrelated_root = directory.path().join("unrelated"); - for path in [®istered_root, &unrelated_root] { - std::fs::create_dir_all(path).unwrap(); - } - - assert!(workspace_reference_path_matches_registered_root( - ®istered_root, - ®istered_root - )); - assert!(!workspace_reference_path_matches_registered_root( - ®istered_root, - &unrelated_root - )); - assert!(!workspace_reference_path_matches_registered_root( - ®istered_root, - Path::new("/stale/remote/workspace") - )); - } - #[test] fn desktop_snapshot_never_serializes_prompt_templates() { let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index dacba3e3bb..29f0133ebe 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -14,8 +14,7 @@ use openbitfun_core::service::git::{ GitTrustOutcome, GitTrustReport, GitTrustState, }; use openbitfun_core::service::remote_ssh::{ - build_remote_git_command as build_remote_git_command_shared, is_remote_path, - lookup_remote_connection, normalize_remote_workspace_path, + build_remote_git_command as build_remote_git_command_shared, normalize_remote_workspace_path, }; use openbitfun_core::service::workspace::WorktreeTopologyFreshness; use serde::{Deserialize, Serialize}; @@ -56,36 +55,54 @@ struct RemoteGitOutput { exit_code: i32, } -/// Resolves the remote workspace a Git request belongs to, if any. -/// -/// `Ok(None)` means "this path is on this machine". It must not also mean "this -/// path belongs to another machine but I could not say which": overlapping -/// registered roots with no usable connection hint make `lookup_remote_connection` -/// answer `None` for a path that plainly is remote (`is_remote_path` still says -/// so), and treating that as local hands the request to the local `GitService`. -/// For a read that reports a stranger's repository; for `git_trust_repository` -/// it writes *this* user's global `safe.directory` for a path that lives -/// somewhere else — a real, wrong, host operation whenever a same-named path -/// happens to exist here. Remote workspace search already refuses the identical -/// ambiguity out loud (`search_api::workspace_search_unavailable_message`). +/// Resolve the execution environment from the authoritative workspace object. +/// repository_path is only an IO location inside that workspace, never its key. async fn resolve_remote_git_target( + workspace_id: &str, repository_path: &str, ) -> Result, String> { - if let Some(entry) = lookup_remote_connection(repository_path).await { - return Ok(Some(RemoteGitTarget { - connection_id: entry.connection_id, - repository_path: normalize_remote_workspace_path(repository_path), - })); + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = service + .require_workspace(workspace_id) + .await + .map_err(|e| e.to_string())?; + if workspace.workspace_kind != openbitfun_core::service::workspace::WorkspaceKind::Remote { + return Ok(None); } - - if is_remote_path(repository_path).await { - return Err(format!( - "Remote workspace is not registered with OpenBitFun SSH state: no single SSH connection \ - matches {repository_path}" - )); + let connection_id = workspace + .remote_ssh_connection_id() + .ok_or("Remote workspace has no saved SSH connection ID")? + .to_string(); + Ok(Some(RemoteGitTarget { + connection_id, + repository_path: normalize_remote_workspace_path(repository_path), + })) +} + +/// Old HostInvoke payloads are decoded only at this temporary upgrade boundary. +async fn resolve_git_request( + workspace_id: &mut Option, + repository_path: &mut String, +) -> Result<(), String> { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = match workspace_id.as_deref() { + Some(id) => service + .require_workspace(id) + .await + .map_err(|e| e.to_string())?, + None => service + .resolve_legacy_workspace_reference(None, repository_path, None, None) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy Git workspace cannot be resolved; select a workspace by ID")?, + }; + if workspace_id.is_some() || repository_path.is_empty() { + *repository_path = workspace.root_path.to_string_lossy().into_owned(); } - - Ok(None) + *workspace_id = Some(workspace.id); + Ok(()) } fn build_remote_git_command(repository_path: &str, args: &[String]) -> String { @@ -408,12 +425,18 @@ fn git_log_args(params: &GitLogParams) -> Vec { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitRepositoryRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitResolveRevisionRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub revision: String, } @@ -421,6 +444,9 @@ pub struct GitResolveRevisionRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitBranchesRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub include_remote: Option, } @@ -428,6 +454,9 @@ pub struct GitBranchesRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCommitsRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: Option, } @@ -435,6 +464,9 @@ pub struct GitCommitsRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitAddFilesRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitAddParams, } @@ -442,6 +474,9 @@ pub struct GitAddFilesRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCommitRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitCommitParams, } @@ -449,6 +484,9 @@ pub struct GitCommitRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitPushRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitPushParams, } @@ -456,6 +494,9 @@ pub struct GitPushRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitPullRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitPullParams, } @@ -463,6 +504,9 @@ pub struct GitPullRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCheckoutBranchRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub branch_name: String, } @@ -470,6 +514,9 @@ pub struct GitCheckoutBranchRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCreateBranchRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub branch_name: String, pub start_point: Option, @@ -478,6 +525,9 @@ pub struct GitCreateBranchRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitDeleteBranchRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub branch_name: String, pub force: Option, @@ -486,6 +536,9 @@ pub struct GitDeleteBranchRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitDiffRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitDiffParams, } @@ -493,6 +546,9 @@ pub struct GitDiffRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitChangedFilesRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub params: GitChangedFilesParams, } @@ -500,6 +556,9 @@ pub struct GitChangedFilesRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitResetFilesRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub files: Vec, pub staged: Option, @@ -508,6 +567,9 @@ pub struct GitResetFilesRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitResetToCommitRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub commit_hash: String, pub mode: String, // "soft", "mixed", or "hard" @@ -516,6 +578,9 @@ pub struct GitResetToCommitRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitGetFileContentRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub file_path: String, pub commit: Option, @@ -524,6 +589,9 @@ pub struct GitGetFileContentRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCherryPickRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub commit_hash: String, pub no_commit: Option, @@ -532,6 +600,9 @@ pub struct GitCherryPickRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitAddWorktreeRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub branch: String, pub create_branch: Option, @@ -540,6 +611,9 @@ pub struct GitAddWorktreeRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitRemoveWorktreeRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub repository_path: String, pub worktree_path: String, pub force: Option, @@ -554,9 +628,15 @@ pub struct GitRemoveWorktreeRequest { #[tauri::command] pub async fn git_get_repository_trust( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return inspect_remote_repository_trust(&state, &target).await; } @@ -580,9 +660,15 @@ pub async fn git_get_repository_trust( #[tauri::command] pub async fn git_trust_repository( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let report = inspect_remote_repository_trust(&state, &target).await?; info!( "Git repository trust must be granted on the remote host: path={}", @@ -712,10 +798,16 @@ fn remote_probe_found_repository(output: &RemoteGitOutput) -> bool { pub async fn git_is_repository( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let trace_started = Instant::now(); - let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + let result = if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { execute_remote_git_command( &state, &target, @@ -742,9 +834,15 @@ pub async fn git_is_repository( #[tauri::command] pub async fn git_get_repository( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let current_branch = execute_remote_git_success( &state, &target, @@ -805,11 +903,17 @@ pub async fn git_get_repository( pub async fn git_get_repository_basic( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let trace_started = Instant::now(); let result = async { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let current_branch = execute_remote_git_success( &state, &target, @@ -861,14 +965,20 @@ pub async fn git_get_repository_basic( #[tauri::command] pub async fn git_resolve_revision( state: State<'_, AppState>, - request: GitResolveRevisionRequest, + mut request: GitResolveRevisionRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let revision = request.revision.trim(); if revision.is_empty() { return Err("Revision cannot be empty".to_string()); } - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_success( &state, &target, @@ -890,9 +1000,15 @@ pub async fn git_resolve_revision( #[tauri::command] pub async fn git_get_status( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let output = execute_remote_git_success( &state, &target, @@ -921,10 +1037,16 @@ pub async fn git_get_status( #[tauri::command] pub async fn git_get_branches( state: State<'_, AppState>, - request: GitBranchesRequest, + mut request: GitBranchesRequest, ) -> Result, String> { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let include_remote = request.include_remote.unwrap_or(false); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec![ "for-each-ref".to_string(), "--format=%(if)%(HEAD)%(then)*%(else) %(end)%09%(refname)%09%(refname:short)%09%(upstream:short)%09%(objectname)%09%(committerdate:iso-strict)".to_string(), @@ -951,10 +1073,16 @@ pub async fn git_get_branches( #[tauri::command] pub async fn git_get_enhanced_branches( state: State<'_, AppState>, - request: GitBranchesRequest, + mut request: GitBranchesRequest, ) -> Result, String> { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let include_remote = request.include_remote.unwrap_or(false); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec![ "for-each-ref".to_string(), "--format=%(if)%(HEAD)%(then)*%(else) %(end)%09%(refname)%09%(refname:short)%09%(upstream:short)%09%(objectname)%09%(committerdate:iso-strict)".to_string(), @@ -981,10 +1109,16 @@ pub async fn git_get_enhanced_branches( #[tauri::command] pub async fn git_get_commits( state: State<'_, AppState>, - request: GitCommitsRequest, + mut request: GitCommitsRequest, ) -> Result, String> { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let params = request.params.unwrap_or_default(); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let output = execute_remote_git_success(&state, &target, &git_log_args(¶ms)).await?; return Ok(parse_remote_commits(&output)); } @@ -1003,9 +1137,15 @@ pub async fn git_get_commits( #[tauri::command] pub async fn git_add_files( state: State<'_, AppState>, - request: GitAddFilesRequest, + mut request: GitAddFilesRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec!["add".to_string()]; if request.params.all.unwrap_or(false) { args.push("-A".to_string()); @@ -1031,9 +1171,15 @@ pub async fn git_add_files( #[tauri::command] pub async fn git_commit( state: State<'_, AppState>, - request: GitCommitRequest, + mut request: GitCommitRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec![ "commit".to_string(), "-m".to_string(), @@ -1069,9 +1215,15 @@ pub async fn git_commit( #[tauri::command] pub async fn git_push( state: State<'_, AppState>, - request: GitPushRequest, + mut request: GitPushRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec!["push".to_string()]; if request.params.force.unwrap_or(false) { args.push("--force".to_string()); @@ -1102,9 +1254,15 @@ pub async fn git_push( #[tauri::command] pub async fn git_pull( state: State<'_, AppState>, - request: GitPullRequest, + mut request: GitPullRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec!["pull".to_string()]; if request.params.rebase.unwrap_or(false) { args.push("--rebase".to_string()); @@ -1132,9 +1290,15 @@ pub async fn git_pull( #[tauri::command] pub async fn git_checkout_branch( state: State<'_, AppState>, - request: GitCheckoutBranchRequest, + mut request: GitCheckoutBranchRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_operation( &state, &target, @@ -1157,9 +1321,15 @@ pub async fn git_checkout_branch( #[tauri::command] pub async fn git_create_branch( state: State<'_, AppState>, - request: GitCreateBranchRequest, + mut request: GitCreateBranchRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec![ "checkout".to_string(), "-b".to_string(), @@ -1189,10 +1359,16 @@ pub async fn git_create_branch( #[tauri::command] pub async fn git_delete_branch( state: State<'_, AppState>, - request: GitDeleteBranchRequest, + mut request: GitDeleteBranchRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let force = request.force.unwrap_or(false); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_operation( &state, &target, @@ -1219,9 +1395,15 @@ pub async fn git_delete_branch( #[tauri::command] pub async fn git_get_diff( state: State<'_, AppState>, - request: GitDiffRequest, + mut request: GitDiffRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_success(&state, &target, &build_git_diff_args(&request.params)) .await; } @@ -1240,14 +1422,20 @@ pub async fn git_get_diff( #[tauri::command] pub async fn git_get_changed_files( state: State<'_, AppState>, - request: GitChangedFilesRequest, + mut request: GitChangedFilesRequest, ) -> Result, String> { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!( "Getting changed Git files for repository: {}", request.repository_path ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let output = execute_remote_git_success( &state, &target, @@ -1268,8 +1456,9 @@ pub async fn git_get_changed_files( #[tauri::command] pub async fn git_reset_files( state: State<'_, AppState>, - request: GitResetFilesRequest, + mut request: GitResetFilesRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let staged = request.staged.unwrap_or(false); info!( @@ -1277,7 +1466,12 @@ pub async fn git_reset_files( request.repository_path, staged, request.files ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec!["restore".to_string()]; if staged { args.push("--staged".to_string()); @@ -1301,14 +1495,20 @@ pub async fn git_reset_files( #[tauri::command] pub async fn git_get_file_content( state: State<'_, AppState>, - request: GitGetFileContentRequest, + mut request: GitGetFileContentRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!( "Getting file content for '{}' at commit '{:?}' in repo '{}'", request.file_path, request.commit, request.repository_path ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let object_spec = format!( "{}:{}", request.commit.as_deref().unwrap_or("HEAD"), @@ -1332,14 +1532,20 @@ pub async fn git_get_file_content( #[tauri::command] pub async fn git_reset_to_commit( state: State<'_, AppState>, - request: GitResetToCommitRequest, + mut request: GitResetToCommitRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!( "Resetting to commit '{}' with mode '{}' in repo '{}'", request.commit_hash, request.mode, request.repository_path ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mode_flag = match request.mode.as_str() { "soft" => "--soft", "mixed" => "--mixed", @@ -1376,16 +1582,25 @@ pub async fn git_reset_to_commit( #[tauri::command] pub async fn git_get_graph( _state: State<'_, AppState>, - repository_path: String, + repository_path: Option, + mut workspace_id: Option, max_count: Option, branch_name: Option, ) -> Result { + let mut repository_path = repository_path.unwrap_or_default(); info!( "Getting git graph: repository_path={}, max_count={:?}, branch_name={:?}", repository_path, max_count, branch_name ); - if resolve_remote_git_target(&repository_path).await?.is_some() { + resolve_git_request(&mut workspace_id, &mut repository_path).await?; + if resolve_remote_git_target( + workspace_id.as_deref().expect("resolved workspace"), + &repository_path, + ) + .await? + .is_some() + { return Err("Git graph is not supported for remote SSH workspaces yet".to_string()); } @@ -1397,8 +1612,9 @@ pub async fn git_get_graph( #[tauri::command] pub async fn git_cherry_pick( state: State<'_, AppState>, - request: GitCherryPickRequest, + mut request: GitCherryPickRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let no_commit = request.no_commit.unwrap_or(false); info!( @@ -1406,7 +1622,12 @@ pub async fn git_cherry_pick( request.commit_hash, request.repository_path, no_commit ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { let mut args = vec!["cherry-pick".to_string()]; if no_commit { args.push("-n".to_string()); @@ -1429,11 +1650,17 @@ pub async fn git_cherry_pick( #[tauri::command] pub async fn git_cherry_pick_abort( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!("Aborting cherry-pick in repo '{}'", request.repository_path); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_operation( &state, &target, @@ -1456,14 +1683,20 @@ pub async fn git_cherry_pick_abort( #[tauri::command] pub async fn git_cherry_pick_continue( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!( "Continuing cherry-pick in repo '{}'", request.repository_path ); - if let Some(target) = resolve_remote_git_target(&request.repository_path).await? { + if let Some(target) = resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + { return execute_remote_git_operation( &state, &target, @@ -1486,13 +1719,17 @@ pub async fn git_cherry_pick_continue( #[tauri::command] pub async fn git_list_worktrees( state: State<'_, AppState>, - request: GitRepositoryRequest, + mut request: GitRepositoryRequest, ) -> Result, String> { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; info!("Listing worktrees for '{}'", request.repository_path); - if resolve_remote_git_target(&request.repository_path) - .await? - .is_some() + if resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + .is_some() { return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); } @@ -1516,17 +1753,21 @@ pub async fn git_list_worktrees( #[tauri::command] pub async fn git_add_worktree( state: State<'_, AppState>, - request: GitAddWorktreeRequest, + mut request: GitAddWorktreeRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let create_branch = request.create_branch.unwrap_or(false); info!( "Adding worktree for branch '{}' in '{}' (create_branch: {})", request.branch, request.repository_path, create_branch ); - if resolve_remote_git_target(&request.repository_path) - .await? - .is_some() + if resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + .is_some() { return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); } @@ -1551,17 +1792,21 @@ pub async fn git_add_worktree( #[tauri::command] pub async fn git_remove_worktree( state: State<'_, AppState>, - request: GitRemoveWorktreeRequest, + mut request: GitRemoveWorktreeRequest, ) -> Result { + resolve_git_request(&mut request.workspace_id, &mut request.repository_path).await?; let force = request.force.unwrap_or(false); info!( "Removing worktree '{}' from '{}' (force: {})", request.worktree_path, request.repository_path, force ); - if resolve_remote_git_target(&request.repository_path) - .await? - .is_some() + if resolve_remote_git_target( + request.workspace_id.as_deref().expect("resolved workspace"), + &request.repository_path, + ) + .await? + .is_some() { return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); } diff --git a/src/apps/desktop/src/api/html_preview_api.rs b/src/apps/desktop/src/api/html_preview_api.rs index 36bfe60e0d..ddd2fee880 100644 --- a/src/apps/desktop/src/api/html_preview_api.rs +++ b/src/apps/desktop/src/api/html_preview_api.rs @@ -22,6 +22,10 @@ const MAX_PREVIEW_BYTES: u64 = 64 * 1024 * 1024; #[serde(rename_all = "camelCase")] pub struct HtmlPreviewCreateRequest { pub file_path: String, + #[serde(default)] + pub workspace_id: Option, + /// Temporary pre-ID wire field; normal clients send workspaceId. + #[serde(default)] pub workspace_path: String, #[serde(default)] pub remote_connection_id: Option, @@ -76,20 +80,36 @@ pub async fn html_preview_create( if request.peer_device_mode { return Err("HTML preview is not supported while controlling a peer device".to_string()); } - let workspace = request.workspace_path.trim(); - if workspace.is_empty() { - return Err("HTML preview requires a workspace path".to_string()); - } - + let record = match request.workspace_id.as_deref() { + Some(id) => app_state + .workspace_service + .require_workspace(id) + .await + .map_err(|e| e.to_string())?, + None => app_state + .workspace_service + .resolve_legacy_workspace_reference( + None, + &request.workspace_path, + request.remote_connection_id.as_deref(), + None, + ) + .await + .map_err(|e| e.to_string())? + .ok_or("HTML preview workspace is unavailable")?, + }; + let workspace = record.root_path.to_string_lossy(); + let connection = record.filesystem_connection_id()?; let target = resolve_desktop_path_target( &app_state, &request.file_path, - request.remote_connection_id.as_deref(), + Some(connection.unwrap_or("")), + Some(&record.id), ) .await?; let (root, entry_relative_path) = match target { DesktopPathTarget::Local { resolved_path, .. } => { - let workspace_root = std::fs::canonicalize(workspace) + let workspace_root = std::fs::canonicalize(workspace.as_ref()) .map_err(|e| format!("Failed to resolve workspace root: {e}"))?; let entry = std::fs::canonicalize(&resolved_path) .map_err(|e| format!("Failed to resolve HTML file: {e}"))?; @@ -109,10 +129,7 @@ pub async fn html_preview_create( ) } DesktopPathTarget::Remote { entry, .. } => { - if request.remote_connection_id.as_deref() != Some(entry.connection_id.as_str()) { - return Err("Remote HTML preview requires an explicit connection id".to_string()); - } - let remote_root = normalize_remote_path(workspace); + let remote_root = normalize_remote_path(&workspace); let file_path = normalize_remote_path(&request.file_path); if !is_under_remote_root(&file_path, &remote_root) { return Err("HTML file is outside the remote workspace".to_string()); @@ -495,6 +512,23 @@ fn response(status: StatusCode, body: &str, content_type: &'static str) -> Respo mod tests { use super::*; #[test] + fn current_and_100_preview_requests_have_distinct_identity_inputs() { + let current: HtmlPreviewCreateRequest = serde_json::from_value(serde_json::json!({ + "workspaceId": "remote-id", "filePath": "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/repo/index.html" + })) + .unwrap(); + assert_eq!(current.workspace_id.as_deref(), Some("remote-id")); + assert!(current.workspace_path.is_empty()); + assert!(current.remote_connection_id.is_none()); + let legacy: HtmlPreviewCreateRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "/repo", "filePath": "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/repo/index.html", "remoteConnectionId": "ssh-1" + })) + .unwrap(); + assert!(legacy.workspace_id.is_none()); + assert_eq!(legacy.workspace_path, "/repo"); + assert_eq!(legacy.remote_connection_id.as_deref(), Some("ssh-1")); + } + #[test] fn rejects_traversal() { assert!(confined_remote_path("/workspace", "../secret").is_err()); assert!(confined_local_path(FsPath::new("/workspace"), "../secret").is_err()); diff --git a/src/apps/desktop/src/api/i18n_api.rs b/src/apps/desktop/src/api/i18n_api.rs index 32e0ad027c..03d6264bde 100644 --- a/src/apps/desktop/src/api/i18n_api.rs +++ b/src/apps/desktop/src/api/i18n_api.rs @@ -54,7 +54,7 @@ pub(crate) async fn apply_language_runtime_effects( #[cfg(target_os = "macos")] { - let has_workspace = _state.workspace_path.read().await.is_some(); + let has_workspace = _state.workspace_id.read().await.is_some(); let mode = if has_workspace { crate::macos_menubar::MenubarMode::Workspace } else { diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index 495946a9ab..e9a174138b 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -255,6 +255,10 @@ pub struct MiniAppAgentEnsureSessionRequest { #[serde(rename_all = "camelCase")] pub struct MiniAppAgentEnsureSessionResponse { pub session_id: String, + /// Owning workspace ID of the hidden MiniApp agent session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// MiniApp appdata workspace root as an IO operand. pub workspace_path: String, pub created: bool, } @@ -295,11 +299,13 @@ pub struct MiniAppAgentCancelStaleRunsResponse { // ============== Commands ============== +/// Creates the hidden MiniApp agent session and returns the created session. +/// Callers read `session_id` and the owning `config.workspace_id` from it. async fn create_miniapp_agent_session( coordinator: &ConversationCoordinator, submission_plan: &MiniAppAgentSubmissionPlan, requested_model: Option, -) -> Result { +) -> Result { let config = SessionConfig { enable_tools: submission_plan.enable_tools, safe_mode: true, @@ -319,7 +325,7 @@ async fn create_miniapp_agent_session( ) .await .map_err(|e| format!("Failed to create MiniApp agent session: {}", e))?; - Ok(session.session_id) + Ok(session) } async fn load_and_validate_miniapp_agent_session( @@ -415,17 +421,16 @@ pub async fn miniapp_agent_ensure_session( .filter(|value| !value.is_empty()) .map(str::to_string); - let (session_id, created) = if let Some(existing_session_id) = + let (session_id, workspace_id, created) = if let Some(existing_session_id) = submission_plan.requested_session_id.clone() { - if load_and_validate_miniapp_agent_session( + if let Some(existing_session) = load_and_validate_miniapp_agent_session( coordinator.inner().as_ref(), &existing_session_id, &request.app_id, &submission_plan.workspace_path, ) .await? - .is_some() { if let Some(model_id) = requested_model.as_deref() { coordinator @@ -439,40 +444,41 @@ pub async fn miniapp_agent_ensure_session( &submission_plan, ) .await?; - (existing_session_id, false) + ( + existing_session_id, + existing_session.config.workspace_id.clone(), + false, + ) } else { check_agent_rate_limit( &request.app_id, agent_perms.rate_limit_per_minute.unwrap_or(0), )?; - ( - create_miniapp_agent_session( - coordinator.inner().as_ref(), - &submission_plan, - requested_model, - ) - .await?, - true, + let session = create_miniapp_agent_session( + coordinator.inner().as_ref(), + &submission_plan, + requested_model, ) + .await?; + (session.session_id, session.config.workspace_id, true) } } else { check_agent_rate_limit( &request.app_id, agent_perms.rate_limit_per_minute.unwrap_or(0), )?; - ( - create_miniapp_agent_session( - coordinator.inner().as_ref(), - &submission_plan, - requested_model, - ) - .await?, - true, + let session = create_miniapp_agent_session( + coordinator.inner().as_ref(), + &submission_plan, + requested_model, ) + .await?; + (session.session_id, session.config.workspace_id, true) }; Ok(MiniAppAgentEnsureSessionResponse { session_id, + workspace_id, workspace_path: workspace_plan.workspace_path, created, }) @@ -598,7 +604,8 @@ pub async fn miniapp_agent_run( &submission_plan, requested_model.clone(), ) - .await?; + .await? + .session_id; if let Some(lease) = context_lease.as_ref() { lease.bind_session(&session_id)?; } diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 7306ef7252..1ffd99546e 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -8,9 +8,7 @@ use openbitfun_core::agentic::tools::workspace_paths::{ use openbitfun_core::infrastructure::get_path_manager_arc; use openbitfun_core::infrastructure::FileOperationOptions; use openbitfun_core::service::remote_ssh::workspace_state::remote_workspace_runtime_root; -use openbitfun_core::service::remote_ssh::{ - get_remote_workspace_manager, normalize_remote_workspace_path, RemoteWorkspaceEntry, -}; +use openbitfun_core::service::remote_ssh::{normalize_remote_workspace_path, RemoteWorkspaceEntry}; use openbitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use serde::Serialize; use std::path::{Path, PathBuf}; @@ -87,28 +85,36 @@ fn runtime_root_for_workspace_info(workspace: &WorkspaceInfo) -> Result, ) -> Result, String> { if !is_openbitfun_runtime_uri(raw_path) { return Ok(None); } let parsed = parse_openbitfun_runtime_uri(raw_path).map_err(|e| e.to_string())?; - let workspace = if parsed.workspace_scope == "current" { - app_state.workspace_service.get_current_workspace().await + let id = if parsed.workspace_scope == "current" { + match workspace_id { + Some(id) => id.to_owned(), + None => { + app_state + .workspace_service + .get_current_workspace() + .await + .ok_or("Runtime URI has no selected workspace")? + .id + } + } } else { - app_state - .workspace_service - .list_workspace_infos() - .await - .into_iter() - .find(|workspace| workspace.id == parsed.workspace_scope) - } - .ok_or_else(|| { - format!( - "Unable to resolve runtime URI scope '{}'", - parsed.workspace_scope - ) - })?; + if workspace_id.is_some_and(|id| id != parsed.workspace_scope) { + return Err("Runtime URI belongs to a different workspace ID".into()); + } + parsed.workspace_scope.to_owned() + }; + let workspace = app_state + .workspace_service + .require_workspace(&id) + .await + .map_err(|error| error.to_string())?; let mut resolved = runtime_root_for_workspace_info(&workspace)?; for segment in parsed.relative_path.split('/') { @@ -118,41 +124,15 @@ async fn resolve_runtime_artifact_path( Ok(Some(resolved)) } -async fn lookup_remote_entry_for_path( - app_state: &AppState, - path: &str, - request_preferred: Option<&str>, -) -> Option { - if should_force_local_assistant_path(path, request_preferred) { - return None; - } - - let manager = get_remote_workspace_manager()?; - if let Some(connection_id) = request_preferred { - return manager.lookup_scoped_connection(path, connection_id).await; - } - - let legacy = app_state - .get_remote_workspace_async() - .await - .map(|workspace| workspace.connection_id); - manager.lookup_connection(path, legacy.as_deref()).await -} - -fn should_force_local_assistant_path( - path: &str, - explicit_remote_connection_id: Option<&str>, -) -> bool { - explicit_remote_connection_id.is_none() - && get_path_manager_arc().is_local_assistant_workspace_path(path) -} - pub async fn resolve_desktop_path_target( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result { - if let Some(resolved_path) = resolve_runtime_artifact_path(app_state, raw_path).await? { + if let Some(resolved_path) = + resolve_runtime_artifact_path(app_state, raw_path, workspace_id).await? + { return Ok(DesktopPathTarget::Local { requested_path: raw_path.to_string(), resolved_path, @@ -160,34 +140,27 @@ pub async fn resolve_desktop_path_target( }); } - if preferred_remote_connection_id == Some("") { - return Ok(DesktopPathTarget::Local { - requested_path: raw_path.to_string(), - resolved_path: PathBuf::from(raw_path), - is_runtime_artifact: false, - }); - } - - let explicit_connection_id = preferred_remote_connection_id - .map(str::trim) - .filter(|value| !value.is_empty()); - if let Some(id) = explicit_connection_id { - let connection_id = openbitfun_core::service::filesystem::path_operations::resolve_explicit_path_connection(raw_path, id).await?; + let connection = match workspace_id { + Some(id) => app_state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())? + .filesystem_connection_id()? + .map(str::to_owned), + None => app_state + .workspace_service + .upgrade_legacy_file_connection(raw_path, None, preferred_remote_connection_id) + .await + .map_err(|error| error.to_string())?, + }; + if let Some(id) = connection { + let connection_id = openbitfun_core::service::filesystem::path_operations::resolve_explicit_path_connection(raw_path, &id).await?; return Ok(DesktopPathTarget::Remote { requested_path: raw_path.to_string(), entry: RemotePathConnection { connection_id }, }); } - if let Some(entry) = - lookup_remote_entry_for_path(app_state, raw_path, explicit_connection_id).await - { - return Ok(DesktopPathTarget::Remote { - requested_path: raw_path.to_string(), - entry: RemotePathConnection { - connection_id: entry.connection_id, - }, - }); - } Ok(DesktopPathTarget::Local { requested_path: raw_path.to_string(), @@ -196,39 +169,44 @@ pub async fn resolve_desktop_path_target( }) } -/// Workspace search retains registered-root scope even when device file tools -/// use a saved SSH connection outside any opened workspace. -pub async fn resolve_desktop_workspace_path_target( +/// Search selects the registered workspace by ID; the root is only an IO operand. +pub async fn resolve_desktop_workspace_target( app_state: &AppState, - path: &str, - preferred: Option<&str>, + workspace_id: &str, ) -> Result, String> { - match resolve_desktop_path_target(app_state, path, preferred).await? { - DesktopPathTarget::Local { - requested_path, - resolved_path, - is_runtime_artifact, - } => Ok(DesktopPathTarget::Local { + let workspace = app_state + .workspace_service + .require_workspace(workspace_id) + .await + .map_err(|error| error.to_string())?; + workspace_path_target(&workspace) +} + +fn workspace_path_target( + workspace: &WorkspaceInfo, +) -> Result, String> { + let requested_path = workspace.root_path.to_string_lossy().into_owned(); + match workspace.filesystem_connection_id()? { + None => Ok(DesktopPathTarget::Local { requested_path, - resolved_path, - is_runtime_artifact, + resolved_path: workspace.root_path.clone(), + is_runtime_artifact: false, + }), + Some(connection_id) => Ok(DesktopPathTarget::Remote { + requested_path: requested_path.clone(), + entry: RemoteWorkspaceEntry { + connection_id: connection_id.to_owned(), + connection_name: workspace.name.clone(), + ssh_host: workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .ok_or("Remote workspace is missing SSH host metadata")? + .to_owned(), + remote_root: normalize_remote_workspace_path(&requested_path), + }, }), - DesktopPathTarget::Remote { - requested_path, - entry, - } => { - let workspace = lookup_remote_entry_for_path( - app_state, - &requested_path, - Some(&entry.connection_id), - ) - .await - .ok_or("Remote search requires a registered workspace owning the requested path")?; - Ok(DesktopPathTarget::Remote { - requested_path, - entry: workspace, - }) - } } } @@ -325,9 +303,15 @@ pub async fn read_text_file( raw_path: &str, encoding: Option<&str>, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result { - let target = - resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?; + let target = resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await?; match &target { DesktopPathTarget::Local { resolved_path, .. } => { if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64")) { @@ -376,8 +360,16 @@ pub async fn write_text_file( raw_path: &str, content: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => { let options = FileOperationOptions { backup_on_overwrite: false, @@ -410,8 +402,16 @@ pub async fn path_exists( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => Ok(resolved_path.exists()), DesktopPathTarget::Remote { requested_path, @@ -433,8 +433,16 @@ pub async fn get_path_metadata( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { requested_path, resolved_path, @@ -489,8 +497,16 @@ pub async fn rename_path( old_path: &str, new_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, old_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + old_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path: old_resolved_path, .. @@ -499,6 +515,7 @@ pub async fn rename_path( app_state, new_path, preferred_remote_connection_id, + workspace_id, ) .await? { @@ -537,8 +554,16 @@ pub async fn delete_file( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_file(&resolved_path.to_string_lossy()) @@ -565,8 +590,16 @@ pub async fn delete_directory( raw_path: &str, recursive: bool, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_directory(&resolved_path.to_string_lossy(), recursive) @@ -599,8 +632,16 @@ pub async fn create_empty_file( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => { let options = FileOperationOptions::default(); app_state @@ -630,8 +671,16 @@ pub async fn create_directory( app_state: &AppState, raw_path: &str, preferred_remote_connection_id: Option<&str>, + workspace_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + match resolve_desktop_path_target( + app_state, + raw_path, + preferred_remote_connection_id, + workspace_id, + ) + .await? + { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .create_directory(&resolved_path.to_string_lossy()) @@ -655,9 +704,33 @@ pub async fn create_directory( #[cfg(test)] mod tests { - use super::{ - encode_remote_file_bytes, get_path_manager_arc, should_force_local_assistant_path, - }; + use super::encode_remote_file_bytes; + + #[test] + fn workspace_search_target_uses_record_kind_even_for_identical_roots() { + let mut record: super::WorkspaceInfo = serde_json::from_value(serde_json::json!({ + "id": "local-id", "name": "Workspace", "rootPath": "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/same/repo", + "workspaceType": "Other", "workspaceKind": "normal", "status": "Inactive", + "languages": [], "openedAt": "2026-09-16T00:00:00Z", "lastAccessed": "2026-09-16T00:00:00Z", + "description": null, "tags": [], "statistics": null, + "metadata": { "sshHost": "localhost", "connectionId": "saved-ssh" } + })).unwrap(); + assert!(matches!( + super::workspace_path_target(&record).unwrap(), + super::DesktopPathTarget::Local { .. } + )); + record.id = "remote-id".into(); + record.workspace_kind = super::WorkspaceKind::Remote; + match super::workspace_path_target(&record).unwrap() { + super::DesktopPathTarget::Remote { entry, .. } => { + assert_eq!(entry.connection_id, "saved-ssh"); + assert_eq!(entry.remote_root, "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/same/repo"); + } + _ => panic!("remote workspace must remain remote"), + } + record.metadata.remove("connectionId"); + assert!(super::workspace_path_target(&record).is_err()); + } #[test] fn remote_file_bytes_support_explicit_base64_encoding() { @@ -678,20 +751,21 @@ mod tests { } #[test] - fn local_assistant_path_ignores_legacy_remote_fallback_without_explicit_hint() { - let assistant_path = get_path_manager_arc() - .assistant_workspace_dir("path-target-local-save", None) - .to_string_lossy() - .to_string(); - - assert!(should_force_local_assistant_path(&assistant_path, None)); - assert!(!should_force_local_assistant_path( - &assistant_path, - Some("explicit-remote-connection") + fn assistant_kind_controls_io_even_with_a_saved_ssh_projection() { + let mut record: super::WorkspaceInfo = serde_json::from_value(serde_json::json!({ + "id":"assistant-id", "name":"Assistant", "rootPath":"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/same/root", "tags":[], + "workspaceType":"Other", "workspaceKind":"assistant", "status":"Inactive", + "languages":[], "openedAt":"2026-09-16T00:00:00Z", "lastAccessed":"2026-09-16T00:00:00Z", + "metadata":{"connectionId":"stale-ssh","sshHost":"remote-host"} + })).unwrap(); + assert!(matches!( + super::workspace_path_target(&record).unwrap(), + super::DesktopPathTarget::Local { .. } )); - assert!(!should_force_local_assistant_path( - "/tmp/regular-project", - None + record.workspace_kind = super::WorkspaceKind::Remote; + assert!(matches!( + super::workspace_path_target(&record).unwrap(), + super::DesktopPathTarget::Remote { .. } )); } } diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs index a5cd56bc8e..13f0451b4e 100644 --- a/src/apps/desktop/src/api/review_platform_api.rs +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -7,6 +7,7 @@ use openbitfun_core::service::review_platform::{ ReviewPlatformError, ReviewPlatformIssueEvidence, ReviewPlatformKind, ReviewPlatformListState, ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformPullRequestReviewTarget, ReviewPlatformService, ReviewPlatformWorkspaceSnapshot, + ReviewRepositoryLocator, }; use serde::Deserialize; use tauri::State; @@ -14,6 +15,9 @@ use tauri::State; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformWorkspaceSnapshotRequest { + /// Owning workspace ID; authoritative for local/remote Git routing. + #[serde(default)] + pub workspace_id: Option, pub repository_path: String, pub remote_id: Option, pub page: Option, @@ -25,6 +29,8 @@ pub struct ReviewPlatformWorkspaceSnapshotRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformWorkspaceContextRequest { + #[serde(default)] + pub workspace_id: Option, pub repository_path: String, pub remote_id: Option, } @@ -32,6 +38,8 @@ pub struct ReviewPlatformWorkspaceContextRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformPullRequestDetailRequest { + #[serde(default)] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -40,6 +48,8 @@ pub struct ReviewPlatformPullRequestDetailRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformPullRequestDetailPageRequest { + #[serde(default)] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -51,6 +61,8 @@ pub struct ReviewPlatformPullRequestDetailPageRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformPullRequestCiLogRequest { + #[serde(default)] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -73,13 +85,27 @@ pub struct ReviewPlatformClearAuthTokenRequest { pub host: String, } +fn review_repository( + workspace_id: &Option, + repository_path: &str, +) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(workspace_id.clone(), repository_path) +} + +fn optional_review_repository( + workspace_id: &Option, + repository_path: Option<&str>, +) -> Option { + repository_path.map(|path| ReviewRepositoryLocator::new(workspace_id.clone(), path)) +} + #[tauri::command] pub async fn review_platform_get_workspace_snapshot( _state: State<'_, AppState>, request: ReviewPlatformWorkspaceSnapshotRequest, ) -> Result { ReviewPlatformService::workspace_snapshot_with_state( - &request.repository_path, + &review_repository(&request.workspace_id, &request.repository_path), request.remote_id.as_deref(), request.page, request.per_page, @@ -100,18 +126,18 @@ pub async fn review_platform_get_workspace_context( _state: State<'_, AppState>, request: ReviewPlatformWorkspaceContextRequest, ) -> Result { - ReviewPlatformService::workspace_context(&request.repository_path, request.remote_id.as_deref()) - .await - .map_err(|error| { - error!( - "Failed to get review platform workspace context: path={}, remote_id={:?}, error={}", - request.repository_path, request.remote_id, error - ); - review_platform_command_error( - "Failed to get review platform workspace context", - &error, - ) - }) + ReviewPlatformService::workspace_context( + &review_repository(&request.workspace_id, &request.repository_path), + request.remote_id.as_deref(), + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform workspace context: path={}, remote_id={:?}, error={}", + request.repository_path, request.remote_id, error + ); + review_platform_command_error("Failed to get review platform workspace context", &error) + }) } #[tauri::command] @@ -120,7 +146,7 @@ pub async fn review_platform_get_pull_request_detail( request: ReviewPlatformPullRequestDetailRequest, ) -> Result { ReviewPlatformService::pull_request_detail( - &request.repository_path, + &review_repository(&request.workspace_id, &request.repository_path), &request.remote_id, &request.pull_request_id, ) @@ -146,7 +172,7 @@ pub async fn review_platform_get_pull_request_review_target( request: ReviewPlatformPullRequestDetailRequest, ) -> Result { ReviewPlatformService::pull_request_review_target( - &request.repository_path, + &review_repository(&request.workspace_id, &request.repository_path), &request.remote_id, &request.pull_request_id, ) @@ -175,7 +201,8 @@ pub async fn review_platform_get_issue( &request.issue_id, request.page, request.per_page, - request.repository_path.as_deref(), + optional_review_repository(&request.workspace_id, request.repository_path.as_deref()) + .as_ref(), ) .await .map_err(|error| { @@ -202,7 +229,8 @@ pub async fn review_platform_get_pull_request_review_target_by_identity( &request.host, &request.project_path, &request.pull_request_id, - request.repository_path.as_deref(), + optional_review_repository(&request.workspace_id, request.repository_path.as_deref()) + .as_ref(), ) .await .map_err(|error| { @@ -265,7 +293,7 @@ pub async fn review_platform_get_pull_request_detail_page( request: ReviewPlatformPullRequestDetailPageRequest, ) -> Result { ReviewPlatformService::pull_request_detail_page( - &request.repository_path, + &review_repository(&request.workspace_id, &request.repository_path), &request.remote_id, &request.pull_request_id, request.section, @@ -297,7 +325,7 @@ pub async fn review_platform_get_pull_request_ci_log( request: ReviewPlatformPullRequestCiLogRequest, ) -> Result { ReviewPlatformService::pull_request_ci_log( - &request.repository_path, + &review_repository(&request.workspace_id, &request.repository_path), &request.remote_id, &request.pull_request_id, &request.ci_item_id, @@ -358,6 +386,8 @@ pub struct ReviewPlatformIssueRequest { pub issue_id: String, pub page: Option, pub per_page: Option, + #[serde(default)] + pub workspace_id: Option, pub repository_path: Option, } @@ -368,6 +398,8 @@ pub struct ReviewPlatformPullRequestIdentityRequest { pub host: String, pub project_path: String, pub pull_request_id: String, + #[serde(default)] + pub workspace_id: Option, pub repository_path: Option, } diff --git a/src/apps/desktop/src/api/search_api.rs b/src/apps/desktop/src/api/search_api.rs index b44a00936a..695dc6f894 100644 --- a/src/apps/desktop/src/api/search_api.rs +++ b/src/apps/desktop/src/api/search_api.rs @@ -1,11 +1,11 @@ use crate::api::app_state::AppState; use openbitfun_core::infrastructure::{FileSearchResult, FileSearchResultGroup, SearchMatchType}; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use openbitfun_core::service::search::{ - remote_workspace_search_service_for_path, workspace_search_daemon_available, + remote_workspace_search_service_for_workspace, workspace_search_daemon_available, workspace_search_feature_enabled, ContentSearchRequest, ContentSearchResult, RemoteWorkspaceSearchService, WorkspaceSearchBackend, WorkspaceSearchRepoPhase, }; +use openbitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; @@ -13,6 +13,10 @@ use tauri::State; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchRepoIndexRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID hosts/clients. + #[serde(default)] pub root_path: String, } @@ -54,27 +58,32 @@ impl WorkspaceContentSearchRunner { } } -pub(crate) async fn remote_workspace_search_service( +pub(crate) async fn resolve_search_workspace( state: &State<'_, AppState>, - root_path: &str, -) -> Result { - let preferred_connection_id = state - .get_remote_workspace_async() + id: Option<&str>, + legacy_root: &str, + legacy_connection: Option<&str>, +) -> Result { + if let Some(id) = id { + return state + .workspace_service + .require_workspace(id) + .await + .map_err(|e| e.to_string()); + } + state + .workspace_service + .resolve_legacy_workspace_reference(None, legacy_root, legacy_connection, None) .await - .and_then(|workspace| { - let remote_root = openbitfun_core::service::remote_ssh::normalize_remote_workspace_path( - &workspace.remote_path, - ); - let root_path = - openbitfun_core::service::remote_ssh::normalize_remote_workspace_path(root_path); - if root_path == remote_root || root_path.starts_with(&format!("{remote_root}/")) { - Some(workspace.connection_id) - } else { - None - } - }); - - remote_workspace_search_service_for_path(root_path, preferred_connection_id).await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Legacy search workspace cannot be resolved; select its ID".into()) +} + +pub(crate) async fn remote_workspace_search_service( + _state: &State<'_, AppState>, + workspace: &WorkspaceInfo, +) -> Result { + remote_workspace_search_service_for_workspace(&workspace.id).await } /// flashgrep refuses to open a directory that is not a Git worktree with a HEAD commit. @@ -93,12 +102,16 @@ fn repo_status_error_message(error: impl std::fmt::Display) -> String { async fn workspace_search_unavailable_message( _state: &State<'_, AppState>, - root_path: &str, + workspace: &WorkspaceInfo, ) -> Option { - if is_remote_path(root_path.trim()).await { + if workspace.workspace_kind == WorkspaceKind::Remote { return Some("Flashgrep is not supported for remote workspaces".to_string()); } + local_workspace_search_unavailable_message(&workspace.root_path.to_string_lossy()).await +} + +async fn local_workspace_search_unavailable_message(root_path: &str) -> Option { if !workspace_search_feature_enabled().await { return Some( "Workspace search is disabled. Enable it in Settings > Session Config to use accelerated workspace search.".to_string(), @@ -125,9 +138,9 @@ async fn workspace_search_unavailable_message( pub(crate) async fn should_use_workspace_search( state: &State<'_, AppState>, - root_path: &str, + workspace: &WorkspaceInfo, ) -> bool { - workspace_search_unavailable_message(state, root_path) + workspace_search_unavailable_message(state, workspace) .await .is_none() } @@ -152,25 +165,27 @@ pub(crate) fn remote_content_search_refusal_message( pub(crate) async fn remote_content_search_refusal( state: &State<'_, AppState>, command: &str, - root_path: &str, + workspace: &WorkspaceInfo, ) -> Option { - if !is_remote_path(root_path.trim()).await { + if workspace.workspace_kind != WorkspaceKind::Remote { return None; } - let reason = workspace_search_unavailable_message(state, root_path).await?; + let reason = workspace_search_unavailable_message(state, workspace).await?; Some(remote_content_search_refusal_message( - command, root_path, &reason, + command, + &workspace.root_path.to_string_lossy(), + &reason, )) } pub(crate) async fn prepare_content_search_runner( state: &State<'_, AppState>, - root_path: &str, + workspace: &WorkspaceInfo, ) -> Result { - if is_remote_path(root_path.trim()).await { + if workspace.workspace_kind == WorkspaceKind::Remote { Ok(WorkspaceContentSearchRunner::Remote( - remote_workspace_search_service(state, root_path).await?, + remote_workspace_search_service(state, workspace).await?, )) } else { Ok(WorkspaceContentSearchRunner::Local( @@ -181,7 +196,7 @@ pub(crate) async fn prepare_content_search_runner( pub(crate) async fn search_file_contents_via_workspace_search( state: &State<'_, AppState>, - root_path: &str, + workspace: &WorkspaceInfo, pattern: &str, case_sensitive: bool, use_regex: bool, @@ -190,8 +205,9 @@ pub(crate) async fn search_file_contents_via_workspace_search( ) -> Result { search_content_request_via_workspace_search( state, + workspace, build_content_search_request( - root_path, + &workspace.root_path.to_string_lossy(), pattern, case_sensitive, use_regex, @@ -228,10 +244,10 @@ pub(crate) fn build_content_search_request( pub(crate) async fn search_content_request_via_workspace_search( state: &State<'_, AppState>, + workspace: &WorkspaceInfo, request: ContentSearchRequest, ) -> Result { - let repo_root = request.repo_root.to_string_lossy().to_string(); - prepare_content_search_runner(state, &repo_root) + prepare_content_search_runner(state, workspace) .await? .search_content(request) .await @@ -282,27 +298,44 @@ pub(crate) fn search_metadata_from_content_result( } } +async fn index_workspace_root( + state: &State<'_, AppState>, + request: &SearchRepoIndexRequest, +) -> Result { + let workspace = if let Some(id) = request.workspace_id.as_deref() { + state + .workspace_service + .require_workspace(id) + .await + .map_err(|e| e.to_string())? + } else { + state + .workspace_service + .resolve_legacy_workspace_reference(None, &request.root_path, None, None) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy search workspace cannot be resolved; select its ID")? + }; + if workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote { + return Err("Flashgrep is not supported for remote workspaces".into()); + } + let root = workspace.root_path.to_string_lossy().into_owned(); + if let Some(message) = local_workspace_search_unavailable_message(&root).await { + return Err(message); + } + Ok(root) +} + #[tauri::command] pub async fn search_get_repo_status( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { - return Err(message); - } - - if is_remote_path(request.root_path.trim()).await { - return remote_workspace_search_service(&state, &request.root_path) - .await? - .get_index_status(&request.root_path) - .await - .map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({}))) - .map_err(repo_status_error_message); - } + let root_path = index_workspace_root(&state, &request).await?; state .workspace_search_service - .get_index_status(&request.root_path) + .get_index_status(&root_path) .await .map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({}))) .map_err(repo_status_error_message) @@ -313,22 +346,11 @@ pub async fn search_build_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { - return Err(message); - } - - if is_remote_path(request.root_path.trim()).await { - return remote_workspace_search_service(&state, &request.root_path) - .await? - .build_index(&request.root_path) - .await - .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) - .map_err(|error| format!("Failed to build workspace index: {}", error)); - } + let root_path = index_workspace_root(&state, &request).await?; state .workspace_search_service - .build_index(&request.root_path) + .build_index(&root_path) .await .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) .map_err(|error| format!("Failed to build workspace index: {}", error)) @@ -339,22 +361,11 @@ pub async fn search_rebuild_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { - return Err(message); - } - - if is_remote_path(request.root_path.trim()).await { - return remote_workspace_search_service(&state, &request.root_path) - .await? - .rebuild_index(&request.root_path) - .await - .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) - .map_err(|error| format!("Failed to rebuild workspace index: {}", error)); - } + let root_path = index_workspace_root(&state, &request).await?; state .workspace_search_service - .rebuild_index(&request.root_path) + .rebuild_index(&root_path) .await .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) .map_err(|error| format!("Failed to rebuild workspace index: {}", error)) diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 58bb08c172..bf035cabe6 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -24,11 +24,13 @@ use std::time::Instant; use tauri::State; fn desktop_session_scope( + workspace_id: Option, workspace_path: String, remote_connection_id: Option, remote_ssh_host: Option, ) -> DesktopSessionScopeRequest { DesktopSessionScopeRequest { + workspace_id, workspace_path, remote_connection_id, remote_ssh_host, @@ -41,6 +43,9 @@ fn desktop_session_error(error: DesktopSessionApplicationError) -> String { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListPersistedSessionsRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -50,6 +55,9 @@ pub struct ListPersistedSessionsRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListPersistedSessionsPageRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, pub limit: usize, /// Optional compact status batch; omitted by older clients. @@ -66,6 +74,9 @@ pub struct ListPersistedSessionsPageRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetSessionLineageRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -76,6 +87,9 @@ pub struct GetSessionLineageRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoadSessionTurnsRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -88,6 +102,9 @@ pub struct LoadSessionTurnsRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveSessionTurnRequest { pub turn_data: DialogTurnData, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -99,6 +116,9 @@ pub struct SaveSessionTurnRequest { pub struct SaveSessionMetadataRequest { pub metadata: SessionMetadata, pub fields: Vec, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -109,6 +129,9 @@ pub struct SaveSessionMetadataRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExportSessionTranscriptRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -140,6 +163,9 @@ fn default_session_reference_search_limit() -> usize { pub struct SessionReferenceCandidate { pub session_id: String, pub session_name: String, + /// Owning workspace ID; the reference identity the composer sends back. + pub workspace_id: String, + /// Display/IO projection of the owning workspace root. pub workspace_path: String, #[serde(skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -156,6 +182,9 @@ fn default_tools() -> bool { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeletePersistedSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -166,6 +195,9 @@ pub struct DeletePersistedSessionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TouchSessionActivityRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -176,6 +208,9 @@ pub struct TouchSessionActivityRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoadPersistedSessionMetadataRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -186,6 +221,9 @@ pub struct LoadPersistedSessionMetadataRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetSessionUsageReportRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -203,6 +241,9 @@ fn default_include_hidden_subagents() -> bool { pub struct ForkSessionRequest { pub source_session_id: String, pub source_turn_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -215,6 +256,9 @@ pub type ForkSessionResponse = SessionBranchResult; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArchiveSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -225,6 +269,9 @@ pub struct ArchiveSessionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnarchiveSessionRequest { pub session_id: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -234,6 +281,9 @@ pub struct UnarchiveSessionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArchiveAllSessionsRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -243,6 +293,9 @@ pub struct ArchiveAllSessionsRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteAllArchivedSessionsRequest { + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -258,6 +311,7 @@ pub async fn list_persisted_sessions( runtime .session_application() .list_persisted_sessions(desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -281,6 +335,7 @@ pub async fn search_session_content( .session_application() .search_session_content( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -334,6 +389,7 @@ pub async fn search_referenceable_sessions( let metadata = runtime .session_application() .list_persisted_sessions(desktop_session_scope( + Some(workspace.id.clone()), workspace_path.clone(), remote_connection_id.clone(), remote_ssh_host.clone(), @@ -360,6 +416,7 @@ pub async fn search_referenceable_sessions( candidates.push(SessionReferenceCandidate { session_id: session.session_id, session_name: session.session_name, + workspace_id: workspace.id.clone(), workspace_path: workspace_path.clone(), remote_connection_id: remote_connection_id.clone(), remote_ssh_host: remote_ssh_host.clone(), @@ -385,6 +442,7 @@ pub async fn list_persisted_sessions_page( .session_application() .list_persisted_sessions_page( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -413,6 +471,7 @@ pub async fn get_session_lineage( .session_application() .get_session_lineage( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -444,6 +503,7 @@ pub async fn load_session_turns( .session_application() .load_session_turns( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -475,6 +535,7 @@ pub async fn get_session_usage_report( .session_application() .generate_usage_report( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -500,6 +561,7 @@ pub async fn save_session_turn( .session_application() .save_session_turn( desktop_session_scope( + request.workspace_id.clone(), request.workspace_path.clone(), request.remote_connection_id, request.remote_ssh_host, @@ -523,6 +585,7 @@ pub async fn save_session_metadata( .session_application() .save_ui_metadata( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -549,6 +612,7 @@ pub async fn export_session_transcript( .session_application() .export_session_transcript( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -579,6 +643,7 @@ pub async fn delete_persisted_session( .session_application() .delete_session( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -605,6 +670,7 @@ pub async fn touch_session_activity( .session_application() .touch_session( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -635,6 +701,7 @@ pub async fn load_persisted_session_metadata( .session_application() .load_session_metadata( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -665,6 +732,7 @@ pub async fn fork_session( .session_application() .fork_session( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -685,6 +753,7 @@ pub async fn archive_session( .session_application() .set_session_archived( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -710,6 +779,7 @@ pub async fn unarchive_session( .session_application() .set_session_archived( desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -732,6 +802,7 @@ pub async fn archive_all_sessions( runtime: State<'_, DesktopRuntimeContext>, ) -> Result { let scope = desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -773,6 +844,7 @@ pub async fn list_archived_sessions( runtime .session_application() .list_archived_sessions(desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -787,6 +859,7 @@ pub async fn delete_all_archived_sessions( runtime: State<'_, DesktopRuntimeContext>, ) -> Result { let scope = desktop_session_scope( + request.workspace_id, request.workspace_path, request.remote_connection_id, request.remote_ssh_host, diff --git a/src/apps/desktop/src/api/session_storage_path.rs b/src/apps/desktop/src/api/session_storage_path.rs index f3ced51b8b..224fa0ee9f 100644 --- a/src/apps/desktop/src/api/session_storage_path.rs +++ b/src/apps/desktop/src/api/session_storage_path.rs @@ -1,36 +1,58 @@ -//! Shared desktop resolution of on-disk session roots for remote workspaces. - +//! Upgrade-only adapter for session commands from pre-ID clients. +//! Normal callers use SessionStorePort::resolve_workspace_storage(workspace_id). +//! Remove with the pre-ID protocol; never use execution paths as storage fallbacks. use crate::api::app_state::AppState; -use openbitfun_core::service::remote_ssh::workspace_state::get_effective_session_path; +use openbitfun_core::agentic::coordination::ConversationCoordinator; +use openbitfun_runtime_ports::SessionStorePort; + +/// ID-first session storage resolution that also takes runtime ownership of +/// the owning workspace. `workspace_path` and the SSH fields are consulted +/// only when the caller is a pre-ID client that sent no `workspace_id`. +pub async fn desktop_session_storage_root( + coordinator: &ConversationCoordinator, + workspace_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + let workspace_id = workspace_id.map(str::trim).filter(|id| !id.is_empty()); + let workspace_path = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()); + if workspace_id.is_none() && workspace_path.is_none() { + return Err("workspace_id is required when the session is not loaded".to_string()); + } + coordinator + .session_storage_for_reference( + workspace_id, + workspace_path.unwrap_or(""), + remote_connection_id, + remote_ssh_host, + ) + .await + .map_err(|error| error.to_string()) +} pub async fn desktop_effective_session_storage_path( app_state: &AppState, workspace_path: &str, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, -) -> std::path::PathBuf { - let conn = remote_connection_id - .map(str::trim) - .filter(|s| !s.is_empty()); - let host_from_request = remote_ssh_host - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let mut host_owned = host_from_request.clone(); - if host_owned.is_none() { - if let Some(cid) = conn { - host_owned = app_state - .workspace_service - .remote_ssh_host_for_remote_workspace(cid, workspace_path) - .await; - } - } - if host_owned.is_none() { - if let Some(cid) = conn { - if let Ok(mgr) = app_state.get_ssh_manager_async().await { - host_owned = mgr.get_saved_host_for_connection_id(cid).await; - } - } - } - get_effective_session_path(workspace_path, conn, host_owned.as_deref()).await +) -> Result { + let workspace = app_state + .workspace_service + .resolve_legacy_workspace_reference( + None, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Workspace ID is unavailable".to_string())?; + openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| error.to_string()) } diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 7181b6da02..79ba82330b 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -1,7 +1,7 @@ //! Skill Management API -use crate::api::app_state::RemoteWorkspace; use log::info; +use openbitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -22,6 +22,7 @@ use openbitfun_core::agentic::tools::implementations::skills::mode_overrides::{ project_mode_skills_path_for_remote, save_project_mode_skills_document_local, set_disabled_mode_skills_in_document, set_global_project_skill_disabled, set_global_user_skill_disabled, set_mode_skill_disabled_in_document, set_user_mode_skill_state, + SkillPolicyWorkspace, }; use openbitfun_core::agentic::tools::implementations::skills::registry::imports::{ self as skill_imports, SkillImportPreview, @@ -35,8 +36,6 @@ use openbitfun_core::infrastructure::get_path_manager_arc; use openbitfun_core::service::config::agent_profile_project_store::{ deserialize_project_agent_profiles_document, serialize_project_agent_profiles_document, }; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; -use openbitfun_core::service::remote_ssh::{get_remote_workspace_manager, RemoteWorkspaceEntry}; use openbitfun_core::service::runtime::RuntimeManager; use openbitfun_core::util::process_manager; @@ -119,6 +118,8 @@ pub struct SkillMarketSearchRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillMarketDownloadRequest { + #[serde(default)] + pub workspace_id: Option, pub package: String, pub level: Option, pub workspace_path: Option, @@ -136,6 +137,8 @@ pub struct SkillMarketDownloadResponse { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReplaceModeSkillSelectionRequest { + #[serde(default)] + pub workspace_id: Option, pub mode_id: String, pub enabled_skill_keys: Vec, pub workspace_path: Option, @@ -144,6 +147,8 @@ pub struct ReplaceModeSkillSelectionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResetModeSkillSelectionRequest { + #[serde(default)] + pub workspace_id: Option, pub mode_id: String, pub workspace_path: Option, } @@ -151,6 +156,8 @@ pub struct ResetModeSkillSelectionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetGlobalSkillDisabledRequest { + #[serde(default)] + pub workspace_id: Option, pub skill_key: String, pub disabled: bool, #[serde(default)] @@ -168,6 +175,8 @@ pub struct GlobalSkillSettingsResponse { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetGlobalSkillSettingsRequest { + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, } @@ -202,56 +211,31 @@ struct SkillSearchApiItem { installs: u64, } -fn workspace_root_from_input(workspace_path: Option<&str>) -> Option { - workspace_path - .filter(|path| !path.is_empty()) - .map(PathBuf::from) -} - -fn trim_workspace_path(workspace_path: Option<&str>) -> Option { - workspace_path - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string) -} - -async fn lookup_remote_entry_for_path( - state: &State<'_, AppState>, - path: &str, -) -> Option { - let manager = get_remote_workspace_manager()?; - let preferred = state - .get_remote_workspace_async() - .await - .map(|workspace: RemoteWorkspace| workspace.connection_id); - manager.lookup_connection(path, preferred.as_deref()).await +fn workspace_root_from_input(workspace: Option<&WorkspaceInfo>) -> Option { + workspace.map(|record| record.root_path.clone()) } async fn resolve_remote_workspace( - state: &State<'_, AppState>, - workspace_path: Option<&str>, -) -> Result, String> { - let Some(path) = trim_workspace_path(workspace_path) else { + workspace: Option<&WorkspaceInfo>, +) -> Result, String> { + let Some(record) = workspace else { return Ok(None); }; - - if !is_remote_path(&path).await { - return Ok(None); - } - - let entry = lookup_remote_entry_for_path(state, &path) - .await - .ok_or_else(|| format!("Remote workspace connection not found for '{}'", path))?; - Ok(Some((path, entry))) + Ok(record.filesystem_connection_id()?.map(|connection| { + ( + record.root_path.to_string_lossy().into_owned(), + connection.to_owned(), + ) + })) } async fn get_all_skills_for_workspace_input( state: &State<'_, AppState>, registry: &SkillRegistry, - workspace_path: Option<&str>, + workspace: Option<&WorkspaceInfo>, ) -> Result, String> { Ok( - get_skill_scan_report_for_workspace_input(state, registry, workspace_path) + get_skill_scan_report_for_workspace_input(state, registry, workspace) .await? .skills, ) @@ -260,16 +244,16 @@ async fn get_all_skills_for_workspace_input( async fn get_skill_scan_report_for_workspace_input( state: &State<'_, AppState>, registry: &SkillRegistry, - workspace_path: Option<&str>, + workspace: Option<&WorkspaceInfo>, ) -> Result { - if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace).await? { await_remote_skill_discovery( async { let remote_fs = state .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; - let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id, remote_fs); + let remote_workspace_fs = RemoteWorkspaceFs::new(entry, remote_fs); Ok(registry .get_skill_scan_report_for_remote_workspace(&remote_workspace_fs, &remote_root) .await) @@ -279,9 +263,7 @@ async fn get_skill_scan_report_for_workspace_input( .await } else { Ok(registry - .get_skill_scan_report_for_workspace( - workspace_root_from_input(workspace_path).as_deref(), - ) + .get_skill_scan_report_for_workspace(workspace_root_from_input(workspace).as_deref()) .await) } } @@ -290,17 +272,16 @@ async fn get_mode_skill_scan_report_for_workspace_input( state: &State<'_, AppState>, registry: &SkillRegistry, mode_id: &str, - workspace_path: Option<&str>, + workspace: Option<&WorkspaceInfo>, ) -> Result, String> { - if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace).await? { await_remote_skill_discovery( async { let remote_fs = state .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; - let remote_workspace_fs = - RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone()); + let remote_workspace_fs = RemoteWorkspaceFs::new(entry.clone(), remote_fs.clone()); Ok(registry .get_mode_skill_scan_report_for_remote_workspace( &remote_workspace_fs, @@ -312,9 +293,12 @@ async fn get_mode_skill_scan_report_for_workspace_input( REMOTE_SKILL_DISCOVERY_TIMEOUT, ) .await - } else if let Some(workspace_root) = workspace_root_from_input(workspace_path) { + } else if let Some(record) = workspace { Ok(registry - .get_mode_skill_scan_report_for_workspace(Some(&workspace_root), mode_id) + .get_mode_skill_scan_report_for_workspace( + Some(SkillPolicyWorkspace::from_record(record)), + mode_id, + ) .await) } else { // Mode-scoped built-in and user-level skills should still be available even @@ -419,7 +403,7 @@ async fn persist_project_mode_skill_selection_local( async fn persist_project_mode_skill_selection_remote( state: &State<'_, AppState>, remote_root: &str, - entry: &RemoteWorkspaceEntry, + entry: &str, mode_id: &str, disabled_project_skills: Vec, ) -> Result<(), String> { @@ -429,12 +413,12 @@ async fn persist_project_mode_skill_selection_remote( .map_err(|e| format!("Remote file service not available: {}", e))?; let config_path = project_mode_skills_path_for_remote(remote_root); let mut document = if remote_fs - .exists(&entry.connection_id, &config_path) + .exists(&entry, &config_path) .await .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))? { let content = remote_fs - .read_file(&entry.connection_id, &config_path) + .read_file(&entry, &config_path) .await .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; let content = String::from_utf8(content) @@ -454,7 +438,7 @@ async fn persist_project_mode_skill_selection_remote( .ok_or_else(|| format!("Invalid remote project config path '{}'", config_path))?; remote_fs - .create_dir_all(&entry.connection_id, &config_dir) + .create_dir_all(&entry, &config_dir) .await .map_err(|e| { format!( @@ -464,7 +448,7 @@ async fn persist_project_mode_skill_selection_remote( })?; remote_fs .write_file( - &entry.connection_id, + &entry, &config_path, serialize_project_agent_profiles_document(&document) .map_err(|e| format!("Failed to serialize remote project skill overrides: {}", e))? @@ -515,7 +499,7 @@ async fn clear_project_mode_skill_selection_local( async fn clear_project_mode_skill_selection_remote( state: &State<'_, AppState>, remote_root: &str, - entry: &RemoteWorkspaceEntry, + entry: &str, mode_id: &str, ) -> Result<(), String> { let remote_fs = state @@ -524,7 +508,7 @@ async fn clear_project_mode_skill_selection_remote( .map_err(|e| format!("Remote file service not available: {}", e))?; let config_path = project_mode_skills_path_for_remote(remote_root); let exists = remote_fs - .exists(&entry.connection_id, &config_path) + .exists(&entry, &config_path) .await .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))?; if !exists { @@ -532,7 +516,7 @@ async fn clear_project_mode_skill_selection_remote( } let content = remote_fs - .read_file(&entry.connection_id, &config_path) + .read_file(&entry, &config_path) .await .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; let content = String::from_utf8(content) @@ -547,13 +531,13 @@ async fn clear_project_mode_skill_selection_remote( if document_is_empty { remote_fs - .remove_file(&entry.connection_id, &config_path) + .remove_file(&entry, &config_path) .await .map_err(|e| format!("Failed to remove remote project skill overrides: {}", e))?; } else { remote_fs .write_file( - &entry.connection_id, + &entry, &config_path, serialize_project_agent_profiles_document(&document) .map_err(|e| { @@ -573,8 +557,11 @@ pub async fn get_skill_configs( state: State<'_, AppState>, force_refresh: Option, include_diagnostics: Option, + workspace_id: Option, workspace_path: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; let registry = SkillRegistry::global(); if force_refresh.unwrap_or(false) { @@ -582,15 +569,14 @@ pub async fn get_skill_configs( } let all_skills = - get_skill_scan_report_for_workspace_input(&state, registry, workspace_path.as_deref()) - .await?; + get_skill_scan_report_for_workspace_input(&state, registry, workspace.as_ref()).await?; let mut response = serialize_skill_scan_response(all_skills, include_diagnostics.unwrap_or(false)) .map_err(|e| format!("Failed to serialize skill configs: {}", e))?; if let Some(object) = response.as_object_mut() { - let supported = match workspace_path.as_deref() { - Some(path) => !is_remote_path(path).await, + let supported = match workspace.as_ref() { + Some(record) => record.workspace_kind != WorkspaceKind::Remote, None => true, }; object.insert( @@ -601,21 +587,46 @@ pub async fn get_skill_configs( Ok(response) } +async fn resolve_skill_workspace( + id: Option<&str>, + legacy_path: Option<&str>, +) -> Result, String> { + if id.is_none() && legacy_path.is_none() { + return Ok(None); + } + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + if let Some(id) = id { + return service + .require_workspace(id) + .await + .map(Some) + .map_err(|e| e.to_string()); + } + // Temporary pre-ID request ingress. Normal callers select a catalog ID. + service + .resolve_legacy_workspace_reference(None, legacy_path.unwrap_or_default(), None, None) + .await + .map_err(|e| e.to_string())? + .map(Some) + .ok_or_else(|| "Legacy Skill workspace cannot be resolved".into()) +} + async fn skill_availability_settings( - workspace_path: Option<&str>, + workspace: Option<&WorkspaceInfo>, ) -> Result { let globally_disabled_user_skill_keys = load_globally_disabled_user_skills() .await .map_err(|error| error.to_string())?; - let globally_disabled_project_skill_keys = match workspace_path { - Some(path) => { - if is_remote_path(path).await { + let globally_disabled_project_skill_keys = match workspace { + Some(record) => { + if record.workspace_kind == WorkspaceKind::Remote { return Err( "Direct Skill availability management is not supported for remote workspaces" .into(), ); } - load_globally_disabled_project_skills(Path::new(path)) + load_globally_disabled_project_skills(SkillPolicyWorkspace::from_record(record)) .await .map_err(|error| error.to_string())? } @@ -632,12 +643,12 @@ async fn skill_availability_settings( pub async fn get_global_skill_settings( request: Option, ) -> Result { - skill_availability_settings( - request - .as_ref() - .and_then(|request| request.workspace_path.as_deref()), + let workspace = resolve_skill_workspace( + request.as_ref().and_then(|r| r.workspace_id.as_deref()), + request.as_ref().and_then(|r| r.workspace_path.as_deref()), ) - .await + .await?; + skill_availability_settings(workspace.as_ref()).await } #[tauri::command] @@ -645,10 +656,14 @@ pub async fn set_global_skill_disabled( request: SetGlobalSkillDisabledRequest, ) -> Result { let skill_key = request.skill_key.trim(); - let workspace = request.workspace_path.as_deref(); + let workspace = resolve_skill_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; // Validate the serving scope before scanning or persisting any local state. - if let Some(path) = workspace { - if is_remote_path(path).await { + if let Some(record) = workspace.as_ref() { + if record.workspace_kind == WorkspaceKind::Remote { return Err( "Direct Skill availability management is not supported for remote workspaces" .into(), @@ -656,7 +671,7 @@ pub async fn set_global_skill_disabled( } } let known_skill = SkillRegistry::global() - .get_all_skills_for_workspace(workspace.map(Path::new)) + .get_all_skills_for_workspace(workspace.as_ref().map(|record| record.root_path.as_path())) .await .into_iter() .find(|skill| skill.key == skill_key) @@ -668,17 +683,22 @@ pub async fn set_global_skill_disabled( .map_err(|error| error.to_string())?; } SkillLocation::Project => { - let root = workspace + let record = workspace + .as_ref() .ok_or_else(|| "Project Skill availability requires a workspace".to_string())?; - set_global_project_skill_disabled(Path::new(root), skill_key, request.disabled) - .await - .map_err(|error| error.to_string())?; + set_global_project_skill_disabled( + SkillPolicyWorkspace::from_record(record), + skill_key, + request.disabled, + ) + .await + .map_err(|error| error.to_string())?; } } if let Err(error) = openbitfun_core::service::config::reload_global_config().await { log::warn!("Failed to reload configuration after Skill availability update: skill_key={}, error={}", skill_key, error); } - skill_availability_settings(workspace).await + skill_availability_settings(workspace.as_ref()).await } #[tauri::command] @@ -687,8 +707,11 @@ pub async fn get_mode_skill_configs( mode_id: String, force_refresh: Option, include_diagnostics: Option, + workspace_id: Option, workspace_path: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; let registry = SkillRegistry::global(); if force_refresh.unwrap_or(false) { @@ -699,7 +722,7 @@ pub async fn get_mode_skill_configs( &state, registry, &mode_id, - workspace_path.as_deref(), + workspace.as_ref(), ) .await?; @@ -713,18 +736,21 @@ pub async fn set_mode_skill_disabled( mode_id: String, skill_key: String, disabled: bool, + workspace_id: Option, workspace_path: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; if skill_key.starts_with("user::") { let registry = SkillRegistry::global(); let skill_info = if let Some((remote_root, entry)) = - resolve_remote_workspace(&state, workspace_path.as_deref()).await? + resolve_remote_workspace(workspace.as_ref()).await? { let remote_fs = state .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; - let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id, remote_fs); + let remote_workspace_fs = RemoteWorkspaceFs::new(entry, remote_fs); registry .find_skill_by_key_for_remote_workspace( &remote_workspace_fs, @@ -736,7 +762,7 @@ pub async fn set_mode_skill_disabled( registry .find_skill_by_key_for_workspace( &skill_key, - workspace_root_from_input(workspace_path.as_deref()).as_deref(), + workspace_root_from_input(workspace.as_ref()).as_deref(), ) .await } @@ -764,21 +790,19 @@ pub async fn set_mode_skill_disabled( return Err(format!("Unsupported skill key '{}'", skill_key)); } - if let Some((remote_root, entry)) = - resolve_remote_workspace(&state, workspace_path.as_deref()).await? - { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace.as_ref()).await? { let remote_fs = state .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; let config_path = project_mode_skills_path_for_remote(&remote_root); let mut document = if remote_fs - .exists(&entry.connection_id, &config_path) + .exists(&entry, &config_path) .await .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))? { let content = remote_fs - .read_file(&entry.connection_id, &config_path) + .read_file(&entry, &config_path) .await .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; let content = String::from_utf8(content).map_err(|e| { @@ -799,7 +823,7 @@ pub async fn set_mode_skill_disabled( .ok_or_else(|| format!("Invalid remote project config path '{}'", config_path))?; remote_fs - .create_dir_all(&entry.connection_id, &config_dir) + .create_dir_all(&entry, &config_dir) .await .map_err(|e| { format!( @@ -809,7 +833,7 @@ pub async fn set_mode_skill_disabled( })?; remote_fs .write_file( - &entry.connection_id, + &entry, &config_path, serialize_project_agent_profiles_document(&document) .map_err(|e| { @@ -820,7 +844,7 @@ pub async fn set_mode_skill_disabled( .await .map_err(|e| format!("Failed to write remote project skill overrides: {}", e))?; } else { - let workspace_root = workspace_root_from_input(workspace_path.as_deref()) + let workspace_root = workspace_root_from_input(workspace.as_ref()) .ok_or_else(|| "Project-level skill overrides require an open workspace".to_string())?; let mut document = load_project_mode_skills_document_local(&workspace_root) .await @@ -843,10 +867,14 @@ pub async fn replace_mode_skill_selection( state: State<'_, AppState>, request: ReplaceModeSkillSelectionRequest, ) -> Result { + let workspace = resolve_skill_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let registry = SkillRegistry::global(); let all_skills = - get_all_skills_for_workspace_input(&state, registry, request.workspace_path.as_deref()) - .await?; + get_all_skills_for_workspace_input(&state, registry, workspace.as_ref()).await?; let enabled_skill_keys = normalize_skill_key_list(request.enabled_skill_keys); let enabled_keys: HashSet = enabled_skill_keys.iter().cloned().collect(); @@ -871,9 +899,7 @@ pub async fn replace_mode_skill_selection( &enabled_keys, )); - if let Some((remote_root, entry)) = - resolve_remote_workspace(&state, request.workspace_path.as_deref()).await? - { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace.as_ref()).await? { persist_project_mode_skill_selection_remote( &state, &remote_root, @@ -882,9 +908,7 @@ pub async fn replace_mode_skill_selection( disabled_project_skills, ) .await?; - } else if let Some(workspace_root) = - workspace_root_from_input(request.workspace_path.as_deref()) - { + } else if let Some(workspace_root) = workspace_root_from_input(workspace.as_ref()) { persist_project_mode_skill_selection_local( &request.mode_id, &workspace_root, @@ -912,18 +936,19 @@ pub async fn reset_mode_skill_selection( state: State<'_, AppState>, request: ResetModeSkillSelectionRequest, ) -> Result { + let workspace = resolve_skill_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; clear_user_mode_skill_overrides(&request.mode_id) .await .map_err(|e| format!("Failed to reset user skill overrides: {}", e))?; - if let Some((remote_root, entry)) = - resolve_remote_workspace(&state, request.workspace_path.as_deref()).await? - { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace.as_ref()).await? { clear_project_mode_skill_selection_remote(&state, &remote_root, &entry, &request.mode_id) .await?; - } else if let Some(workspace_root) = - workspace_root_from_input(request.workspace_path.as_deref()) - { + } else if let Some(workspace_root) = workspace_root_from_input(workspace.as_ref()) { clear_project_mode_skill_selection_local(&request.mode_id, &workspace_root).await?; } @@ -944,15 +969,18 @@ pub async fn reset_mode_skill_selection( async fn resolve_external_skill_import_source( source_path: &str, source_key: &str, - workspace: Option<&Path>, + workspace: Option<&WorkspaceInfo>, ) -> Result { - if let Some(root) = workspace { - if is_remote_path(&root.to_string_lossy()).await { + if let Some(record) = workspace { + if record.workspace_kind == WorkspaceKind::Remote { return Err("External Skill import into remote workspaces is not supported".into()); } } let source = SkillRegistry::global() - .find_skill_by_key_for_workspace(source_key, workspace) + .find_skill_by_key_for_workspace( + source_key, + workspace.map(|record| record.root_path.as_path()), + ) .await .ok_or("skill_import_stale: External Skill source changed; refresh before importing")?; if tokio::fs::canonicalize(&source.path) @@ -971,13 +999,15 @@ async fn resolve_external_skill_import_source( pub async fn validate_skill_path( path: String, source_key: Option, + workspace_id: Option, workspace_path: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; use std::path::Path; if let Some(source_key) = source_key { - let workspace = workspace_root_from_input(workspace_path.as_deref()); let source = - resolve_external_skill_import_source(&path, &source_key, workspace.as_deref()).await?; + resolve_external_skill_import_source(&path, &source_key, workspace.as_ref()).await?; let preview = skill_imports::preview_import(source).await?; return Ok(SkillValidationResult { valid: true, @@ -1055,23 +1085,26 @@ pub async fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, + workspace_id: Option, workspace_path: Option, source_key: Option, target_name: Option, expected_source_fingerprint: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; if let Some(source_key) = source_key { if !matches!(level.as_str(), "user" | "project") { return Err("Invalid Skill target scope".into()); } - let workspace = workspace_root_from_input(workspace_path.as_deref()); + let workspace_root = workspace_root_from_input(workspace.as_ref()); let source = - resolve_external_skill_import_source(&source_path, &source_key, workspace.as_deref()) + resolve_external_skill_import_source(&source_path, &source_key, workspace.as_ref()) .await?; let paths = get_path_manager_arc(); let target = if level == "project" { paths - .project_root(workspace.as_deref().ok_or("No workspace selected")?) + .project_root(workspace_root.as_deref().ok_or("No workspace selected")?) .join("skills") } else { paths.user_skills_dir() @@ -1084,14 +1117,14 @@ pub async fn add_skill( ) .await?; SkillRegistry::global() - .refresh_for_workspace(workspace.as_deref()) + .refresh_for_workspace(workspace_root.as_deref()) .await; return Ok("External Skill imported successfully".into()); } if target_name.is_some() || expected_source_fingerprint.is_some() { return Err("Reviewed or renamed Skill imports require their source identity".into()); } - let validation = validate_skill_path(source_path.clone(), None, None).await?; + let validation = validate_skill_path(source_path.clone(), None, None, None).await?; if !validation.valid { return Err(validation.error.unwrap_or("Invalid skill path".to_string())); } @@ -1103,8 +1136,11 @@ pub async fn add_skill( let source = Path::new(&source_path); let target_dir = if level == "project" { - if let Some(workspace_root) = workspace_root_from_input(workspace_path.as_deref()) { - if is_remote_path(&workspace_root.to_string_lossy()).await { + if let Some(workspace_root) = workspace_root_from_input(workspace.as_ref()) { + if workspace + .as_ref() + .is_some_and(|record| record.workspace_kind == WorkspaceKind::Remote) + { return Err( "Installing project skills into remote workspaces is not supported yet" .to_string(), @@ -1148,7 +1184,7 @@ pub async fn add_skill( } SkillRegistry::global() - .refresh_for_workspace(workspace_root_from_input(workspace_path.as_deref()).as_deref()) + .refresh_for_workspace(workspace_root_from_input(workspace.as_ref()).as_deref()) .await; info!( @@ -1183,13 +1219,14 @@ async fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io:: pub async fn delete_skill( state: State<'_, AppState>, skill_key: String, + workspace_id: Option, workspace_path: Option, expected_import_id: Option, ) -> Result { + let workspace = + resolve_skill_workspace(workspace_id.as_deref(), workspace_path.as_deref()).await?; let registry = SkillRegistry::global(); - if let Some((remote_root, entry)) = - resolve_remote_workspace(&state, workspace_path.as_deref()).await? - { + if let Some((remote_root, entry)) = resolve_remote_workspace(workspace.as_ref()).await? { if expected_import_id.is_some() { return Err("External Skill import undo on remote workspaces is not supported".into()); } @@ -1197,8 +1234,7 @@ pub async fn delete_skill( .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; - let remote_workspace_fs = - RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone()); + let remote_workspace_fs = RemoteWorkspaceFs::new(entry.clone(), remote_fs.clone()); let skill_info = registry .find_skill_by_key_for_remote_workspace(&remote_workspace_fs, &remote_root, &skill_key) .await @@ -1208,7 +1244,7 @@ pub async fn delete_skill( match skill_info.level { SkillLocation::Project => { remote_fs - .remove_dir_all(&entry.connection_id, &skill_info.path) + .remove_dir_all(&entry, &skill_info.path) .await .map_err(|e| format!("Failed to delete remote skill folder: {}", e))?; info!( @@ -1236,7 +1272,7 @@ pub async fn delete_skill( return Ok(format!("Skill '{}' deleted successfully", skill_info.name)); } - let workspace_root = workspace_root_from_input(workspace_path.as_deref()); + let workspace_root = workspace_root_from_input(workspace.as_ref()); let skill_info = registry .find_skill_by_key_for_workspace(&skill_key, workspace_root.as_deref()) .await @@ -1303,6 +1339,51 @@ mod tests { ); } + #[tokio::test] + async fn skill_provider_uses_record_kind_and_saved_connection_not_its_root() { + use openbitfun_core::service::workspace::WorkspaceInfoRuntimeExt; + let root = tempfile::tempdir().unwrap(); + let mut local = super::WorkspaceInfo::new_without_worktree( + root.path().to_path_buf(), + Default::default(), + ) + .await + .unwrap(); + local + .metadata + .insert("connectionId".into(), serde_json::json!("stale-ssh")); + assert!(super::resolve_remote_workspace(Some(&local)) + .await + .unwrap() + .is_none()); + let mut remote = local.clone(); + remote.id = "remote-id".into(); + remote.workspace_kind = super::WorkspaceKind::Remote; + let target = super::resolve_remote_workspace(Some(&remote)) + .await + .unwrap() + .unwrap(); + assert_eq!(target.1, "stale-ssh"); + remote.metadata.clear(); + assert!(super::resolve_remote_workspace(Some(&remote)) + .await + .is_err()); + } + + #[test] + fn skill_settings_accept_an_id_without_a_path() { + let request: super::SetGlobalSkillDisabledRequest = + serde_json::from_value(serde_json::json!({ + "workspaceId": "opaque-id", "skillKey": "project::agents::demo", "disabled": false, + })) + .unwrap(); + assert_eq!(request.workspace_id.as_deref(), Some("opaque-id")); + assert!(request.workspace_path.is_none()); + let restored: super::SetGlobalSkillDisabledRequest = + serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(); + assert_eq!(restored.workspace_id.as_deref(), Some("opaque-id")); + } + #[test] fn skill_validation_reads_and_round_trips_legacy_payloads() { let old = serde_json::json!({ "valid": true, "name": "demo", "description": "existing", "error": null }); @@ -1323,10 +1404,14 @@ mod tests { "---\nname: demo\ndescription: Existing\n---\nBody", ) .unwrap(); - let result = - super::validate_skill_path(temp.path().to_string_lossy().into_owned(), None, None) - .await - .unwrap(); + let result = super::validate_skill_path( + temp.path().to_string_lossy().into_owned(), + None, + None, + None, + ) + .await + .unwrap(); assert!(result.valid); assert!(result.import_preview.is_none()); assert_eq!(result.name.as_deref(), Some("demo")); @@ -1438,16 +1523,21 @@ pub async fn download_skill_market( } let level = request.level.unwrap_or(SkillLocation::Project); + let workspace = resolve_skill_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let workspace_path = if level == SkillLocation::Project { - let path = trim_workspace_path(request.workspace_path.as_deref()) - .ok_or_else(|| "No workspace open, cannot add project-level Skill".to_string())?; - if is_remote_path(&path).await { + let record = workspace + .as_ref() + .ok_or("No workspace open, cannot add project-level Skill")?; + if record.workspace_kind == WorkspaceKind::Remote { return Err( - "Downloading project skills into remote workspaces is not supported yet" - .to_string(), + "Downloading project skills into remote workspaces is not supported yet".into(), ); } - Some(PathBuf::from(path)) + Some(record.root_path.clone()) } else { None }; diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index b527e702ac..a6c5a891fe 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -1,18 +1,17 @@ //! Snapshot Service API use log::{info, warn}; +use openbitfun_core::agentic::workspace::WorkspaceBinding; use openbitfun_core::product_runtime::{ CoreSessionMaintenancePermit, CoreSessionMutationPermit, CoreSessionReadPermit, }; -use openbitfun_core::service::remote_ssh::workspace_state::{ - is_remote_path, workspace_session_identity, -}; use openbitfun_core::service::snapshot::{ ensure_snapshot_manager_for_workspace, get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace, open_snapshot_history_for_workspace, open_snapshot_manager_for_view, FileChangeEntry, OperationType, SnapshotManager, }; -use openbitfun_runtime_ports::{AgentSessionWorkspaceLocation, SessionStoragePathRequest}; +use openbitfun_core::service::workspace::WorkspaceKind; +use openbitfun_runtime_ports::{AgentSessionWorkspaceLocation, SessionStorePort}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::{path::PathBuf, sync::Arc}; @@ -22,6 +21,11 @@ use crate::runtime::{DesktopRuntimeContext, DesktopSessionScopeRequest}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SnapshotRemoteScope { + #[serde(default, alias = "workspaceId")] + pub workspace_id: Option, + /// Host-resolved fact. Never accept a client-supplied classification. + #[serde(skip)] + workspace_kind: Option, #[serde(default, alias = "remoteConnectionId")] pub remote_connection_id: Option, #[serde(default, alias = "remoteSshHost")] @@ -29,25 +33,60 @@ pub struct SnapshotRemoteScope { } impl SnapshotRemoteScope { - fn declares_remote(&self) -> bool { - self.remote_connection_id + fn declares_remote(&self) -> Result { + self.workspace_kind + .as_ref() + .map(|kind| *kind == WorkspaceKind::Remote) + .ok_or_else(|| "Snapshot workspace must be resolved by ID before use".into()) + } + + fn id(&self) -> Result<&str, String> { + self.workspace_id .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .remote_ssh_host - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| "Snapshot workspace ID is required".into()) } } +async fn resolve_snapshot_scope( + path: &mut String, + scope: &mut SnapshotRemoteScope, +) -> Result<(), String> { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = if let Some(id) = scope.workspace_id.as_deref() { + service + .require_workspace(id) + .await + .map_err(|e| e.to_string())? + } else { + // Temporary 1.0.0 wire upgrade boundary. Business logic never keys by path. + service + .resolve_legacy_workspace_reference( + None, + path, + scope.remote_connection_id.as_deref(), + scope.remote_ssh_host.as_deref(), + ) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy snapshot workspace cannot be resolved")? + }; + *path = workspace.root_path.to_string_lossy().into_owned(); + scope.workspace_kind = Some(workspace.workspace_kind); + scope.workspace_id = Some(workspace.id); + Ok(()) +} + async fn ensure_local_runtime_ownership( runtime: &DesktopRuntimeContext, - workspace_path: &str, + scope: &SnapshotRemoteScope, ) -> Result<(), String> { runtime .session_application() .ensure_workspace_runtime_ownership(DesktopSessionScopeRequest { - workspace_path: workspace_path.to_string(), + workspace_id: Some(scope.id()?.to_owned()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) @@ -62,7 +101,7 @@ pub struct RollbackSessionRequest { #[serde(default)] #[serde(alias = "deleteSession")] pub delete_session: bool, // Whether to also delete the session (default false) - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -72,7 +111,7 @@ pub struct RollbackSessionRequest { pub struct AcceptSessionRequest { #[serde(alias = "sessionId")] pub session_id: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -84,7 +123,7 @@ pub struct AcceptFileRequest { pub session_id: String, #[serde(alias = "filePath")] pub file_path: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -94,7 +133,7 @@ pub struct AcceptFileRequest { pub struct GetSessionFilesRequest { #[serde(alias = "sessionId")] pub session_id: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -104,7 +143,7 @@ pub struct GetSessionFilesRequest { pub struct GetSessionTurnsRequest { #[serde(alias = "sessionId")] pub session_id: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -116,7 +155,7 @@ pub struct GetTurnFilesRequest { pub session_id: String, #[serde(alias = "turnIndex")] pub turn_index: usize, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -131,7 +170,7 @@ pub struct GetFileDiffRequest { #[serde(default)] #[serde(alias = "operationId")] pub operation_id: Option, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -141,7 +180,7 @@ pub struct GetFileDiffRequest { pub struct GetBaselineSnapshotDiffRequest { #[serde(rename = "filePath")] pub file_path: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -153,7 +192,7 @@ pub struct GetOperationDiffRequest { pub filePath: String, #[serde(default)] pub operationId: Option, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -163,7 +202,7 @@ pub struct GetOperationDiffRequest { pub struct GetSessionFileDiffStatsRequest { pub sessionId: String, pub filePath: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -173,7 +212,7 @@ pub struct GetSessionFileDiffStatsRequest { pub struct GetOperationSummaryRequest { pub sessionId: String, pub operationId: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -183,7 +222,7 @@ pub struct GetOperationSummaryRequest { pub struct GetSessionStatsRequest { #[serde(alias = "sessionId")] pub session_id: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -193,7 +232,7 @@ pub struct GetSessionStatsRequest { pub struct GetFileChangeHistoryRequest { #[serde(alias = "filePath")] pub file_path: String, - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -201,7 +240,7 @@ pub struct GetFileChangeHistoryRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetAllModifiedFilesRequest { - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -209,7 +248,7 @@ pub struct GetAllModifiedFilesRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotWorkspaceRequest { - #[serde(alias = "workspacePath")] + #[serde(default, alias = "workspacePath")] pub workspace_path: String, #[serde(flatten)] pub remote_scope: SnapshotRemoteScope, @@ -221,8 +260,8 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result } let workspace_dir = PathBuf::from(workspace_path); - // Remote paths don't exist on the local filesystem — skip the existence check - if !is_remote_path(workspace_path).await && !workspace_dir.exists() { + // This helper only receives a resolved local workspace IO root. + if !workspace_dir.exists() { return Err(format!( "Workspace directory does not exist: {}", workspace_path @@ -233,21 +272,19 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result } async fn ensure_snapshot_manager_ready_for( - workspace_path: &str, + workspace_id: &str, caller: &str, ) -> Result, String> { let started_at = std::time::Instant::now(); - // Remote workspaces don't support the snapshot system - if is_remote_path(workspace_path).await { - return Err(format!( - "Snapshot system not supported for remote workspace: {}", - workspace_path - )); + let binding = WorkspaceBinding::resolve(workspace_id) + .await + .map_err(|e| e.to_string())?; + if binding.is_remote() { + return Err("Local snapshot manager requires a local workspace".into()); } + let workspace_dir = resolve_workspace_dir(&binding.root_path_string()).await?; - let workspace_dir = resolve_workspace_dir(workspace_path).await?; - - if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { + if let Some(manager) = get_snapshot_manager_for_workspace(workspace_id) { let duration_ms = started_at.elapsed().as_millis(); if duration_ms >= 20 { log::debug!( @@ -266,7 +303,7 @@ async fn ensure_snapshot_manager_ready_for( workspace_dir.display() ); - initialize_snapshot_manager_for_workspace(workspace_dir.clone(), None) + initialize_snapshot_manager_for_workspace(workspace_id, None) .await .map_err(|e| { format!( @@ -276,7 +313,7 @@ async fn ensure_snapshot_manager_ready_for( ) })?; - let manager = ensure_snapshot_manager_for_workspace(&workspace_dir) + let manager = ensure_snapshot_manager_for_workspace(workspace_id) .map_err(|e| format!("Failed to get snapshot manager: {}", e))?; log::debug!( "Snapshot manager ready: caller={}, workspace={}, source=lazy_init, duration_ms={}", @@ -291,7 +328,7 @@ async fn ensure_local_snapshot_mutation_path( workspace_path: &str, remote_scope: &SnapshotRemoteScope, ) -> Result<(), String> { - if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + if remote_scope.declares_remote()? { return Err(format!( "Snapshot system not supported for remote workspace: {}", workspace_path @@ -305,9 +342,8 @@ async fn ensure_complete_rollback_supported( remote_scope: &SnapshotRemoteScope, explicit_location: Option, ) -> Result<(), String> { - let is_remote = remote_scope.declares_remote() - || explicit_location == Some(AgentSessionWorkspaceLocation::Remote) - || (explicit_location.is_none() && is_remote_path(workspace_path).await); + let is_remote = remote_scope.declares_remote()? + || explicit_location == Some(AgentSessionWorkspaceLocation::Remote); if is_remote { return Err(format!( "Complete rollback is not supported for remote workspaces because complete file snapshot coverage is unavailable. No workspace files or session messages were changed: {workspace_path}" @@ -320,14 +356,13 @@ async fn snapshot_manager_for_view( workspace_path: &str, remote_scope: &SnapshotRemoteScope, ) -> Result, String> { - if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + if remote_scope.declares_remote()? { return Err(format!( "Snapshot view unavailable (snapshot_remote_workspace_unavailable): remote workspace {} has no local snapshot runtime", workspace_path )); } - let workspace_dir = resolve_workspace_dir(workspace_path).await?; - open_snapshot_manager_for_view(&workspace_dir) + open_snapshot_manager_for_view(remote_scope.id()?) .await .map_err(|error| format!("Failed to open snapshot view: {error}")) } @@ -339,27 +374,10 @@ async fn snapshot_manager_for_recorded_operation( workspace_path: &str, remote_scope: &SnapshotRemoteScope, ) -> Result, String> { - if !remote_scope.declares_remote() { + if !remote_scope.declares_remote()? { return snapshot_manager_for_view(workspace_path, remote_scope).await; } - let connection_id = remote_scope - .remote_connection_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let hostname = remote_scope - .remote_ssh_host - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if connection_id.is_none() || hostname.is_none() { - return Err("Snapshot operation view unavailable: the remote Session requires its saved connection id and host; no local fallback was attempted".into()); - } - let identity = - workspace_session_identity(workspace_path, connection_id, hostname).ok_or_else(|| { - "Snapshot operation view unavailable: invalid remote workspace identity".to_string() - })?; - open_snapshot_history_for_workspace(&identity) + open_snapshot_history_for_workspace(remote_scope.id()?) .await .map_err(|error| format!("Snapshot operation view unavailable: {error}")) } @@ -370,20 +388,6 @@ struct SnapshotHistoryMutation { _storage_path: PathBuf, } -async fn begin_snapshot_history_read( - runtime: &DesktopRuntimeContext, - workspace_path: &str, - session_id: &str, -) -> Result { - begin_snapshot_history_read_for_scope( - runtime, - workspace_path, - session_id, - &SnapshotRemoteScope::default(), - ) - .await -} - async fn begin_snapshot_history_read_for_scope( runtime: &DesktopRuntimeContext, workspace_path: &str, @@ -395,34 +399,24 @@ async fn begin_snapshot_history_read_for_scope( // disk; holding the session mutation lock across that load stalls every // waiter on the same session, including the next dialog-turn start. After // warming, the caller's in-permit view lookup is a cache hit. - if remote_scope.declares_remote() { + if remote_scope.declares_remote()? { snapshot_manager_for_recorded_operation(workspace_path, remote_scope).await?; - } else if !is_remote_path(workspace_path).await { - let workspace_dir = resolve_workspace_dir(workspace_path).await?; - open_snapshot_manager_for_view(&workspace_dir) + } else { + open_snapshot_manager_for_view(remote_scope.id()?) .await .map_err(|error| format!("Failed to open snapshot view: {error}"))?; } let compatibility = runtime.session_application().compatibility(); - let storage_path = compatibility - .resolve_persisted_session_storage_path(SessionStoragePathRequest { - workspace_path: PathBuf::from(workspace_path), - remote_connection_id: remote_scope.remote_connection_id.clone(), - remote_ssh_host: remote_scope.remote_ssh_host.clone(), - }) + let storage_path = openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(remote_scope.id()?) .await - .map_err(|error| { - format!("Failed to resolve session storage before snapshot view: {error}") - })?; - let read = if remote_scope.declares_remote() { - let identity = workspace_session_identity( - workspace_path, - remote_scope.remote_connection_id.as_deref(), - remote_scope.remote_ssh_host.as_deref(), - ) - .ok_or_else(|| { - "Snapshot operation view unavailable: invalid Session identity".to_string() - })?; + .map_err(|e| e.to_string())? + .effective_storage_path; + let read = if remote_scope.declares_remote()? { + let identity = WorkspaceBinding::resolve(remote_scope.id()?) + .await + .map_err(|e| e.to_string())? + .session_identity; compatibility .begin_persisted_session_read_for_workspace(&storage_path, session_id, &identity) .await @@ -436,20 +430,15 @@ async fn begin_snapshot_history_read_for_scope( async fn begin_snapshot_history_mutation( runtime: &DesktopRuntimeContext, - workspace_path: &str, + remote_scope: &SnapshotRemoteScope, session_id: &str, ) -> Result { let compatibility = runtime.session_application().compatibility(); - let storage_path = compatibility - .resolve_persisted_session_storage_path(SessionStoragePathRequest { - workspace_path: PathBuf::from(workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - }) + let storage_path = openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(remote_scope.id()?) .await - .map_err(|error| { - format!("Failed to resolve session storage before snapshot mutation: {error}") - })?; + .map_err(|e| e.to_string())? + .effective_storage_path; compatibility .ensure_session_loaded_from_storage_path(&storage_path, session_id, false) .await @@ -479,20 +468,21 @@ async fn begin_snapshot_history_mutation( pub async fn rollback_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: RollbackSessionRequest, + mut request: RollbackSessionRequest, ) -> Result, String> { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope, None) .await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; let _history_mutation = begin_snapshot_history_mutation( runtime.inner(), - &request.workspace_path, + &request.remote_scope, &request.session_id, ) .await?; let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?; + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "rollback_session").await?; let restored_files = manager .rollback_session(&request.session_id) @@ -519,19 +509,46 @@ pub async fn rollback_session( #[tauri::command] pub async fn rollback_session_to_turn( runtime: State<'_, DesktopRuntimeContext>, - request: openbitfun_runtime_ports::AgentSessionRollbackToTurnRequest, + mut request: openbitfun_runtime_ports::AgentSessionRollbackToTurnRequest, ) -> Result { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = if let Some(id) = request.workspace_id.as_deref() { + service + .require_workspace(id) + .await + .map_err(|e| e.to_string())? + } else { + service + .resolve_legacy_workspace_reference( + None, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .map_err(|e| e.to_string())? + .ok_or("Legacy rollback workspace cannot be resolved")? + }; + let location = + if workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote { + AgentSessionWorkspaceLocation::Remote + } else { + request.remote_connection_id = None; + request.remote_ssh_host = None; + AgentSessionWorkspaceLocation::Local + }; + request.workspace_id = Some(workspace.id); + request.workspace_path = workspace.root_path.to_string_lossy().into_owned(); let remote_scope = SnapshotRemoteScope { + workspace_id: request.workspace_id.clone(), + workspace_kind: Some(workspace.workspace_kind), remote_connection_id: request.remote_connection_id.clone(), remote_ssh_host: request.remote_ssh_host.clone(), }; - ensure_complete_rollback_supported( - &request.workspace_path, - &remote_scope, - request.explicit_workspace_location(), - ) - .await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + ensure_complete_rollback_supported(&request.workspace_path, &remote_scope, Some(location)) + .await?; + ensure_local_runtime_ownership(runtime.inner(), &remote_scope).await?; runtime .session_application() .rollback_session_to_turn(request) @@ -543,18 +560,19 @@ pub async fn rollback_session_to_turn( pub async fn accept_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: AcceptSessionRequest, + mut request: AcceptSessionRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; let _history_mutation = begin_snapshot_history_mutation( runtime.inner(), - &request.workspace_path, + &request.remote_scope, &request.session_id, ) .await?; let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?; + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "accept_session").await?; manager .accept_session(&request.session_id) @@ -578,17 +596,19 @@ pub async fn accept_session( pub async fn accept_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: AcceptFileRequest, + mut request: AcceptFileRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; let _history_mutation = begin_snapshot_history_mutation( runtime.inner(), - &request.workspace_path, + &request.remote_scope, &request.session_id, ) .await?; - let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_file").await?; + let manager = + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "accept_file").await?; manager .accept_file(&request.session_id, &request.file_path) @@ -613,17 +633,19 @@ pub async fn accept_file( pub async fn reject_file( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: AcceptFileRequest, + mut request: AcceptFileRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; let _history_mutation = begin_snapshot_history_mutation( runtime.inner(), - &request.workspace_path, + &request.remote_scope, &request.session_id, ) .await?; - let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_file").await?; + let manager = + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "reject_file").await?; let restored_files = manager .reject_file(&request.session_id, &request.file_path) @@ -653,15 +675,15 @@ pub async fn reject_file( #[tauri::command] pub async fn get_session_files( runtime: State<'_, DesktopRuntimeContext>, - request: GetSessionFilesRequest, + mut request: GetSessionFilesRequest, ) -> Result, String> { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } - let read = begin_snapshot_history_read( + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -680,15 +702,15 @@ pub async fn get_session_files( #[tauri::command] pub async fn get_session_turns( runtime: State<'_, DesktopRuntimeContext>, - request: GetSessionTurnsRequest, + mut request: GetSessionTurnsRequest, ) -> Result, String> { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } - let read = begin_snapshot_history_read( + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -701,15 +723,15 @@ pub async fn get_session_turns( #[tauri::command] pub async fn get_turn_files( runtime: State<'_, DesktopRuntimeContext>, - request: GetTurnFilesRequest, + mut request: GetTurnFilesRequest, ) -> Result, String> { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } - let read = begin_snapshot_history_read( + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -731,13 +753,15 @@ pub async fn get_turn_files( #[tauri::command] pub async fn get_file_diff( runtime: State<'_, DesktopRuntimeContext>, - request: GetFileDiffRequest, + mut request: GetFileDiffRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - let read = begin_snapshot_history_read( + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -758,9 +782,10 @@ pub async fn get_file_diff( #[tauri::command] pub async fn get_operation_diff( runtime: State<'_, DesktopRuntimeContext>, - request: GetOperationDiffRequest, + mut request: GetOperationDiffRequest, ) -> Result { - if request.remote_scope.declares_remote() + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + if request.remote_scope.declares_remote()? && request .operationId .as_deref() @@ -833,12 +858,17 @@ pub async fn get_operation_diff( #[tauri::command] pub async fn get_session_file_diff_stats( runtime: State<'_, DesktopRuntimeContext>, - request: GetSessionFileDiffStatsRequest, + mut request: GetSessionFileDiffStatsRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - let read = - begin_snapshot_history_read(runtime.inner(), &request.workspace_path, &request.sessionId) - .await?; + let read = begin_snapshot_history_read_for_scope( + runtime.inner(), + &request.workspace_path, + &request.sessionId, + &request.remote_scope, + ) + .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let stats = manager @@ -856,8 +886,9 @@ pub async fn get_session_file_diff_stats( #[tauri::command] pub async fn get_operation_summary( runtime: State<'_, DesktopRuntimeContext>, - request: GetOperationSummaryRequest, + mut request: GetOperationSummaryRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, @@ -894,15 +925,15 @@ pub async fn get_operation_summary( #[tauri::command] pub async fn get_session_operations( runtime: State<'_, DesktopRuntimeContext>, - request: GetSessionFilesRequest, + mut request: GetSessionFilesRequest, ) -> Result { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(serde_json::Value::Array(Vec::new())); - } - let read = begin_snapshot_history_read( + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -948,18 +979,16 @@ pub async fn get_session_operations( pub async fn accept_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: GetOperationSummaryRequest, + mut request: GetOperationSummaryRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; - let _history_mutation = begin_snapshot_history_mutation( - runtime.inner(), - &request.workspace_path, - &request.sessionId, - ) - .await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; + let _history_mutation = + begin_snapshot_history_mutation(runtime.inner(), &request.remote_scope, &request.sessionId) + .await?; let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_operation").await?; + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "accept_operation").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -994,18 +1023,16 @@ pub async fn accept_operation( pub async fn reject_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, - request: GetOperationSummaryRequest, + mut request: GetOperationSummaryRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; - ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; - let _history_mutation = begin_snapshot_history_mutation( - runtime.inner(), - &request.workspace_path, - &request.sessionId, - ) - .await?; + ensure_local_runtime_ownership(runtime.inner(), &request.remote_scope).await?; + let _history_mutation = + begin_snapshot_history_mutation(runtime.inner(), &request.remote_scope, &request.sessionId) + .await?; let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_operation").await?; + ensure_snapshot_manager_ready_for(request.remote_scope.id()?, "reject_operation").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -1045,20 +1072,15 @@ pub async fn reject_operation( #[tauri::command] pub async fn get_session_stats( runtime: State<'_, DesktopRuntimeContext>, - request: GetSessionStatsRequest, + mut request: GetSessionStatsRequest, ) -> Result { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(serde_json::json!({ - "session_id": request.session_id, - "total_files": 0, - "total_turns": 0, - "total_changes": 0 - })); - } - let read = begin_snapshot_history_read( + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + let read = begin_snapshot_history_read_for_scope( runtime.inner(), &request.workspace_path, &request.session_id, + &request.remote_scope, ) .await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; @@ -1070,8 +1092,9 @@ pub async fn get_session_stats( #[tauri::command] pub async fn get_snapshot_system_stats( - request: SnapshotWorkspaceRequest, + mut request: SnapshotWorkspaceRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let stats = manager @@ -1084,11 +1107,10 @@ pub async fn get_snapshot_system_stats( #[tauri::command] pub async fn get_snapshot_sessions( - request: SnapshotWorkspaceRequest, + mut request: SnapshotWorkspaceRequest, ) -> Result, String> { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; manager @@ -1099,8 +1121,9 @@ pub async fn get_snapshot_sessions( #[tauri::command] pub async fn check_git_isolation( - request: SnapshotWorkspaceRequest, + mut request: SnapshotWorkspaceRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let is_isolated = manager @@ -1117,11 +1140,10 @@ pub async fn check_git_isolation( #[tauri::command] pub async fn get_file_change_history( runtime: State<'_, DesktopRuntimeContext>, - request: GetFileChangeHistoryRequest, + mut request: GetFileChangeHistoryRequest, ) -> Result { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(serde_json::Value::Array(Vec::new())); - } + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let file_path = PathBuf::from(&request.file_path); @@ -1131,9 +1153,13 @@ pub async fn get_file_change_history( .map_err(|error| format!("Failed to list snapshot sessions: {error}"))?; let mut changes = Vec::new(); for session_id in session_ids { - let read = - begin_snapshot_history_read(runtime.inner(), &request.workspace_path, &session_id) - .await?; + let read = begin_snapshot_history_read_for_scope( + runtime.inner(), + &request.workspace_path, + &session_id, + &request.remote_scope, + ) + .await?; let session = manager .get_session_before(&session_id, read.visible_turn_end()) .await @@ -1164,11 +1190,10 @@ pub async fn get_file_change_history( #[tauri::command] pub async fn get_all_modified_files( runtime: State<'_, DesktopRuntimeContext>, - request: GetAllModifiedFilesRequest, + mut request: GetAllModifiedFilesRequest, ) -> Result, String> { - if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let session_ids = manager @@ -1177,9 +1202,13 @@ pub async fn get_all_modified_files( .map_err(|error| format!("Failed to list snapshot sessions: {error}"))?; let mut files = BTreeSet::new(); for session_id in session_ids { - let read = - begin_snapshot_history_read(runtime.inner(), &request.workspace_path, &session_id) - .await?; + let read = begin_snapshot_history_read_for_scope( + runtime.inner(), + &request.workspace_path, + &session_id, + &request.remote_scope, + ) + .await?; files.extend( manager .get_session_files_before(&session_id, read.visible_turn_end()) @@ -1197,8 +1226,9 @@ pub async fn get_all_modified_files( #[tauri::command] pub async fn get_baseline_snapshot_diff( - request: GetBaselineSnapshotDiffRequest, + mut request: GetBaselineSnapshotDiffRequest, ) -> Result { + resolve_snapshot_scope(&mut request.workspace_path, &mut request.remote_scope).await?; let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let file_path = PathBuf::from(&request.file_path); @@ -1236,17 +1266,23 @@ mod tests { get_snapshot_manager_for_workspace, snapshot_manager_for_view, SnapshotRemoteScope, }; + fn resolved_scope(kind: super::WorkspaceKind) -> SnapshotRemoteScope { + SnapshotRemoteScope { + workspace_id: Some("opaque-workspace-id".into()), + workspace_kind: Some(kind), + ..Default::default() + } + } + #[tokio::test] - async fn incomplete_remote_operation_scope_never_opens_colliding_local_history() { + async fn unresolved_scope_never_opens_colliding_local_history() { let workspace = tempfile::tempdir().unwrap(); for scope in [ + SnapshotRemoteScope::default(), SnapshotRemoteScope { remote_connection_id: Some("connection-1".into()), - remote_ssh_host: None, - }, - SnapshotRemoteScope { - remote_connection_id: None, remote_ssh_host: Some("host-1".into()), + ..Default::default() }, ] { let result = super::snapshot_manager_for_recorded_operation( @@ -1254,9 +1290,8 @@ mod tests { &scope, ) .await; - let error = result.err().expect("incomplete identity must fail closed"); - assert!(error.contains("saved connection id and host")); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(result.err().unwrap().contains("resolved by ID")); + assert!(get_snapshot_manager_for_workspace("opaque-workspace-id").is_none()); assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0); } } @@ -1267,7 +1302,7 @@ mod tests { "sessionId": "session-1", "operationId": "operation-1", "workspacePath": "/srv/project", })) .unwrap(); - assert!(!legacy.remote_scope.declares_remote()); + assert!(legacy.remote_scope.declares_remote().is_err()); let remote: super::GetOperationSummaryRequest = serde_json::from_value(serde_json::json!({ "sessionId": "session-1", "operationId": "operation-1", "workspacePath": "/srv/project", "remoteConnectionId": "ssh:user@host:22", "remoteSshHost": "host", @@ -1313,98 +1348,54 @@ mod tests { assert_eq!(request.remote_ssh_host.as_deref(), Some("example.com")); } - #[tokio::test] - async fn remote_snapshot_mutation_is_rejected_before_writer_initialization() { - let workspace = tempfile::tempdir().expect("create workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let remote = - openbitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); - remote - .register_remote_workspace( - workspace_path.clone(), - "snapshot-remote-test".to_string(), - "Snapshot remote test".to_string(), - "snapshot-test-host".to_string(), - ) - .await; - - let error = ensure_local_snapshot_mutation_path(&workspace_path, &Default::default()) - .await - .expect_err("remote mutation must fail closed"); - - assert!(error.contains("not supported for remote workspace")); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + #[test] + fn id_only_wire_round_trip_cannot_supply_the_authoritative_kind() { + let request: super::GetSessionFilesRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", "workspaceId": "opaque-id", "workspaceKind": "remote", + })) + .unwrap(); assert_eq!( - std::fs::read_dir(workspace.path()) - .expect("workspace remains readable") - .count(), - 0 + request.remote_scope.workspace_id.as_deref(), + Some("opaque-id") + ); + assert!(request.workspace_path.is_empty()); + assert!(request.remote_scope.declares_remote().is_err()); + let restored: super::GetSessionFilesRequest = + serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(); + assert_eq!( + restored.remote_scope.workspace_id.as_deref(), + Some("opaque-id") ); - remote - .unregister_remote_workspace("snapshot-remote-test", &workspace_path) - .await; - - let disconnected_scope = SnapshotRemoteScope { - remote_connection_id: Some("snapshot-test-connection".to_string()), - remote_ssh_host: Some("snapshot-test-host".to_string()), - }; - let disconnected_error = - ensure_local_snapshot_mutation_path(&workspace_path, &disconnected_scope) - .await - .expect_err("structured session facts remain remote after registry removal"); - assert!(disconnected_error.contains("not supported for remote workspace")); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); } #[tokio::test] - async fn remote_complete_rollback_reports_missing_file_snapshot_coverage() { - let scope = SnapshotRemoteScope { - remote_connection_id: Some("connection-1".to_string()), - remote_ssh_host: Some("example.com".to_string()), - }; - + async fn remote_snapshot_mutation_is_rejected_before_writer_initialization() { + let workspace = tempfile::tempdir().unwrap(); + let scope = resolved_scope(super::WorkspaceKind::Remote); + let error = + ensure_local_snapshot_mutation_path(&workspace.path().to_string_lossy(), &scope) + .await + .unwrap_err(); + assert!(error.contains("not supported for remote workspace")); + assert!(get_snapshot_manager_for_workspace("opaque-workspace-id").is_none()); + assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0); let error = ensure_complete_rollback_supported("/root/repos", &scope, None) .await - .expect_err("remote rollback must fail before changing files or history"); - - assert_eq!( - error, - "Complete rollback is not supported for remote workspaces because complete file snapshot coverage is unavailable. No workspace files or session messages were changed: /root/repos" - ); + .unwrap_err(); + assert!(error.contains("complete file snapshot coverage is unavailable")); } #[tokio::test] - async fn explicit_local_rollback_identity_wins_over_a_remote_path_collision() { - let workspace = tempfile::tempdir().expect("create local workspace"); - let workspace_path = workspace.path().to_string_lossy().to_string(); - let remote = - openbitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); - remote - .register_remote_workspace( - workspace_path.clone(), - "rollback-path-collision".to_string(), - "Rollback collision test".to_string(), - "remote.example".to_string(), - ) - .await; - - let legacy_error = - ensure_complete_rollback_supported(&workspace_path, &Default::default(), None) - .await - .expect_err("legacy path-only requests must retain remote fallback behavior"); - assert!(legacy_error.contains("not supported for remote workspaces")); - - ensure_complete_rollback_supported( - &workspace_path, - &Default::default(), - Some(openbitfun_runtime_ports::AgentSessionWorkspaceLocation::Local), - ) - .await - .expect("explicit local identity must disambiguate the registered remote path"); - - remote - .unregister_remote_workspace("rollback-path-collision", &workspace_path) - .await; + async fn local_kind_ignores_stale_ssh_metadata() { + let mut scope = resolved_scope(super::WorkspaceKind::Normal); + scope.remote_connection_id = Some("old-connection".into()); + scope.remote_ssh_host = Some("old-host".into()); + ensure_complete_rollback_supported("/srv/shared", &scope, None) + .await + .unwrap(); + ensure_local_snapshot_mutation_path("/srv/shared", &scope) + .await + .unwrap(); } #[test] @@ -1483,17 +1474,21 @@ mod tests { } #[tokio::test] - async fn snapshot_view_does_not_initialize_a_writer() { + async fn snapshot_view_requires_a_registered_id_before_reading_local_history() { let workspace = tempfile::tempdir().expect("create workspace"); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(get_snapshot_manager_for_workspace("opaque-workspace-id").is_none()); - snapshot_manager_for_view( + let result = snapshot_manager_for_view( &workspace.path().to_string_lossy(), - &SnapshotRemoteScope::default(), + &resolved_scope(super::WorkspaceKind::Normal), ) - .await - .expect("an empty read-only view remains available"); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + .await; + assert!( + result.is_err(), + "a path cannot substitute for an unavailable workspace ID" + ); + assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0); + assert!(get_snapshot_manager_for_workspace("opaque-workspace-id").is_none()); } #[tokio::test] @@ -1502,6 +1497,7 @@ mod tests { let scope = SnapshotRemoteScope { remote_connection_id: Some("connection-1".to_string()), remote_ssh_host: Some("host-1".to_string()), + ..resolved_scope(super::WorkspaceKind::Remote) }; let error = match snapshot_manager_for_view(&workspace.path().to_string_lossy(), &scope) @@ -1512,6 +1508,6 @@ mod tests { }; assert!(error.contains("snapshot_remote_workspace_unavailable")); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(get_snapshot_manager_for_workspace("opaque-workspace-id").is_none()); } } diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 59c380e2cd..bc54654957 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -7,22 +7,26 @@ use openbitfun_core::agentic::agents::{ SubagentListScope, SubagentQueryContext, }; use openbitfun_core::service::config::SubagentModelSelection; -use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::path::PathBuf; use tauri::State; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListSubagentsRequest { pub source: Option, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListVisibleSubagentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub parent_agent_type: String, } @@ -30,27 +34,52 @@ pub struct ListVisibleSubagentsRequest { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListManageableSubagentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, pub parent_agent_type: String, } -fn workspace_root_from_request(workspace_path: Option<&str>) -> Option { - workspace_path - .filter(|path| !path.is_empty()) - .map(PathBuf::from) +async fn query_workspace_from_request( + state: &AppState, + workspace_id: Option<&str>, + legacy_path: Option<&str>, +) -> Result<(Option, bool), String> { + if workspace_id.is_none() && legacy_path.is_none() { + return Ok((None, true)); + } + let record = state + .workspace_service + .resolve_legacy_workspace_reference( + workspace_id, + legacy_path.unwrap_or_default(), + None, + None, + ) + .await + .map_err(|error| error.to_string())? + .ok_or("Unknown workspace reference")?; + let local = record.workspace_kind != openbitfun_core::service::workspace::WorkspaceKind::Remote; + Ok((Some(record.id), local)) } -async fn supports_local_external_sources(workspace_path: Option<&str>) -> bool { - match workspace_path { - Some(path) if !path.is_empty() => !is_remote_path(path).await, - _ => true, +pub(super) async fn local_workspace_from_request( + state: &AppState, + workspace_id: Option<&str>, + legacy_path: Option<&str>, +) -> Result, String> { + let (id, local) = query_workspace_from_request(state, workspace_id, legacy_path).await?; + if !local { + return Err("Custom Agent management is unsupported for remote workspaces".into()); } + Ok(id) } fn reject_external_subagent_mutation( state: &AppState, subagent_id: &str, - workspace: Option<&std::path::Path>, + workspace: Option<&str>, ) -> Result<(), String> { if state .agent_registry @@ -68,9 +97,12 @@ pub async fn list_subagents( state: State<'_, AppState>, request: ListSubagentsRequest, ) -> Result, String> { - let external_sources_supported = - supports_local_external_sources(request.workspace_path.as_deref()).await; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let (workspace, external_sources_supported) = query_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let query_workspace = external_sources_supported .then_some(workspace.as_deref()) .flatten(); @@ -78,7 +110,7 @@ pub async fn list_subagents( .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: None, - workspace_root: query_workspace, + workspace_id: query_workspace, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, external_sources_supported, @@ -101,9 +133,12 @@ pub async fn list_visible_subagents( state: State<'_, AppState>, request: ListVisibleSubagentsRequest, ) -> Result, String> { - let external_sources_supported = - supports_local_external_sources(request.workspace_path.as_deref()).await; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let (workspace, external_sources_supported) = query_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let query_workspace = external_sources_supported .then_some(workspace.as_deref()) .flatten(); @@ -111,7 +146,7 @@ pub async fn list_visible_subagents( .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some(request.parent_agent_type.as_str()), - workspace_root: query_workspace, + workspace_id: query_workspace, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported, @@ -124,9 +159,12 @@ pub async fn list_manageable_subagents( state: State<'_, AppState>, request: ListManageableSubagentsRequest, ) -> Result, String> { - let external_sources_supported = - supports_local_external_sources(request.workspace_path.as_deref()).await; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let (workspace, external_sources_supported) = query_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let query_workspace = external_sources_supported .then_some(workspace.as_deref()) .flatten(); @@ -134,7 +172,7 @@ pub async fn list_manageable_subagents( .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some(request.parent_agent_type.as_str()), - workspace_root: query_workspace, + workspace_id: query_workspace, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, external_sources_supported, @@ -146,6 +184,9 @@ pub async fn list_manageable_subagents( #[serde(rename_all = "camelCase")] pub struct GetSubagentDetailRequest { pub subagent_id: String, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -154,7 +195,12 @@ pub async fn get_subagent_detail( state: State<'_, AppState>, request: GetSubagentDetailRequest, ) -> Result { - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_subagent_mutation(&state, &request.subagent_id, workspace.as_deref())?; state .agent_registry @@ -167,6 +213,9 @@ pub async fn get_subagent_detail( #[serde(rename_all = "camelCase")] pub struct DeleteSubagentRequest { pub subagent_id: String, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -176,7 +225,12 @@ pub async fn delete_subagent( request: DeleteSubagentRequest, ) -> Result<(), String> { let subagent_id = request.subagent_id; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_subagent_mutation(&state, &subagent_id, workspace.as_deref())?; let file_path = state @@ -209,6 +263,9 @@ pub struct UpdateSubagentRequest { pub tools: Option>, pub readonly: Option, pub review: Option, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -223,7 +280,12 @@ pub async fn update_subagent( if request.prompt.trim().is_empty() { return Err("Prompt cannot be empty".to_string()); } - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_subagent_mutation(&state, &request.subagent_id, workspace.as_deref())?; state .agent_registry @@ -257,6 +319,9 @@ pub struct CreateSubagentRequest { pub tools: Option>, pub readonly: Option, pub review: Option, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -315,7 +380,12 @@ pub async fn create_subagent( ) -> Result<(), String> { let name = request.name.trim(); validate_agent_name(name)?; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; if request.level == SubagentLevel::Project && workspace.is_none() { return Err("Project-level Agent requires opening a workspace first".to_string()); @@ -342,8 +412,13 @@ pub async fn create_subagent( let agents_dir = match request.level { SubagentLevel::User => pm.user_agents_dir(), SubagentLevel::Project => { - let root = workspace.as_deref().ok_or("Workspace path not available")?; - pm.project_agents_dir(root) + let id = workspace.as_deref().ok_or("Workspace ID not available")?; + let record = state + .workspace_service + .require_workspace(id) + .await + .map_err(|error| error.to_string())?; + pm.project_agents_dir(&record.root_path) } }; @@ -400,6 +475,9 @@ pub async fn create_subagent( #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReloadSubagentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -408,11 +486,16 @@ pub async fn reload_subagents( state: State<'_, AppState>, request: ReloadSubagentsRequest, ) -> Result<(), String> { - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()) - .ok_or_else(|| "workspacePath is required to reload project subagents".to_string())?; + let workspace_root = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await? + .ok_or_else(|| "workspaceId is required to reload project subagents".to_string())?; state .agent_registry - .load_custom_subagents(workspace_root.as_path()) + .load_custom_subagents(workspace_root.as_str()) .await; Ok(()) } @@ -436,6 +519,9 @@ pub struct UpdateSubagentConfigRequest { pub model: Option, #[serde(default)] pub clear_model_override: bool, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. pub workspace_path: Option, } @@ -452,7 +538,12 @@ pub async fn update_subagent_config( request: UpdateSubagentConfigRequest, ) -> Result { let subagent_id = &request.subagent_id; - let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let workspace = local_workspace_from_request( + &state, + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; reject_external_subagent_mutation(&state, subagent_id, workspace.as_deref())?; if let Some(workspace) = workspace.as_deref() { state.agent_registry.load_custom_subagents(workspace).await; @@ -511,13 +602,12 @@ pub async fn update_subagent_config( if let Some(workspace) = workspace.as_deref() { return Err(format!( "Project Sub-Agent '{}' was not found in workspace '{}'", - subagent_id, - workspace.display() + subagent_id, workspace )); } return Err(format!( - "workspacePath is required to update project Sub-Agent '{}'", + "workspaceId is required to update project Sub-Agent '{}'", subagent_id )); } diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index eed4292e1d..ee43d50e51 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -429,6 +429,10 @@ pub struct CheckCommandResponse { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RunCommandRequest { + #[serde(default)] + pub controller_local: bool, + #[serde(default)] + pub workspace_id: Option, pub command: String, #[serde(default)] pub args: Vec, @@ -495,8 +499,16 @@ pub async fn check_commands_exist( pub async fn run_system_command( request: RunCommandRequest, ) -> Result { - if let Some(cwd) = request.cwd.as_deref() { - if openbitfun_core::service::remote_ssh::workspace_state::is_remote_path(cwd.trim()).await { + if request.workspace_id.is_some() || request.cwd.is_some() { + let cwd = request.cwd.as_deref().unwrap_or_default(); + if openbitfun_core::service::workspace::remote_io_for_legacy_or_id( + request.workspace_id.as_deref(), + request.controller_local, + cwd.trim(), + ) + .await + .map_err(|e| e.to_string())? + { return Err(format!( "run_system_command cannot execute '{}' in remote workspace directory '{}': this command spawns controller-local processes only; local filesystem fallback was not attempted", request.command, cwd @@ -550,7 +562,7 @@ pub async fn set_macos_edit_menu_mode( .get_config::(Some("app.language")) .await .unwrap_or_else(|_| "zh-CN".to_string()); - let menubar_mode = if state.workspace_path.read().await.is_some() { + let menubar_mode = if state.workspace_id.read().await.is_some() { crate::macos_menubar::MenubarMode::Workspace } else { crate::macos_menubar::MenubarMode::Startup @@ -1204,6 +1216,8 @@ mod remote_guard_tests { .await; let error = run_system_command(RunCommandRequest { + controller_local: false, + workspace_id: None, command: "git".to_string(), args: vec!["status".to_string()], cwd: Some(format!("{REMOTE_ROOT}/repo")), diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index 65cb493437..6395941a19 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -107,6 +107,8 @@ impl Default for TerminalState { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { + #[serde(default)] + pub workspace_id: Option, pub session_id: Option, pub name: Option, pub shell_type: Option, @@ -124,6 +126,8 @@ pub struct CreateSessionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub id: String, pub name: String, pub shell_type: String, @@ -144,6 +148,7 @@ pub struct SessionResponse { impl From for SessionResponse { fn from(resp: CoreSessionResponse) -> Self { Self { + workspace_id: resp.workspace_id, id: resp.id, name: resp.name, shell_type: format!("{:?}", resp.shell_type), @@ -334,15 +339,6 @@ pub async fn terminal_get_shells( Ok(shells.into_iter().map(ShellInfo::from).collect()) } -/// Check if the given working directory belongs to any registered remote workspace. -/// Returns (connection_id, remote_cwd) if so. -async fn lookup_remote_for_terminal(working_directory: Option<&str>) -> Option<(String, String)> { - let wd = working_directory?; - let manager = get_remote_workspace_manager()?; - let entry = manager.lookup_connection(wd, None).await?; - Some((entry.connection_id, wd.to_string())) -} - /// Try to find session in remote terminal manager. Returns true if found. async fn is_remote_session(session_id: &str) -> bool { if let Some(manager) = get_remote_workspace_manager() { @@ -477,11 +473,16 @@ async fn spawn_remote_pty_session( .await .map_err(|e| format!("Failed to create remote session: {}", e))?; - let session = result.session; + let mut session = result.session; + terminal_manager + .set_workspace_id(&session.id, request.workspace_id.clone()) + .await; + session.workspace_id = request.workspace_id.clone(); let mut rx = result.output_rx; let session_id = session.id.clone(); let response = SessionResponse { + workspace_id: session.workspace_id, id: session.id, name: session.name, shell_type: "Remote".to_string(), @@ -550,10 +551,54 @@ async fn spawn_remote_pty_session( #[tauri::command] pub async fn terminal_create( _app: AppHandle, - request: CreateSessionRequest, + mut request: CreateSessionRequest, state: State<'_, TerminalState>, app_state: State<'_, AppState>, ) -> Result { + if let Some(id) = request.workspace_id.as_deref() { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = service + .require_workspace(id) + .await + .map_err(|e| e.to_string())?; + request.connection_id = match workspace.workspace_kind { + openbitfun_core::service::workspace::WorkspaceKind::Remote => Some( + workspace + .remote_ssh_connection_id() + .ok_or("Remote workspace is missing its saved SSH connection ID")? + .to_owned(), + ), + _ => Some(String::new()), + }; + request + .working_directory + .get_or_insert_with(|| workspace.root_path.to_string_lossy().into_owned()); + } else if request.connection_id.is_none() { + // Upgrade-only pre-ID terminal payload. Never infer a target from cwd. + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let workspace = service + .resolve_legacy_workspace_reference( + None, + request.working_directory.as_deref().unwrap_or_default(), + None, + None, + ) + .await + .map_err(|e| e.to_string())? + .ok_or("Terminal workspace ID is unavailable")?; + request.workspace_id = Some(workspace.id.clone()); + request.connection_id = match workspace.workspace_kind { + openbitfun_core::service::workspace::WorkspaceKind::Remote => Some( + workspace + .remote_ssh_connection_id() + .ok_or("Remote workspace is missing its saved SSH connection ID")? + .to_owned(), + ), + _ => Some(String::new()), + }; + } // Explicit SSH connection (Relay Deploy wizard) — no remote workspace required. // Register AppState's RemoteTerminalManager onto the global workspace manager so // subsequent terminal_get/write/resize/close look up the same session store. @@ -595,28 +640,6 @@ pub async fn terminal_create( .await; } - if request.connection_id.as_deref() != Some("") { - if let Some((connection_id, remote_cwd)) = - lookup_remote_for_terminal(request.working_directory.as_deref()).await - { - if let Some(remote_manager) = get_remote_workspace_manager() { - let terminal_manager = remote_manager - .get_terminal_manager() - .await - .ok_or("Remote terminal manager not available")?; - - return spawn_remote_pty_session( - &_app, - &terminal_manager, - &connection_id, - &request, - Some(remote_cwd.as_str()), - ) - .await; - } - } - } - let api = state.get_or_init_api().await?; let parsed_shell_type = request.shell_type.and_then(|s| parse_shell_type(&s)); @@ -638,6 +661,20 @@ pub async fn terminal_create( .await .map_err(|e| format!("Failed to create session: {}", e))?; + let mut session = session; + if let Some(workspace_id) = request.workspace_id { + api.session_manager() + .set_owner( + &session.id, + openbitfun_core::service::terminal::session::SessionOwner { + id: workspace_id.clone(), + owner_type: openbitfun_core::service::terminal::session::OwnerType::Workspace, + }, + ) + .await + .map_err(|e| e.to_string())?; + session.workspace_id = Some(workspace_id); + } register_terminal_stream(&session.id).await?; Ok(SessionResponse::from(session)) } @@ -652,6 +689,7 @@ pub async fn terminal_get( if let Some(terminal_manager) = remote_manager.get_terminal_manager().await { if let Some(session) = terminal_manager.get_session(&session_id).await { return Ok(SessionResponse { + workspace_id: session.workspace_id, id: session.id, name: session.name, shell_type: "Remote".to_string(), @@ -689,6 +727,7 @@ pub async fn terminal_list( if let Some(terminal_manager) = remote_manager.get_terminal_manager().await { let remote_sessions = terminal_manager.list_sessions().await; all_sessions.extend(remote_sessions.into_iter().map(|s| SessionResponse { + workspace_id: s.workspace_id, id: s.id, name: s.name, shell_type: "Remote".to_string(), diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 192a70a7b8..31bf027b33 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -15,9 +15,7 @@ use openbitfun_core::agentic::{ WorkspaceBinding, }; use openbitfun_core::product_runtime::CoreRuntimeServicesProvider; -use openbitfun_core::service::remote_ssh::workspace_state::{ - get_remote_workspace_manager, lookup_remote_connection, workspace_session_identity, -}; +use openbitfun_core::service::remote_ssh::workspace_state::get_remote_workspace_manager; use openbitfun_core::util::elapsed_ms_u64; use crate::runtime::DesktopRuntimeContext; @@ -40,6 +38,10 @@ pub async fn get_chat_mcp_catalog( pub struct ToolExecutionRequest { pub tool_name: String, pub input: serde_json::Value, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only old-client payload; converted once before tool execution. + #[serde(default)] pub workspace_path: Option, pub context: Option>, pub safe_mode: Option, @@ -72,6 +74,10 @@ pub struct ToolExecutionResponse { pub struct ToolValidationRequest { pub tool_name: String, pub input: serde_json::Value, + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only old-client payload; converted once before tool execution. + #[serde(default)] pub workspace_path: Option, } @@ -87,37 +93,30 @@ pub struct ToolValidationResponse { /// Builds the tool context for a direct tool call. A remote workspace whose /// SSH provider cannot be built is an error: a context without workspace /// services would otherwise resolve remote paths against this machine. -async fn build_tool_context(workspace_path: Option<&str>) -> Result { - let normalized_workspace_path = workspace_path - .map(str::trim) - .filter(|path| !path.is_empty()); - - let workspace = match normalized_workspace_path { - Some(path) => { - if let Some(entry) = lookup_remote_connection(path).await { - let identity = workspace_session_identity( - path, - Some(&entry.connection_id), - Some(&entry.ssh_host), - ) - .unwrap_or_else(|| { - openbitfun_core::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { - hostname: entry.ssh_host.clone(), - logical_workspace_path: entry.remote_root.clone(), - remote_connection_id: Some(entry.connection_id.clone()), - } - }); - Some(WorkspaceBinding::new_remote( - None, - PathBuf::from(path), - entry.connection_id, - entry.connection_name, - identity, - )) - } else { - Some(WorkspaceBinding::new(None, PathBuf::from(path))) - } - } +async fn build_tool_context( + workspace_id: Option<&str>, + legacy_path: Option<&str>, +) -> Result { + let id = if let Some(id) = workspace_id { + Some(id.to_owned()) + } else if let Some(path) = legacy_path.filter(|path| !path.trim().is_empty()) { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + let record = service + .resolve_legacy_workspace_reference(None, path, None, None) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Legacy workspace reference is unavailable".to_string())?; + Some(record.id) + } else { + None + }; + let workspace = match id { + Some(id) => Some( + WorkspaceBinding::resolve(&id) + .await + .map_err(|error| error.to_string())?, + ), None => None, }; @@ -268,10 +267,17 @@ pub async fn validate_tool_input( ensure_workspace_requirement( &request.tool_name, &request.input, - request.workspace_path.as_deref(), + request + .workspace_id + .as_deref() + .or(request.workspace_path.as_deref()), )?; - let context = build_tool_context(request.workspace_path.as_deref()).await?; + let context = build_tool_context( + request.workspace_id.as_deref(), + request.workspace_path.as_deref(), + ) + .await?; let validation_result = tool.validate_input(&request.input, Some(&context)).await; @@ -299,10 +305,17 @@ pub async fn execute_tool(request: ToolExecutionRequest) -> Result, budget_roots: Vec, ) { - let workspace_path = state.workspace_path.clone(); + let workspace_id = state.workspace_id.clone(); let workspace_search_service = state.workspace_search_service.clone(); tokio::spawn(async move { if !workspace_search_runtime_available().await { return; } - let focused_path = workspace_path.read().await.clone(); - let protected_roots = focused_path - .as_ref() - .filter(|path| { - workspaces.iter().any(|workspace| { - workspace.workspace_kind != WorkspaceKind::Remote - && workspace.root_path == **path - }) + let focused_id = workspace_id.read().await.clone(); + let protected_roots = workspaces + .iter() + .filter(|workspace| { + workspace.workspace_kind != WorkspaceKind::Remote + && focused_id.as_deref() == Some(workspace.id.as_str()) }) - .cloned() - .into_iter() + .map(|workspace| workspace.root_path.clone()) .collect(); workspace_search_service .enforce_index_disk_budget(budget_roots, protected_roots) @@ -55,7 +52,7 @@ pub fn spawn_restored_workspace_auto_index( if let Some(focused) = workspaces.iter().find(|workspace| { workspace.workspace_kind != WorkspaceKind::Remote - && focused_path.as_ref() == Some(&workspace.root_path) + && focused_id.as_deref() == Some(workspace.id.as_str()) }) { workspace_search_service .schedule_auto_index( @@ -67,7 +64,7 @@ pub fn spawn_restored_workspace_auto_index( for workspace in workspaces { if workspace.workspace_kind == WorkspaceKind::Remote - || focused_path.as_ref() == Some(&workspace.root_path) + || focused_id.as_deref() == Some(workspace.id.as_str()) { continue; } @@ -82,7 +79,7 @@ pub fn spawn_restored_workspace_auto_index( } async fn warm_workspace_background_services( - workspace_path: Arc>>, + workspace_id: Arc>>, agent_registry: Arc, workspace_search_service: Arc, workspace_info: WorkspaceInfo, @@ -90,9 +87,11 @@ async fn warm_workspace_background_services( let started_at = Instant::now(); let target_path = workspace_info.root_path.clone(); - if is_workspace_active(&workspace_path, &target_path).await { + if is_workspace_active(&workspace_id, &workspace_info.id).await { let subagents_started_at = Instant::now(); - agent_registry.load_custom_agents(Some(&target_path)).await; + agent_registry + .load_custom_agents(Some(&workspace_info.id)) + .await; debug!( "Workspace custom agent warmup completed: path={}, elapsed_ms={}", target_path.display(), @@ -101,13 +100,13 @@ async fn warm_workspace_background_services( } if workspace_info.workspace_kind != WorkspaceKind::Remote - && is_workspace_active(&workspace_path, &target_path).await + && is_workspace_active(&workspace_id, &workspace_info.id).await && workspace_search_runtime_available().await { let search_started_at = Instant::now(); match workspace_search_service.open_repo(&target_path).await { Ok(_) => { - let still_active = is_workspace_active(&workspace_path, &target_path).await; + let still_active = is_workspace_active(&workspace_id, &workspace_info.id).await; workspace_search_service .schedule_auto_index( target_path.clone(), @@ -149,13 +148,10 @@ async fn warm_workspace_background_services( ); } -async fn is_workspace_active( - workspace_path: &Arc>>, - target_path: &Path, -) -> bool { - workspace_path +async fn is_workspace_active(workspace_id: &Arc>>, target_id: &str) -> bool { + workspace_id .read() .await .as_ref() - .is_some_and(|current| current == target_path) + .is_some_and(|current| current == target_id) } diff --git a/src/apps/desktop/src/api/worktree_api.rs b/src/apps/desktop/src/api/worktree_api.rs index c72f1e1555..033156139b 100644 --- a/src/apps/desktop/src/api/worktree_api.rs +++ b/src/apps/desktop/src/api/worktree_api.rs @@ -1,6 +1,13 @@ //! Thin desktop adapter for product-owned managed worktrees. +//! +//! The owning project workspace is identified by its workspace ID. The +//! adapter resolves that ID to the authoritative workspace record, rejects +//! remote workspaces from `workspace_kind`, and only then hands the record's +//! local root path to the worktree service as a Git IO operand. A path-only +//! request is a legacy shape and is resolved through the workspace +//! legacy-compat boundary. -use openbitfun_core::service::remote_ssh::lookup_remote_connection; +use openbitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind}; use openbitfun_core::service::worktree::{ WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest, WorktreeMutationResult, WorktreeProjectListRequest, WorktreeProjectSummary, @@ -17,22 +24,78 @@ fn remote_unsupported() -> WorktreeError { } } -async fn ensure_local(project_workspace_path: &str) -> Result<(), WorktreeError> { - if lookup_remote_connection(project_workspace_path) - .await - .is_some() - { - Err(remote_unsupported()) - } else { - Ok(()) +fn invalid_project(message: impl Into) -> WorktreeError { + WorktreeError { + code: WorktreeErrorCode::InvalidPath, + message: message.into(), + recovery_path: None, + } +} + +/// Resolve the local project root that owns a managed-worktree request. +/// +/// Returns the authoritative root path from the workspace record when an ID is +/// supplied. Without an ID the legacy path is accepted only if it does not +/// resolve to a remote workspace record; an ambiguous legacy path fails loudly. +async fn resolve_local_project( + project_workspace_id: Option<&str>, + project_workspace_path: &str, +) -> Result { + let project_workspace_id = project_workspace_id + .map(str::trim) + .filter(|id| !id.is_empty()); + let Some(service) = get_global_workspace_service() else { + if project_workspace_id.is_some() { + return Err(invalid_project( + "Workspace service is not initialized; cannot resolve the project workspace", + )); + } + return Ok(project_workspace_path.to_string()); + }; + match project_workspace_id { + Some(id) => { + let workspace = service + .require_workspace(id) + .await + .map_err(|error| invalid_project(error.to_string()))?; + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(remote_unsupported()); + } + Ok(workspace.root_path.to_string_lossy().into_owned()) + } + None => { + let trimmed = project_workspace_path.trim(); + if trimmed.is_empty() { + return Err(invalid_project( + "A project workspace ID is required for managed worktrees", + )); + } + match service + .resolve_legacy_workspace_reference(None, trimmed, None, None) + .await + .map_err(|error| invalid_project(error.to_string()))? + { + Some(workspace) if workspace.workspace_kind == WorkspaceKind::Remote => { + Err(remote_unsupported()) + } + Some(workspace) => Ok(workspace.root_path.to_string_lossy().into_owned()), + // Not an open workspace: a plain local repository path handed + // in by a pre-ID client. Remote paths always have a record. + None => Ok(trimmed.to_string()), + } + } } } #[tauri::command] pub async fn worktree_list( - request: WorktreeListRequest, + mut request: WorktreeListRequest, ) -> Result, WorktreeError> { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::list(request).await } @@ -45,39 +108,55 @@ pub async fn worktree_list_projects( #[tauri::command] pub async fn worktree_create( - request: WorktreeCreateRequest, + mut request: WorktreeCreateRequest, ) -> Result { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::create(request).await } #[tauri::command] pub async fn worktree_create_branch( - request: WorktreeCreateBranchRequest, + mut request: WorktreeCreateBranchRequest, ) -> Result { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::create_branch(request).await } #[tauri::command] pub async fn worktree_promote( - request: WorktreePromoteRequest, + mut request: WorktreePromoteRequest, ) -> Result { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::promote(request).await } #[tauri::command] pub async fn worktree_remove( - request: WorktreeRemoveRequest, + mut request: WorktreeRemoveRequest, ) -> Result { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::remove(request).await } -/// Toggle worktree isolation for a single session. The optional project path -/// lets the product layer locate view-only persisted sessions; remote checks -/// and repository resolution remain in that shared layer. +/// Toggle worktree isolation for a single session. The optional project +/// workspace ID lets the product layer locate view-only persisted sessions; +/// remote checks and repository resolution remain in that shared layer. #[tauri::command] pub async fn worktree_bind_session( request: WorktreeSessionBindingRequest, @@ -87,8 +166,12 @@ pub async fn worktree_bind_session( #[tauri::command] pub async fn worktree_recreate( - request: WorktreeRecreateRequest, + mut request: WorktreeRecreateRequest, ) -> Result { - ensure_local(&request.project_workspace_path).await?; + request.project_workspace_path = resolve_local_project( + request.project_workspace_id.as_deref(), + &request.project_workspace_path, + ) + .await?; WorktreeService::recreate(request).await } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7198e21504..9de19979dc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1064,7 +1064,7 @@ pub async fn run() { let app_handle_for_menu = app.handle().clone(); let app_state: tauri::State<'_, api::app_state::AppState> = app.state(); let config_service = app_state.config_service.clone(); - let workspace_path = app_state.workspace_path.clone(); + let workspace_id = app_state.workspace_id.clone(); let macos_edit_menu_mode = app_state.macos_edit_menu_mode.clone(); tokio::spawn(async move { @@ -1073,7 +1073,7 @@ pub async fn run() { .await .unwrap_or_else(|_| "zh-CN".to_string()); - let has_workspace = workspace_path.read().await.is_some(); + let has_workspace = workspace_id.read().await.is_some(); let mode = if has_workspace { crate::macos_menubar::MenubarMode::Workspace } else { @@ -2716,7 +2716,8 @@ fn spawn_workspace_search_feature_listener(app_handle: tauri::AppHandle) { let app_state: tauri::State<'_, api::AppState> = app_handle.state(); let workspace_search_service = app_state.workspace_search_service.clone(); - let workspace_path = app_state.workspace_path.clone(); + let workspace_id = app_state.workspace_id.clone(); + let workspace_service = app_state.workspace_service.clone(); tokio::spawn(async move { let mut feature_enabled = @@ -2757,14 +2758,17 @@ fn spawn_workspace_search_feature_listener(app_handle: tauri::AppHandle) { continue; } - let current_workspace = workspace_path.read().await.clone(); + let selected_id = workspace_id.read().await.clone(); + let current_workspace = if let Some(id) = selected_id { + workspace_service.get_workspace(&id).await + } else { + None + }; if let Some(current_workspace) = current_workspace { - let workspace_str = current_workspace.to_string_lossy().to_string(); - if !openbitfun_core::service::remote_ssh::workspace_state::is_remote_path( - workspace_str.trim(), - ) - .await + if current_workspace.workspace_kind + != openbitfun_core::service::workspace::WorkspaceKind::Remote { + let current_workspace = current_workspace.root_path; match workspace_search_service.open_repo(¤t_workspace).await { Ok(_) => { workspace_search_service.schedule_auto_index( diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index cabc6069e4..2360c8bc73 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -21,9 +21,8 @@ use openbitfun_core::agentic::core::Session; use openbitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage}; use openbitfun_core::agentic::session::SessionViewRestoreTiming; use openbitfun_core::product_runtime::{CoreAgentRuntimeCompatibility, CoreProductAgentRuntime}; -use openbitfun_core::service::remote_ssh::workspace_state::{ - get_effective_session_path, LOCAL_WORKSPACE_SSH_HOST, -}; +#[cfg(test)] +use openbitfun_core::service::remote_ssh::workspace_state::get_effective_session_path; use openbitfun_core::service::remote_ssh::SSHConnectionManager; use openbitfun_core::service::session::{ DialogTurnData, DialogTurnKind, SessionContextUsage, SessionMetadata, SessionStatus, @@ -53,6 +52,7 @@ pub enum UiSessionMetadataField { #[derive(Debug, Clone)] pub(crate) struct DesktopSessionScopeRequest { + pub workspace_id: Option, pub workspace_path: String, pub remote_connection_id: Option, pub remote_ssh_host: Option, @@ -158,6 +158,7 @@ fn overlay_live_session_state(restored: &mut Session, live: Option) { #[derive(Clone)] struct ResolvedDesktopSessionScope { + workspace_id: String, workspace_path: String, effective_storage_path: PathBuf, remote_connection_id: Option, @@ -169,103 +170,62 @@ struct ResolvedDesktopSessionScope { #[derive(Clone)] struct DesktopSessionScopeResolver { workspace_service: Arc, - ssh_manager: Arc>>, } impl DesktopSessionScopeResolver { - async fn resolve(&self, request: DesktopSessionScopeRequest) -> ResolvedDesktopSessionScope { - let remote_connection_id = normalized_optional(request.remote_connection_id.as_deref()); - let requested_remote_ssh_host = normalized_remote_ssh_host( - remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ); - let registered_remote_ssh_host = - if let Some(connection_id) = remote_connection_id.as_deref() { - self.workspace_service - .remote_ssh_host_for_remote_workspace(connection_id, &request.workspace_path) - .await - } else { - None - }; - let remote_binding_verified = remote_connection_id.is_some() - && registered_remote_ssh_host - .as_deref() - .is_some_and(|registered| { - requested_remote_ssh_host - .as_deref() - .is_none_or(|requested| requested.eq_ignore_ascii_case(registered)) - }); - let mut saved_remote_ssh_host = None; - if requested_remote_ssh_host.is_none() && registered_remote_ssh_host.is_none() { - if let Some(connection_id) = remote_connection_id.as_deref() { - let manager = self.ssh_manager.read().await.clone(); - if let Some(manager) = manager { - saved_remote_ssh_host = manager - .get_saved_host_for_connection_id(connection_id) - .await; - } - } + async fn resolve( + &self, + request: DesktopSessionScopeRequest, + ) -> DesktopSessionApplicationResult { + let workspace = if let Some(id) = request.workspace_id.as_deref() { + self.workspace_service.require_workspace(id).await + } else { + // Upgrade-only ingress for existing 1.0.0 clients. Runtime and + // persistence below this boundary consume the resolved object. + self.workspace_service + .resolve_legacy_workspace_reference( + None, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .and_then(|record| { + record.ok_or_else(|| OpenBitFunError::service("Workspace ID is unavailable")) + }) } - let resolved_remote_ssh_host = choose_remote_ssh_host( - requested_remote_ssh_host.as_deref(), - registered_remote_ssh_host.as_deref(), - saved_remote_ssh_host.as_deref(), - ); - let effective_storage_path = get_effective_session_path( - &request.workspace_path, - remote_connection_id.as_deref(), - resolved_remote_ssh_host.as_deref(), - ) - .await; - - ResolvedDesktopSessionScope { - workspace_path: request.workspace_path, - effective_storage_path, + .map_err(desktop_core_session_error)?; + use openbitfun_runtime_ports::SessionStorePort; + let storage = openbitfun_core::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; + let remote = + workspace.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; + let remote_connection_id = if remote { + workspace.remote_ssh_connection_id().map(str::to_owned) + } else { + None + }; + let host = if remote { + workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::to_owned) + } else { + None + }; + Ok(ResolvedDesktopSessionScope { + workspace_id: workspace.id.clone(), + workspace_path: workspace.root_path.to_string_lossy().into_owned(), + effective_storage_path: storage.effective_storage_path, remote_connection_id, - requested_remote_ssh_host, - resolved_remote_ssh_host, - remote_binding_verified, - } - } -} - -fn normalized_optional(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn normalized_remote_ssh_host( - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, -) -> Option { - let host = normalized_optional(remote_ssh_host)?; - if remote_connection_id.is_none() && is_local_workspace_host(&host) { - return None; + requested_remote_ssh_host: host.clone(), + resolved_remote_ssh_host: host, + remote_binding_verified: remote, + }) } - Some(host) -} - -fn is_local_workspace_host(host: &str) -> bool { - let host = host.to_ascii_lowercase(); - host == LOCAL_WORKSPACE_SSH_HOST - || host.starts_with("localhost:") - || host == "127.0.0.1" - || host.starts_with("127.0.0.1:") - || host == "::1" - || host == "[::1]" - || host.starts_with("[::1]:") -} - -fn choose_remote_ssh_host( - requested: Option<&str>, - registered: Option<&str>, - saved: Option<&str>, -) -> Option { - normalized_optional(requested) - .or_else(|| normalized_optional(registered)) - .or_else(|| normalized_optional(saved)) } #[async_trait] @@ -288,7 +248,7 @@ impl DesktopSessionApplication { scheduler: Arc, token_usage_service: Arc, workspace_service: Arc, - ssh_manager: Arc>>, + _ssh_manager: Arc>>, host_effects: Arc, session_event_journal: Arc, ) -> Result { @@ -304,10 +264,7 @@ impl DesktopSessionApplication { coordinator, agent_runtime, compatibility, - scope_resolver: DesktopSessionScopeResolver { - workspace_service, - ssh_manager, - }, + scope_resolver: DesktopSessionScopeResolver { workspace_service }, host_effects, }) } @@ -344,7 +301,7 @@ impl DesktopSessionApplication { async fn resolved_scope( &self, request: DesktopSessionScopeRequest, - ) -> ResolvedDesktopSessionScope { + ) -> DesktopSessionApplicationResult { self.scope_resolver.resolve(request).await } @@ -380,16 +337,15 @@ impl DesktopSessionApplication { &self, request: DesktopSessionScopeRequest, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope) } pub(crate) async fn ensure_configured_plugin_instance( &self, request: DesktopSessionScopeRequest, - project_id: Option, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; if scope.remote_connection_id.is_some() { if !openbitfun_core::plugin_host::configured_plugins_present() @@ -403,12 +359,9 @@ impl DesktopSessionApplication { scope.workspace_path ))); } - let workspace_path = PathBuf::from(&scope.workspace_path); openbitfun_core::plugin_host::ensure_configured_plugin_instance( crate::PLUGIN_HOST_LAUNCH_POLICY, - workspace_path.clone(), - workspace_path, - project_id, + &scope.workspace_id, ) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) @@ -418,12 +371,19 @@ impl DesktopSessionApplication { &self, request: DesktopSessionScopeRequest, ) -> DesktopSessionApplicationResult> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); - self.compatibility + let mut records = self + .compatibility .list_persisted_sessions(&storage_path) .await - .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; + for record in &mut records { + record + .workspace_id + .get_or_insert_with(|| scope.workspace_id.clone()); + } + Ok(records) } pub(crate) async fn list_persisted_sessions_page( @@ -433,9 +393,10 @@ impl DesktopSessionApplication { limit: usize, session_ids: Option<&[String]>, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); - self.compatibility + let mut page = self + .compatibility .list_persisted_sessions_page_with_activity( &self.agent_runtime, &storage_path, @@ -444,7 +405,13 @@ impl DesktopSessionApplication { session_ids, ) .await - .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; + for record in &mut page.sessions { + record + .workspace_id + .get_or_insert_with(|| scope.workspace_id.clone()); + } + Ok(page) } pub(crate) async fn search_session_content( @@ -454,7 +421,7 @@ impl DesktopSessionApplication { limit: usize, include_archived: bool, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.compatibility .search_persisted_session_content( &self.storage_path(&scope), @@ -471,9 +438,10 @@ impl DesktopSessionApplication { request: DesktopSessionScopeRequest, anchor_session_id: &str, ) -> DesktopSessionApplicationResult> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.agent_runtime .get_session_lineage(AgentSessionLineageRequest { + workspace_id: Some(scope.workspace_id), workspace_path: scope.workspace_path, anchor_session_id: anchor_session_id.to_string(), remote_connection_id: scope.remote_connection_id, @@ -500,7 +468,7 @@ impl DesktopSessionApplication { session_id: &str, options: &SessionTranscriptExportOptions, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; self.compatibility .export_persisted_session_transcript(&self.storage_path(&scope), session_id, options) @@ -514,7 +482,7 @@ impl DesktopSessionApplication { session_id: &str, limit: Option, ) -> DesktopSessionApplicationResult> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); self.compatibility .load_persisted_session_turns(&storage_path, session_id, limit) @@ -527,7 +495,7 @@ impl DesktopSessionApplication { scope_request: DesktopSessionScopeRequest, mut request: SessionTurnWindowRequest, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(scope_request).await; + let scope = self.resolved_scope(scope_request).await?; let storage_path = self.storage_path(&scope); request.workspace_path = storage_path.clone(); self.compatibility @@ -541,7 +509,7 @@ impl DesktopSessionApplication { request: DesktopSessionScopeRequest, session_id: &str, ) -> DesktopSessionApplicationResult> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); self.compatibility .load_persisted_session_metadata(&storage_path, session_id) @@ -554,7 +522,7 @@ impl DesktopSessionApplication { request: DesktopSessionScopeRequest, turn: &DialogTurnData, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); // An externally projected Session has no Runtime state to restore, and @@ -594,7 +562,7 @@ impl DesktopSessionApplication { request: DesktopSessionScopeRequest, session_id: &str, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility @@ -614,7 +582,7 @@ impl DesktopSessionApplication { "At least one session metadata field is required".to_string(), )); } - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); let session_id = incoming.session_id.clone(); @@ -633,11 +601,12 @@ impl DesktopSessionApplication { session_id: String, include_hidden_subagents: bool, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); let mut report = self .agent_runtime .generate_session_usage(AgentSessionUsageRequest { + workspace_id: Some(scope.workspace_id.clone()), session_id, workspace_path: Some(storage_path.to_string_lossy().to_string()), remote_connection_id: scope.remote_connection_id.clone(), @@ -658,12 +627,13 @@ impl DesktopSessionApplication { source_session_id: String, source_turn_id: String, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let result = self .agent_runtime .fork_session_at_turn(AgentSessionForkAtTurnRequest { - workspace_path: scope.workspace_path.clone(), + workspace_id: Some(scope.workspace_id.clone()), + workspace_path: String::new(), source_session_id, source_turn_id, remote_connection_id: scope.remote_connection_id, @@ -684,10 +654,11 @@ impl DesktopSessionApplication { session_id: String, archived: bool, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; self.agent_runtime .set_session_archived(AgentSessionArchiveStateRequest { + workspace_id: Some(scope.workspace_id.clone()), workspace_path: scope.workspace_path.clone(), session_id, archived, @@ -703,7 +674,7 @@ impl DesktopSessionApplication { request: DesktopSessionScopeRequest, session_id: String, ) -> DesktopSessionApplicationResult<()> { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; delete_session_with_host_effects( &self.agent_runtime, @@ -722,7 +693,7 @@ impl DesktopSessionApplication { ) -> DesktopSessionApplicationResult { let normalized_title = title.trim().to_string(); if let Some(request) = request { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; if !self .compatibility @@ -739,7 +710,8 @@ impl DesktopSessionApplication { } self.agent_runtime .rename_session(AgentSessionRenameRequest { - workspace_path: scope.workspace_path.clone(), + workspace_id: Some(scope.workspace_id.clone()), + workspace_path: String::new(), session_id: session_id.clone(), session_name: title, remote_connection_id: scope.remote_connection_id, @@ -780,12 +752,12 @@ impl DesktopSessionApplication { { return Ok(()); } - if request.workspace_path.trim().is_empty() { + if request.workspace_id.is_none() && request.workspace_path.trim().is_empty() { return Err(DesktopSessionApplicationError::Validation( - "workspace_path is required when the session is not loaded".to_string(), + "workspace_id is required when the session is not loaded".to_string(), )); } - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility @@ -800,7 +772,7 @@ impl DesktopSessionApplication { session_id: &str, include_internal: bool, ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility @@ -842,7 +814,7 @@ impl DesktopSessionApplication { F: FnOnce(u64) + Send, { let path_started_at = Instant::now(); - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; let storage_path = self.storage_path(&scope); let resolve_storage_path_duration_ms = path_started_at.elapsed().as_millis().min(u64::MAX as u128) as u64; @@ -896,7 +868,7 @@ impl DesktopSessionApplication { F: FnOnce(u64) + Send, { let path_started_at = Instant::now(); - let scope = self.resolved_scope(request).await; + let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); let resolve_storage_path_duration_ms = @@ -924,6 +896,7 @@ async fn delete_session_with_host_effects( host_effects.release_session(&session_id).await; agent_runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: Some(scope.workspace_id.clone()), workspace_path: scope.workspace_path.clone(), session_id: session_id.clone(), remote_connection_id: scope.remote_connection_id, @@ -1135,6 +1108,7 @@ mod tests { fn delete_test_scope() -> ResolvedDesktopSessionScope { ResolvedDesktopSessionScope { + workspace_id: "test-workspace".to_string(), workspace_path: "D:/workspace/project".to_string(), effective_storage_path: PathBuf::from("D:/managed/project/sessions"), remote_connection_id: None, @@ -1160,55 +1134,6 @@ mod tests { .expect("delete test runtime") } - #[test] - fn optional_scope_values_are_trimmed_without_inventing_identity() { - assert_eq!( - normalized_optional(Some(" host ")), - Some("host".to_string()) - ); - assert_eq!(normalized_optional(Some(" ")), None); - assert_eq!(normalized_optional(None), None); - } - - #[test] - fn local_host_sentinels_require_a_remote_connection_id() { - for host in [ - "localhost", - "LOCALHOST:22", - "127.0.0.1", - "127.0.0.1:22", - "::1", - "[::1]:22", - ] { - assert_eq!(normalized_remote_ssh_host(None, Some(host)), None); - } - - assert_eq!( - normalized_remote_ssh_host(Some("connection-1"), Some(" localhost ")), - Some("localhost".to_string()) - ); - assert_eq!( - normalized_remote_ssh_host(None, Some(" legacy.example ")), - Some("legacy.example".to_string()) - ); - } - - #[test] - fn remote_host_resolution_preserves_request_registry_and_offline_saved_precedence() { - assert_eq!( - choose_remote_ssh_host(Some("request-host"), Some("live-host"), Some("saved-host")), - Some("request-host".to_string()) - ); - assert_eq!( - choose_remote_ssh_host(None, Some("live-host"), Some("saved-host")), - Some("live-host".to_string()) - ); - assert_eq!( - choose_remote_ssh_host(None, None, Some(" saved-host ")), - Some("saved-host".to_string()) - ); - } - #[test] fn live_processing_state_overlays_sanitized_view_state() { let mut restored = Session::new_with_id( @@ -1281,15 +1206,10 @@ mod tests { async fn offline_saved_ssh_host_resolves_to_the_same_remote_session_tree() { let connection_id = "offline-saved-host-test"; let workspace_path = "/srv/offline-project"; - let saved_host = - choose_remote_ssh_host(None, None, Some("saved.example")).expect("saved SSH host"); + let saved_host = "saved.example"; - let resolved = get_effective_session_path( - workspace_path, - Some(connection_id), - Some(saved_host.as_str()), - ) - .await; + let resolved = + get_effective_session_path(workspace_path, Some(connection_id), Some(saved_host)).await; let unresolved = get_effective_session_path(workspace_path, Some(connection_id), None).await; diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt index 917b79b64e..f74b7db134 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt @@ -156,6 +156,8 @@ internal fun CreateSessionScreen( var workspacePath by rememberSaveable { mutableStateOf("") } var workspaceConnectionId by rememberSaveable { mutableStateOf(null) } var workspaceSshHost by rememberSaveable { mutableStateOf(null) } + // The stable reference; the three fields above are display context once this is set. + var workspaceId by rememberSaveable { mutableStateOf(null) } var selectedModelId by rememberSaveable { mutableStateOf(null) } // Not saveable: an open sheet is a finger part-way through a gesture. var pickerKind by remember { mutableStateOf(null) } @@ -175,7 +177,9 @@ internal fun CreateSessionScreen( workspacePath = workspace?.path.orEmpty() workspaceConnectionId = workspace?.remoteConnectionId workspaceSshHost = workspace?.remoteSshHost + workspaceId = workspace?.workspaceId } + val selectedChoice = WorkspaceChoice(workspacePath, "", workspaceConnectionId, workspaceSshHost, workspaceId) val voiceInput = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { @@ -260,7 +264,7 @@ internal fun CreateSessionScreen( label = when { workspaceState is RemoteWorkspaceUiState.Loading -> stringResource(R.string.sessions_loading) workspacePath.isEmpty() -> stringResource(R.string.create_chat) - else -> ready?.workspaces?.firstOrNull { it.path == workspacePath }?.displayName.orEmpty() + else -> ready?.workspaces?.firstOrNull { selectedChoice.refersTo(WorkspaceChoice(it.path, it.displayName, it.remoteConnectionId, it.remoteSshHost, it.workspaceId)) }?.displayName.orEmpty() .ifEmpty { workspacePath } }, expanded = pickerKind == CreateSelectionKind.WORKSPACE, @@ -282,12 +286,8 @@ internal fun CreateSessionScreen( shadowElevation = 18.dp, ) { WorkspacePicker( - workspaces = ready?.workspaces.orEmpty().map { - WorkspaceChoice(path = it.path, name = it.displayName, remoteConnectionId = it.remoteConnectionId, remoteSshHost = it.remoteSshHost) - }, - selectedPath = workspacePath, - selectedConnectionId = workspaceConnectionId, - selectedSshHost = workspaceSshHost, + workspaces = ready?.workspaces.orEmpty().map { WorkspaceChoice(it.path, it.displayName, it.remoteConnectionId, it.remoteSshHost, it.workspaceId) }, + selected = selectedChoice, showHeader = false, onDismiss = { pickerKind = null }, onPick = { path -> @@ -355,6 +355,7 @@ internal fun CreateSessionScreen( workspacePath = workspacePath.takeIf { it.isNotBlank() }, remoteConnectionId = workspaceConnectionId, remoteSshHost = workspaceSshHost, + workspaceId = workspaceId?.takeIf { workspacePath.isNotBlank() }, ), ) draft = "" @@ -368,12 +369,8 @@ internal fun CreateSessionScreen( if (compact && pickerKind == CreateSelectionKind.WORKSPACE) { ModalBottomSheet(onDismissRequest = { pickerKind = null }) { WorkspacePicker( - workspaces = ready?.workspaces.orEmpty().map { - WorkspaceChoice(path = it.path, name = it.displayName, remoteConnectionId = it.remoteConnectionId, remoteSshHost = it.remoteSshHost) - }, - selectedPath = workspacePath, - selectedConnectionId = workspaceConnectionId, - selectedSshHost = workspaceSshHost, + workspaces = ready?.workspaces.orEmpty().map { WorkspaceChoice(it.path, it.displayName, it.remoteConnectionId, it.remoteSshHost, it.workspaceId) }, + selected = selectedChoice, showHeader = true, onDismiss = { pickerKind = null }, onPick = { path -> @@ -396,7 +393,18 @@ private const val ASSISTANT_KIND = "assistant" * The workspace domain type carries a kind and a timestamp the picker has no use * for, and the app layer cannot see `core-domain` anyway. */ -private data class WorkspaceChoice(val path: String, val name: String, val remoteConnectionId: String?, val remoteSshHost: String?) +private data class WorkspaceChoice(val path: String, val name: String, val remoteConnectionId: String?, val remoteSshHost: String?, val workspaceId: String?) { + /** + * ID-first identity: when both rows carry a workspace ID only the IDs are + * compared; a row from a pre-ID host falls back to the legacy triple. + */ + fun refersTo(other: WorkspaceChoice): Boolean { + val ownId = workspaceId?.takeIf { it.isNotBlank() } + val otherId = other.workspaceId?.takeIf { it.isNotBlank() } + if (ownId != null && otherId != null) return ownId == otherId + return path == other.path && remoteConnectionId == other.remoteConnectionId && remoteSshHost == other.remoteSshHost + } +} /** * The tap target that puts the keyboard away. @@ -493,9 +501,7 @@ private fun DevicePicker( @Composable private fun WorkspacePicker( workspaces: List, - selectedPath: String, - selectedConnectionId: String?, - selectedSshHost: String?, + selected: WorkspaceChoice, showHeader: Boolean, onDismiss: () -> Unit, onPick: (WorkspaceChoice?) -> Unit, @@ -525,7 +531,7 @@ private fun WorkspacePicker( PickerRow( title = stringResource(R.string.create_chat), subtitle = "", - selected = selectedPath.isEmpty(), + selected = selected.path.isEmpty(), onClick = { onPick(null) }, ) if (workspaces.isEmpty()) { @@ -543,8 +549,7 @@ private fun WorkspacePicker( // Two projects can share a name; the path is what tells them // apart, and it is the only place the user can check. subtitle = workspace.path, - selected = workspace.path == selectedPath && workspace.remoteConnectionId == selectedConnectionId && - workspace.remoteSshHost == selectedSshHost, + selected = selected.path.isNotEmpty() && selected.refersTo(workspace), onClick = { onPick(workspace) }, ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index fd2ba8436d..183d6f1298 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -399,21 +399,25 @@ internal fun RemoteWorkspacePanel( } } if (state.savedConnectionsFailure) Text(stringResource(R.string.workspace_connections_failed), color = MaterialTheme.colorScheme.error) + // A hand-typed path has no workspace ID: only the legacy projection can be sent. TextButton(enabled = !state.busy && workspacePath.isNotBlank(), onClick = { onIntent(RemoteWorkspaceIntent.SelectWorkspace(workspacePath, savedConnectionId, null)) }) { Text(stringResource(R.string.workspace_open_path)) } + state.workspaceReferenceFailure?.let { failure -> + Text(stringResource(workspaceReferenceFailureText(failure)), color = MaterialTheme.colorScheme.error) + } state.workspaces.forEach { workspace -> TextButton( - onClick = { onIntent(RemoteWorkspaceIntent.SelectWorkspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost)) }, - enabled = !state.busy && state.selected?.path != workspace.path, + onClick = { onIntent(RemoteWorkspaceIntent.SelectWorkspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, false, workspace.workspaceId)) }, + enabled = !state.busy && !state.isSelected(workspace), ) { Text(workspace.displayName) } } if (state.assistants.isNotEmpty()) { Text(stringResource(R.string.assistants_title), style = MaterialTheme.typography.titleSmall) state.assistants.forEach { assistant -> TextButton( - onClick = { onIntent(RemoteWorkspaceIntent.SelectAssistant(assistant.path)) }, - enabled = !state.busy, + onClick = { onIntent(RemoteWorkspaceIntent.SelectAssistant(assistant.path, assistant.workspaceId)) }, + enabled = !state.busy && !state.isSelected(assistant), ) { Text(assistant.name) } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt index 6f13eca7b1..6861287d33 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RemoteSessionListView.kt @@ -165,7 +165,8 @@ internal fun RemoteSessionListContent( var revealedSectionKeys by rememberSaveable { mutableStateOf>(emptyList()) } // Not saveable, like the delete confirmation: an open menu is a finger // half-way through a gesture, not a place to come back to. - var projectCreateMenuPath by remember { mutableStateOf(null) } + // Keyed by the project's identity key, never its path: two open workspaces may share a root. + var projectCreateMenuKey by remember { mutableStateOf(null) } var viewSettings by rememberSaveable(stateSaver = SessionViewSettings.Saver) { mutableStateOf(SessionViewSettings.Default) } @@ -250,28 +251,25 @@ internal fun RemoteSessionListContent( section = section, collapsed = collapsed, createMenuOpen = section is SessionListSection.Project && - projectCreateMenuPath == section.path, + projectCreateMenuKey == section.key, onToggleCreateMenu = if (section is SessionListSection.Project) { { - projectCreateMenuPath = if (projectCreateMenuPath == section.path) { + projectCreateMenuKey = if (projectCreateMenuKey == section.key) { null } else { - section.path + section.key } } } else null, - onDismissCreateMenu = { projectCreateMenuPath = null }, + onDismissCreateMenu = { projectCreateMenuKey = null }, onCreateAgent = if (section is SessionListSection.Project) { { agentType -> - projectCreateMenuPath = null - val scope = workspaceState as? RemoteWorkspaceUiState.Ready - val matching = scope?.workspaces.orEmpty().filter { it.path == section.path } - val selected = scope?.selected?.takeIf { it.path == section.path } - if (selected != null || matching.size == 1) { - onIntent(RemoteSessionIntent.CreateSession(agentType, "", "", null, section.path, (if (selected != null) selected.remoteConnectionId else matching.singleOrNull()?.remoteConnectionId), - (if (selected != null) selected.remoteSshHost else matching.singleOrNull()?.remoteSshHost))) - } else onCreate() - + projectCreateMenuKey = null + // The section already carries the resolved workspace identity: with an + // ID the create is addressed by ID only; a pre-ID row sends its legacy + // triple. The shared store refuses unknown IDs instead of retrying by path. + onIntent(RemoteSessionIntent.CreateSession(agentType, "", "", null, section.path, + section.remoteConnectionId, section.remoteSshHost, section.workspaceId)) } } else null, onCreateAssistant = if (section is SessionListSection.Chat) { @@ -529,6 +527,9 @@ private fun RemoteWorkspaceUiState.asSessionContext(): SessionWorkspaceContext { selectedName = ready?.selected?.name.orEmpty(), selectedKind = ready?.selected?.kind.orEmpty(), recent = ready?.workspaces.orEmpty(), + selectedWorkspaceId = ready?.selected?.workspaceId, + selectedRemoteConnectionId = ready?.selected?.remoteConnectionId, + selectedRemoteSshHost = ready?.selected?.remoteSshHost, ) } } @@ -702,7 +703,7 @@ internal fun ProjectCreateControl( private fun sectionKey(section: SessionListSection): String = when (section) { is SessionListSection.Chat -> "chat" - is SessionListSection.Project -> "project:" + section.path + is SessionListSection.Project -> "project:" + section.key is SessionListSection.Today -> "today" is SessionListSection.Yesterday -> "yesterday" is SessionListSection.Earlier -> "earlier" @@ -841,6 +842,8 @@ private fun SessionFailure(state: RemoteSessionUiState.Failed, onRetry: () -> Un RemoteSessionFailureReason.RATE_LIMITED -> R.string.sessions_failed_rate_limited RemoteSessionFailureReason.PROTOCOL_MISMATCH -> R.string.sessions_failed_protocol_mismatch RemoteSessionFailureReason.SESSION_NOT_FOUND -> R.string.sessions_failed_session_not_found + RemoteSessionFailureReason.WORKSPACE_ID_UNSUPPORTED -> R.string.sessions_failed_workspace_id_unsupported + RemoteSessionFailureReason.WORKSPACE_ID_UNKNOWN -> R.string.sessions_failed_workspace_id_unknown // Exhaustive on purpose rather than an `else`: every reason // that reaches this screen was raised to say something // specific, and a new one falling into the generic line is diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RuntimeWorkspacePickerDialog.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RuntimeWorkspacePickerDialog.kt index b68d377da9..6b9995d158 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RuntimeWorkspacePickerDialog.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/RuntimeWorkspacePickerDialog.kt @@ -12,6 +12,14 @@ import androidx.compose.ui.unit.dp import com.openbitfun.mobile.app.R import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState +import com.openbitfun.mobile.core.feature.workspace.WorkspaceReferenceFailure + +/** Typed reference failures from the shared store, mapped to copy. */ +internal fun workspaceReferenceFailureText(failure: WorkspaceReferenceFailure): Int = when (failure) { + WorkspaceReferenceFailure.ID_REFERENCES_UNSUPPORTED -> R.string.workspace_reference_unsupported + WorkspaceReferenceFailure.UNKNOWN_ID -> R.string.workspace_reference_unknown + WorkspaceReferenceFailure.AMBIGUOUS_PATH -> R.string.workspace_reference_ambiguous +} /** Native presentation only; directory access and workspace mutation stay in KMP. */ @Composable @@ -27,17 +35,21 @@ internal fun RuntimeWorkspacePickerDialog( var browsing by rememberSaveable { mutableStateOf(false) } var submittedPath by rememberSaveable { mutableStateOf(null) } var submittedConnectionId by rememberSaveable { mutableStateOf(null) } - LaunchedEffect(state.busy, state.selected, state.loadFailure, submittedPath, submittedConnectionId) { + var submittedWorkspaceId by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(state.busy, state.selected, state.loadFailure, state.workspaceReferenceFailure, submittedPath, submittedConnectionId, submittedWorkspaceId) { val targetPath = submittedPath ?: return@LaunchedEffect - if (!state.busy) { - if (!state.loadFailure && state.selected?.path == targetPath && - state.selected?.remoteConnectionId == submittedConnectionId) onDismiss() - } + if (state.busy || state.loadFailure || state.workspaceReferenceFailure != null) return@LaunchedEffect + val selected = state.selected ?: return@LaunchedEffect + // ID first: a submitted ID is confirmed only by the same ID. Hand-typed paths have none. + val confirmed = submittedWorkspaceId?.let { it == selected.workspaceId } + ?: (selected.path == targetPath && selected.remoteConnectionId == submittedConnectionId) + if (confirmed) onDismiss() } - fun open(targetPath: String, targetConnection: String?, sshHost: String? = null) { + fun open(targetPath: String, targetConnection: String?, sshHost: String?, workspaceId: String?) { submittedPath = targetPath submittedConnectionId = targetConnection - onIntent(RemoteWorkspaceIntent.SelectWorkspace(targetPath, targetConnection, sshHost, false)) + submittedWorkspaceId = workspaceId + onIntent(RemoteWorkspaceIntent.SelectWorkspace(targetPath, targetConnection, sshHost, false, workspaceId)) } if (browsing) RuntimeDirectoryPickerDialog( state.directoryPicker, connectionId, onIntent, @@ -74,9 +86,12 @@ internal fun RuntimeWorkspacePickerDialog( Text(stringResource(R.string.sessions_refresh)) } } + state.workspaceReferenceFailure?.let { failure -> + Text(stringResource(workspaceReferenceFailureText(failure)), color = MaterialTheme.colorScheme.error) + } state.workspaces.forEach { workspace -> - TextButton(colors = actionColors, enabled = !state.busy, onClick = { - open(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost) + TextButton(colors = actionColors, enabled = !state.busy && !state.isSelected(workspace), onClick = { + open(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId) }, modifier = Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) { Text(workspace.displayName, style = MaterialTheme.typography.bodyMedium) @@ -88,7 +103,8 @@ internal fun RuntimeWorkspacePickerDialog( } }, confirmButton = { - TextButton(colors = actionColors, enabled = !state.busy && path.isNotBlank(), onClick = { open(path.trim(), connectionId) }) { + // A hand-typed path never carries an ID; it is sent as the legacy projection only. + TextButton(colors = actionColors, enabled = !state.busy && path.isNotBlank(), onClick = { open(path.trim(), connectionId, null, null) }) { Text(stringResource(R.string.workspace_open_path)) } }, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/SessionViewSettingsSheet.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/SessionViewSettingsSheet.kt index 1b3ea45a8e..9221bc1621 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/SessionViewSettingsSheet.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/SessionViewSettingsSheet.kt @@ -109,8 +109,10 @@ internal fun SessionViewSettingsSheet( HorizontalDivider() ChoiceRow( label = option.name, - selected = settings.workspaceFilter == option.path, - onSelect = { onChange(settings.copy(workspaceFilter = option.path)) }, + // Filters store the identity key (workspace ID first). A filter + // persisted as a bare path before IDs still highlights its row. + selected = settings.workspaceFilter == option.key || settings.workspaceFilter == option.path, + onSelect = { onChange(settings.copy(workspaceFilter = option.key)) }, ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt index 631f0be612..b5769a5f21 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt @@ -354,16 +354,18 @@ internal fun MobileScreen() { closeDrawer() dispatchActiveSession(RemoteSessionIntent.Open(sessionId)) }, - onCreateRemoteInWorkspace = { path, connectionId, sshHost, agentType -> + onCreateRemoteInWorkspace = { workspace, agentType -> + // With an ID the create carries only the ID; the legacy triple is for pre-ID rows. dispatchActiveSession( RemoteSessionIntent.CreateSession( agentType = agentType, title = "", instruction = "", modelId = null, - workspacePath = path, - remoteConnectionId = connectionId, - remoteSshHost = sshHost, + workspacePath = workspace.path, + remoteConnectionId = workspace.remoteConnectionId, + remoteSshHost = workspace.remoteSshHost, + workspaceId = workspace.workspaceId, ), ) shell.show(MobileSurface.REMOTE) @@ -375,20 +377,25 @@ internal fun MobileScreen() { closeDrawer() }, onAddRemoteWorkspace = { workspacePickerOpen = true }, - onOpenRemoteWorkspace = { path -> - dispatchActiveWorkspace(RemoteWorkspaceIntent.SelectWorkspace(path)) + onOpenRemoteWorkspace = { workspace -> + dispatchActiveWorkspace(RemoteWorkspaceIntent.SelectWorkspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, false, workspace.workspaceId)) shell.show(MobileSurface.REMOTE) shell.closeRemoteSession() closeDrawer() }, - onExpandRemoteWorkspace = { path, connectionId, sshHost -> + onExpandRemoteWorkspace = { workspace -> + // The branch is loaded by workspace ID; the legacy triple only serves pre-ID rows. dispatchActiveSession( - RemoteSessionIntent.LoadWorkspaceSessions(path, connectionId, sshHost), + RemoteSessionIntent.LoadWorkspaceSessions( + workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId, + ), ) }, - onRetryRemoteWorkspaceSessions = { path, connectionId, sshHost -> + onRetryRemoteWorkspaceSessions = { workspace -> dispatchActiveSession( - RemoteSessionIntent.RetryWorkspaceSessions(path, connectionId, sshHost), + RemoteSessionIntent.RetryWorkspaceSessions( + workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId, + ), ) }, onDeleteRemoteSession = { id -> dispatchActiveSession(RemoteSessionIntent.DeleteSession(id)) }, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt index 89cc825f98..e18854a2e4 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt @@ -45,6 +45,7 @@ import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.session.WorkspaceSessionDirectoryUiState import com.openbitfun.mobile.core.feature.layout.SettingsPlacement import com.openbitfun.mobile.core.feature.shell.RemoteSidebarSessionRow +import com.openbitfun.mobile.core.feature.shell.RemoteSidebarWorkspaceRow import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState internal const val SIDEBAR_TEST_TAG: String = "app-sidebar" @@ -90,10 +91,10 @@ internal fun AppSidebar( directoryRefreshError: String? = null, onSelectRemoteDevice: (String) -> Unit, onOpenRemoteSession: (String) -> Unit, - onCreateRemoteInWorkspace: (String, String?, String?, String) -> Unit, - onOpenRemoteWorkspace: (String) -> Unit, - onExpandRemoteWorkspace: (String, String?, String?) -> Unit, - onRetryRemoteWorkspaceSessions: (String, String?, String?) -> Unit, + onCreateRemoteInWorkspace: (RemoteSidebarWorkspaceRow, String) -> Unit, + onOpenRemoteWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onExpandRemoteWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onRetryRemoteWorkspaceSessions: (RemoteSidebarWorkspaceRow) -> Unit, onAddRemoteWorkspace: (() -> Unit)? = null, onWorkspaceTool: (String, String?, Boolean) -> Unit, onDeleteRemoteSession: (String) -> Unit, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index 5b40491ddd..a3a508743e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -51,6 +51,7 @@ import com.openbitfun.mobile.core.feature.connection.ConnectionStatusPresenter import com.openbitfun.mobile.core.feature.connection.RemoteControlSource import com.openbitfun.mobile.core.feature.shell.RemoteSidebarSessionRow import com.openbitfun.mobile.core.feature.shell.RemoteSidebarWorkspaceLoad +import com.openbitfun.mobile.core.feature.shell.RemoteSidebarWorkspaceRow import com.openbitfun.mobile.core.feature.session.WorkspaceSessionDirectoryUiState import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -82,10 +83,11 @@ internal fun SidebarRemoteWorkspaceSection( onSelectDevice: (String) -> Unit, onOpenSession: (String) -> Unit, onOpenActions: (RemoteSidebarSessionRow, IntRect) -> Unit, - onCreateInWorkspace: (String, String?, String?, String) -> Unit, - onOpenWorkspace: (String) -> Unit, - onExpandWorkspace: (String, String?, String?) -> Unit, - onRetryWorkspaceSessions: (String, String?, String?) -> Unit, + /** The row carries the workspace identity (ID first); the agent type follows. */ + onCreateInWorkspace: (RemoteSidebarWorkspaceRow, String) -> Unit, + onOpenWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onExpandWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onRetryWorkspaceSessions: (RemoteSidebarWorkspaceRow) -> Unit, onAddWorkspace: (() -> Unit)? = null, onWorkspaceTool: (String, String?, Boolean) -> Unit, ) { @@ -240,10 +242,11 @@ private fun SidebarActiveDeviceBody( onOpenSession: (String) -> Unit, onOpenActions: (RemoteSidebarSessionRow, IntRect) -> Unit, canActOnSessions: Boolean, - onCreateInWorkspace: (String, String?, String?, String) -> Unit, - onOpenWorkspace: (String) -> Unit, - onExpandWorkspace: (String, String?, String?) -> Unit, - onRetryWorkspaceSessions: (String, String?, String?) -> Unit, + /** The row carries the workspace identity (ID first); the agent type follows. */ + onCreateInWorkspace: (RemoteSidebarWorkspaceRow, String) -> Unit, + onOpenWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onExpandWorkspace: (RemoteSidebarWorkspaceRow) -> Unit, + onRetryWorkspaceSessions: (RemoteSidebarWorkspaceRow) -> Unit, onAddWorkspace: (() -> Unit)? = null, onWorkspaceTool: (String, String?, Boolean) -> Unit, ) { @@ -341,10 +344,10 @@ private fun SidebarActiveDeviceBody( // `list_sessions` answers per workspace, so // this branch's rows only exist once asked // for. The store ignores a repeat. - onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost) + onExpandWorkspace(entry) } }, - onLongClick = { onOpenWorkspace(path) }, + onLongClick = { onOpenWorkspace(entry) }, ) .semantics(mergeDescendants = true) { contentDescription = entry.name.ifBlank { path.substringAfterLast('/') } @@ -385,7 +388,7 @@ private fun SidebarActiveDeviceBody( expanded = createMenuOpen, onToggle = { createMenuOpen = !createMenuOpen }, onDismiss = { createMenuOpen = false }, - onCreateAgent = { agent -> createMenuOpen = false; onCreateInWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost, agent) }, + onCreateAgent = { agent -> createMenuOpen = false; onCreateInWorkspace(entry, agent) }, ) Icon( painterResource( @@ -404,7 +407,7 @@ private fun SidebarActiveDeviceBody( RemoteSidebarWorkspaceLoad.LOADING, -> if (workspaceSessions.isEmpty()) DeviceLoadingRow(startPadding = 26.dp) RemoteSidebarWorkspaceLoad.FAILED -> WorkspaceFailedRow { - onRetryWorkspaceSessions(path, entry.remoteConnectionId, entry.remoteSshHost) + onRetryWorkspaceSessions(entry) } RemoteSidebarWorkspaceLoad.READY -> if (workspaceSessions.isEmpty()) { Text( diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index 002c36bf48..0aadb6697b 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -260,6 +260,9 @@ 模型列表刷新失败,正在显示之前加载的列表。 工作区 工作区信息加载失败。 + 桌面端不支持按 ID 选择工作区,请升级桌面端的 OpenBitFun。 + 桌面端已没有这个工作区。 + 多个已打开的工作区使用同一路径,请从列表中选择。 助手 远程文件路径 在电脑端 @@ -356,6 +359,8 @@ 请求过于频繁,请稍后重试。 桌面端返回了本应用无法识别的格式,请升级桌面端的 OpenBitFun。 该会话已不在桌面端。 + 桌面端不支持按 ID 选择工作区,请升级桌面端的 OpenBitFun。 + 桌面端已没有这个工作区。 返回会话列表 会话设置 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 649558e8ab..f29729fcc7 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -266,6 +266,9 @@ The model list could not be refreshed. Showing the previously loaded list. Workspace Could not load workspace information. + The desktop does not support selecting workspaces by ID. Update OpenBitFun on the desktop. + The desktop no longer has this workspace. + Several open workspaces share this path. Choose one from the list. Assistants Remote file path @@ -369,6 +372,8 @@ Too many requests; try again shortly. The desktop answered in a format this app does not understand. Update OpenBitFun on the desktop. That session is no longer on the desktop. + The desktop does not support selecting workspaces by ID. Update OpenBitFun on the desktop. + The desktop no longer has this workspace. Back to sessions Session settings diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 228c89b3e1..21ba1e55ed 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -693,6 +693,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.permissionDenied', 'Required permission was denied. Allow camera or clipboard access and retry.'], ['errors.operationFailed', 'The operation failed. Try again later.'], ['errors.payloadTooLarge', 'This message’s attachments exceed the relay size limit. Send fewer images or smaller ones.'], + ['errors.workspaceIdUnsupported', 'The connected desktop does not support workspace ID references. Update the desktop app to open this workspace.'], ['errors.imageTooLargeAfterCompression', 'The image is still larger than {0} MB after compression. Choose a smaller image.'], ['errors.imageCompressionFailed', 'Image compression failed. Choose a smaller image.'], ['errors.voicePermissionDenied', 'Microphone permission was denied, so voice input is unavailable.'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 471908f8f5..5acb930618 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -693,6 +693,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.permissionDenied', '没有获得所需权限,请允许扫码或剪贴板访问后重试。'], ['errors.operationFailed', '操作失败,请稍后重试。'], ['errors.payloadTooLarge', '这条消息带的附件超过了中继允许的大小,请减少图片数量或选择更小的图片。'], + ['errors.workspaceIdUnsupported', '已连接的桌面端不支持按工作区 ID 引用。请升级桌面端后再打开该工作区。'], ['errors.imageTooLargeAfterCompression', '图片压缩后仍超过 {0} MB,请选择较小图片。'], ['errors.imageCompressionFailed', '图片压缩失败,请选择较小图片。'], ['errors.voicePermissionDenied', '没有获得麦克风权限,无法语音输入。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 00f996d9e3..b6b9a6b34b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -34,7 +34,14 @@ export interface RecentWorkspaceEntry extends RemoteWorkspaceIdentity { workspaceKind: string; } +/** + * A workspace reference. When `workspaceId` is present it is the whole + * identity; `path`, `remoteConnectionId` and `remoteSshHost` are then display + * strings and IO operands only. Only references from pre-ID hosts or old caches + * are identified by the legacy (connection, ssh host, path) triple. + */ export interface RemoteWorkspaceIdentity { + workspaceId?: string; path: string; remoteConnectionId?: string; remoteSshHost?: string; @@ -78,6 +85,8 @@ export interface ChatMessage { } export interface SessionSummary { + /** Owning workspace record ID; absent only for sessions from pre-ID hosts. */ + workspaceId?: string; remoteSshHost?: string; remoteConnectionId?: string; sessionId: string; @@ -88,6 +97,7 @@ export interface SessionSummary { } export interface CreateSessionOptions { + workspaceId?: string; remoteSshHost?: string; remoteConnectionId?: string; agentType: string; @@ -118,6 +128,7 @@ export interface SavedRuntimeConnection { id: string; name: string; host: string export interface SavedRuntimeConnectionsResponse extends CommandStatusResponse { ok: boolean; value?: SavedRuntimeConnection[]; error?: string; } export interface RemoteCommand { + workspace_id?: string; remote_ssh_host?: string; command?: string; args?: Object; @@ -172,6 +183,7 @@ export interface PermissionModeResponse extends CommandStatusResponse { } export interface WorkspaceInfoResponse extends CommandStatusResponse { + workspace_id?: string; remote_connection_id?: string; remote_ssh_host?: string; has_workspace?: boolean; @@ -186,6 +198,7 @@ export interface WorkspaceInfoResponse extends CommandStatusResponse { } export interface RecentWorkspaceEntryResponse { + workspace_id?: string; remote_connection_id?: string; remote_ssh_host?: string; path?: string; @@ -203,13 +216,17 @@ export interface RecentWorkspaceListResponse extends CommandStatusResponse { } export interface SetWorkspaceResponse extends CommandStatusResponse { + workspace_id?: string; success?: boolean; path?: string; project_name?: string; + remote_connection_id?: string; + remote_ssh_host?: string; error?: string; } export interface AssistantEntry { + workspace_id?: string; path: string; name: string; assistant_id?: string; @@ -220,6 +237,7 @@ export interface AssistantListResponse extends CommandStatusResponse { } export interface SetAssistantResponse extends CommandStatusResponse { + workspace_id?: string; success?: boolean; path?: string; name?: string; @@ -227,6 +245,8 @@ export interface SetAssistantResponse extends CommandStatusResponse { } export interface SessionItemResponse { + /** Owning workspace record ID; always set by ID-aware hosts. */ + workspace_id?: string; id?: string; session_id?: string; title?: string; @@ -268,12 +288,15 @@ export interface SessionListResponse extends CommandStatusResponse { } export interface InitialSyncResponse extends CommandStatusResponse { + workspace_id?: string; has_workspace?: boolean; path?: string; project_name?: string; git_branch?: string; workspace_kind?: string; assistant_id?: string; + remote_connection_id?: string; + remote_ssh_host?: string; sessions?: SessionItemResponse[]; has_more_sessions?: boolean; authenticated_user_id?: string; @@ -305,6 +328,8 @@ export interface SessionListResult { } export interface CreateSessionResponse extends CommandStatusResponse { + /** Owning workspace record ID; absent only from pre-ID hosts. */ + workspace_id?: string; remote_ssh_host?: string; workspace_path?: string; remote_connection_id?: string; @@ -332,6 +357,8 @@ export interface SteerTurnResult { export const REMOTE_CAPABILITY_DIALOG_STEER_V1: string = 'dialog_steer_v1'; export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1'; export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1'; +/** The host resolves `workspace_id` in commands and stamps it on its projections. */ +export const REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1: string = 'workspace_id_references_v1'; export interface RemoteModelConfig { id: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 794879af7e..cf84094007 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -36,8 +36,8 @@ export interface RemoteHomePresentationActions { readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; - readonly selectWorkspace: (path: string, deviceId?: string, connectionId?: string, sshHost?: string) => void; - readonly selectAssistant: (path: string) => void; + readonly selectWorkspace: (path: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void; + readonly selectAssistant: (path: string, workspaceId?: string) => void; readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; readonly queryChanged: (query: string) => void; @@ -48,12 +48,12 @@ export interface RemoteHomePresentationActions { readonly clearPairing: () => void; readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createInWorkspace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string) => void; - readonly createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string) => void; + readonly createInWorkspace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void; readonly selectDirectoryDevice: (deviceId: string) => void; readonly addDirectoryWorkspace: (deviceId: string) => void; readonly retryDirectoryDevice: (deviceId: string) => void; - readonly loadWorkspaceSessions: (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string) => Promise; + readonly loadWorkspaceSessions: (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string) => Promise; readonly openSession: (session: RemoteSession) => void; readonly openSessionInPlace: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index a8bd1961b8..df3a68678f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -77,6 +77,7 @@ export struct AppSidebarSurface { workspacePath: this.remotePageState.workspacePath, workspaceConnectionId: this.remotePageState.workspaceConnectionId, workspaceSshHost: this.remotePageState.workspaceSshHost, + workspaceId: this.remotePageState.workspaceId, workspaceKind: this.remotePageState.workspaceKind, connectionState: this.remotePageState.connectionState, controlTargetType: this.remotePageState.controlTargetType, @@ -89,15 +90,17 @@ export struct AppSidebarSurface { REMOTE_CAPABILITY_HARNESS_PROFILES_V1), workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onSidebar.openSession, - onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string) => { + onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string, + workspaceId?: string) => { this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType, deviceId, connectionId, sshHost); + this.actions.onRemoteHome.createInWorkspace(path, agentType, deviceId, connectionId, sshHost, workspaceId); }, - onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string) => { - this.actions.onRemoteHome.selectWorkspace(path, deviceId, connectionId, sshHost); + onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => { + this.actions.onRemoteHome.selectWorkspace(path, deviceId, connectionId, sshHost, workspaceId); }, - onExpandWorkspace: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise => { - await this.actions.onRemoteHome.loadWorkspaceSessions(deviceId, path, remoteConnectionId, remoteSshHost); + onExpandWorkspace: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, + workspaceId?: string): Promise => { + await this.actions.onRemoteHome.loadWorkspaceSessions(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId); }, onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index c6bee9e3ab..52abd8085a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -39,6 +39,10 @@ export struct RemoteSessionList { @Param selectedSessionId: string = ''; @Param workspaceName: string = ''; @Param workspacePath: string = ''; + /** Stable record ID of the active workspace; empty for pre-ID hosts. */ + @Param workspaceId: string = ''; + @Param workspaceConnectionId: string = ''; + @Param workspaceSshHost: string = ''; @Param workspaceKind: string = 'normal'; @Param recentWorkspaces: RecentWorkspaceEntry[] = []; @Param actionPresentation: SessionActionPresentation = SessionActionPresentation.BottomSheet; @@ -56,9 +60,14 @@ export struct RemoteSessionList { @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onLoadMore: () => void = () => {}; @Event onCreateAssistantSession: () => void = () => {}; - @Event onCreateInWorkspace: (path: string, agentType: string) => void = (_path: string, _agentType: string) => {}; - @Event onSelectWorkspace: (path: string) => void = (_path: string) => {}; - @Local collapsedWorkspacePaths: string[] = []; + /** Project rows report their catalog identity; the ID is what the host resolves by. */ + @Event onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = + (_path: string, _agentType: string) => {}; + @Event onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = + (_path: string) => {}; + // Disclosure state is keyed by the project's section key: its workspace ID + // when the catalog supplies one, the legacy triple otherwise. + @Local collapsedWorkspaceKeys: string[] = []; @Local projectVisibleSteps: string[] = []; @Local visibleProjectCount: number = 3; @Local chatsCollapsed: boolean = false; @@ -66,7 +75,7 @@ export struct RemoteSessionList { @Local todayCollapsed: boolean = false; @Local yesterdayCollapsed: boolean = false; @Local earlierCollapsed: boolean = false; - @Local createMenuPath: string = ''; + @Local createMenuKey: string = ''; @Local activeActionSessionId: string = ''; @Local showSessionActionSheet: boolean = false; @Local detailsSessionId: string = ''; @@ -80,7 +89,7 @@ export struct RemoteSessionList { @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { - this.createMenuPath = ''; + this.createMenuKey = ''; } @Monitor('selectedSessionId') @@ -263,7 +272,7 @@ export struct RemoteSessionList { // the left edge of any box forced wider than its natural, // half-width advance box. Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(this.isWorkspaceCollapsed(project.path) ? + SymbolGlyph(this.isWorkspaceCollapsed(this.sectionKey(project)) ? $r('sys.symbol.chevron_right') : $r('sys.symbol.chevron_down')) .fontSize(14) .fontColor([MUTED]) @@ -277,9 +286,9 @@ export struct RemoteSessionList { .width(22) .height(22) .opacity(0.52) - .bindPopup(this.createMenuPath.length > 0 && this.createMenuPath === project.path, { + .bindPopup(this.createMenuKey.length > 0 && this.createMenuKey === this.sectionKey(project), { builder: () => { - this.ProjectCreateMenu(project.path) + this.ProjectCreateMenu(project) }, placement: Placement.Top, popupColor: TRANSPARENT, @@ -289,27 +298,28 @@ export struct RemoteSessionList { targetSpace: 6, onStateChange: (event) => { if (!event.isVisible) { - this.createMenuPath = ''; + this.createMenuKey = ''; } } }) .onClick(() => { - this.createMenuPath = this.createMenuPath === project.path ? '' : project.path; + const key = this.sectionKey(project); + this.createMenuKey = this.createMenuKey === key ? '' : key; }) } } .width('100%') .height(44) .onClick(() => { - this.toggleWorkspace(project.path); + this.toggleWorkspace(this.sectionKey(project)); }) - if (!this.isWorkspaceCollapsed(project.path)) { - this.projectPreview(project.path) + if (!this.isWorkspaceCollapsed(this.sectionKey(project))) { + this.projectPreview(project) } } .width('100%') .margin({ bottom: 8 }) - }, (project: RecentWorkspaceEntry): string => project.path) + }, (project: RecentWorkspaceEntry): string => this.sectionKey(project)) if (this.visibleProjectCount < this.projectEntries().length) { Text(RemoteI18n.f('remote.projects.showMore', String(this.nextProjectEntryBatchSize()))) .fontSize(MobileDesignTypography.bodySmall.size) @@ -327,19 +337,19 @@ export struct RemoteSessionList { } @Builder - private ProjectCreateMenu(path: string) { + private ProjectCreateMenu(project: RecentWorkspaceEntry) { if (this.supportsHarnessProfiles) { HarnessProfileMenu({ includeCowork: true, onSelect: (agentType: string) => { - this.createMenuPath = ''; - this.onCreateInWorkspace(path, agentType); + this.createMenuKey = ''; + this.onCreateInWorkspace(project.path, agentType, project.remoteConnectionId, project.remoteSshHost, project.workspaceId); } }) } else { Column({ space: 2 }) { - this.ProjectCreateMenuItem('Code', 'code', path) - this.ProjectCreateMenuItem('Cowork', 'Cowork', path) + this.ProjectCreateMenuItem('Code', 'code', project) + this.ProjectCreateMenuItem('Cowork', 'Cowork', project) } .width(150) .padding({ top: 8, bottom: 8 }) @@ -351,7 +361,7 @@ export struct RemoteSessionList { } @Builder - private ProjectCreateMenuItem(label: string, agentType: string, path: string) { + private ProjectCreateMenuItem(label: string, agentType: string, project: RecentWorkspaceEntry) { Row() { Text(label) .fontSize(MobileDesignTypography.titleSmall.size) @@ -362,8 +372,8 @@ export struct RemoteSessionList { .height(42) .padding({ left: 18, right: 18 }) .onClick(() => { - this.createMenuPath = ''; - this.onCreateInWorkspace(path, agentType); + this.createMenuKey = ''; + this.onCreateInWorkspace(project.path, agentType, project.remoteConnectionId, project.remoteSshHost, project.workspaceId); }) } @@ -429,19 +439,19 @@ export struct RemoteSessionList { } @Builder - private projectPreview(path: string) { - ForEach(this.visibleProjectSessions(path), (item: RemoteSession) => { + private projectPreview(project: RecentWorkspaceEntry) { + ForEach(this.visibleProjectSessions(project), (item: RemoteSession) => { this.SessionRow(item, true) }) - if (this.projectVisibleCount(path) < this.projectSessions(path).length) { - Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(path)))) + if (this.projectVisibleCount(project) < this.projectSessions(project).length) { + Text(RemoteI18n.f('remote.sessions.showMore', String(this.nextProjectBatchSize(project)))) .fontSize(MobileDesignTypography.titleSmall.size) .fontColor(MUTED) .height(44) .width('100%') .textAlign(TextAlign.Center) .onClick(() => { - this.projectVisibleSteps = this.projectVisibleSteps.concat([path]); + this.projectVisibleSteps = this.projectVisibleSteps.concat([this.sectionKey(project)]); }) } } @@ -460,6 +470,9 @@ export struct RemoteSessionList { sortMode: this.sortMode, workspaceName: this.workspaceName, workspacePath: this.workspacePath, + workspaceId: this.workspaceId, + workspaceConnectionId: this.workspaceConnectionId, + workspaceSshHost: this.workspaceSshHost, workspaceKind: this.workspaceKind, recentWorkspaces: this.recentWorkspaces, workspaceFilter: this.workspaceFilter, @@ -505,8 +518,12 @@ export struct RemoteSessionList { return bucket === 'yesterday' ? projection.yesterday : projection.earlier; } - private projectSessions(path: string): RemoteSession[] { - return SessionListProjector.sessionsForProject(this.view(), path); + private sectionKey(project: RecentWorkspaceEntry): string { + return SessionListProjector.sectionKeyFor(project); + } + + private projectSessions(project: RecentWorkspaceEntry): RemoteSession[] { + return SessionListProjector.sessionsForProject(this.view(), project); } private filteredSessions(): RemoteSession[] { @@ -517,32 +534,33 @@ export struct RemoteSessionList { return this.view().hasActiveFilter; } - private visibleProjectSessions(path: string): RemoteSession[] { - return this.projectSessions(path).slice(0, this.projectVisibleCount(path)); + private visibleProjectSessions(project: RecentWorkspaceEntry): RemoteSession[] { + return this.projectSessions(project).slice(0, this.projectVisibleCount(project)); } - private projectVisibleCount(path: string): number { - const additionalSteps = this.projectVisibleSteps.filter((item: string) => item === path).length; + private projectVisibleCount(project: RecentWorkspaceEntry): number { + const key = this.sectionKey(project); + const additionalSteps = this.projectVisibleSteps.filter((item: string) => item === key).length; return 3 + additionalSteps * 3; } - private nextProjectBatchSize(path: string): number { - return Math.min(3, this.projectSessions(path).length - this.projectVisibleCount(path)); + private nextProjectBatchSize(project: RecentWorkspaceEntry): number { + return Math.min(3, this.projectSessions(project).length - this.projectVisibleCount(project)); } private nextChatBatchSize(): number { return Math.min(3, this.visibleChatSessions().length - this.chatVisibleCount); } - private isWorkspaceCollapsed(path: string): boolean { - return this.collapsedWorkspacePaths.indexOf(path) >= 0; + private isWorkspaceCollapsed(key: string): boolean { + return this.collapsedWorkspaceKeys.indexOf(key) >= 0; } - private toggleWorkspace(path: string): void { - if (this.isWorkspaceCollapsed(path)) { - this.collapsedWorkspacePaths = this.collapsedWorkspacePaths.filter((item: string) => item !== path); + private toggleWorkspace(key: string): void { + if (this.isWorkspaceCollapsed(key)) { + this.collapsedWorkspaceKeys = this.collapsedWorkspaceKeys.filter((item: string) => item !== key); } else { - this.collapsedWorkspacePaths = this.collapsedWorkspacePaths.concat([path]); + this.collapsedWorkspaceKeys = this.collapsedWorkspaceKeys.concat([key]); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index 7a67cd4e16..a53f565999 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -1,7 +1,7 @@ -import { remoteWorkspaceKey, remoteSessionBelongsToWorkspace } from '../../services/RemoteSessionIdentity'; +import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey, remoteSessionBelongsToWorkspace } from '../../services/RemoteSessionIdentity'; import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; -import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DeviceDirectoryEntry, @@ -34,6 +34,8 @@ import { struct SidebarWorkspaceGroup { @Param sshHost: string = ''; @Param connectionId: string = ''; + /** Stable record ID of this row; empty only for rows from pre-ID hosts. */ + @Param workspaceId: string = ''; @Event onWorkspaceTools: (intent: WorkspaceToolsIntent) => void = (_intent: WorkspaceToolsIntent) => {}; @Param deviceId: string = ''; @Param path: string = ''; @@ -48,17 +50,21 @@ struct SidebarWorkspaceGroup { @Param loadStatus: WorkspaceDirectoryStatus = 'idle'; @Param hasLoadedSessions: boolean = false; @Param supportsHarnessProfiles: boolean = false; - @Event onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string) => void = (_path: string) => {}; + @Event onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_path: string) => {}; @Event onExpandWorkspace: (path: string) => Promise = async (_path: string) => {}; @Event onExpandedChange: (path: string, expanded: boolean) => void = (_path: string, _expanded: boolean) => {}; - @Event onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string) => void = + @Event onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_path: string, _agentType: string) => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Local visibleSessionCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; @Local showCreateMenu: boolean = false; + private workspaceIdOrUndefined(): string | undefined { + return this.workspaceId.length > 0 ? this.workspaceId : undefined; + } + build() { Column() { this.WorkspaceRow() @@ -123,7 +129,7 @@ struct SidebarWorkspaceGroup { void this.toggleExpanded(); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => { - this.onOpenWorkspace(this.path, this.connectionId, this.sshHost); + this.onOpenWorkspace(this.path, this.connectionId, this.sshHost, this.workspaceIdOrUndefined()); })) Stack({ alignContent: Alignment.Center }) { @@ -166,7 +172,7 @@ struct SidebarWorkspaceGroup { private CreateModeMenu() { HarnessProfileMenu({ showTitle: false, onSelect: (agentType: string) => { this.showCreateMenu = false; - this.onCreateInWorkspace(this.path, agentType, this.connectionId, this.sshHost); + this.onCreateInWorkspace(this.path, agentType, this.connectionId, this.sshHost, this.workspaceIdOrUndefined()); } }) } @@ -333,6 +339,8 @@ export struct SidebarDeviceGroup { @Param workspacePath: string = ''; @Param workspaceConnectionId: string = ''; @Param workspaceSshHost: string = ''; + /** Stable record ID of the desktop's active workspace; empty for pre-ID hosts. */ + @Param workspaceId: string = ''; @Param workspaceKind: string = 'normal'; @Param selectedSessionId: string = ''; @Param runningSessionId: string = ''; @@ -342,11 +350,11 @@ export struct SidebarDeviceGroup { @Event onRetry: () => void = () => {}; @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string) => void = + @Event onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_path: string, _agentType: string) => {}; - @Event onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string) => void = (_path: string) => {}; - @Event onExpandWorkspace: (path: string, remoteConnectionId?: string, remoteSshHost?: string) => Promise = async (_path: string) => {}; - @Event onWorkspaceExpandedChange: (path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string) => void = + @Event onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_path: string) => {}; + @Event onExpandWorkspace: (path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string) => Promise = async (_path: string) => {}; + @Event onWorkspaceExpandedChange: (path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string) => void = (_path: string, _expanded: boolean) => {}; @Local visibleWorkspaceCount: number = SidebarDirectoryPreviewPolicy.PREVIEW_COUNT; private readonly projectionCache: SessionListProjectionCache = new SessionListProjectionCache(); @@ -368,6 +376,7 @@ export struct SidebarDeviceGroup { SidebarWorkspaceGroup({ connectionId: entry.remoteConnectionId || '', sshHost: entry.remoteSshHost || '', + workspaceId: entry.workspaceId || '', onWorkspaceTools: this.onWorkspaceTools, deviceId: this.device.deviceId, path: entry.path, @@ -379,12 +388,17 @@ export struct SidebarDeviceGroup { activeDevice: this.isActive, supportsHarnessProfiles: this.supportsHarnessProfiles, bodyIndent: 10, - expanded: this.directoryState.workspaceExpanded(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), - loadStatus: this.directoryState.workspaceStatus(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), - hasLoadedSessions: this.directoryState.workspaceHasLoadedSessions(this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost), + expanded: this.directoryState.workspaceExpanded( + this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + loadStatus: this.directoryState.workspaceStatus( + this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + hasLoadedSessions: this.directoryState.workspaceHasLoadedSessions( + this.device.deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), onOpenWorkspace: this.onOpenWorkspace, - onExpandWorkspace: (path: string): Promise => this.onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost), - onExpandedChange: (path: string, expanded: boolean): void => this.onWorkspaceExpandedChange(path, expanded, entry.remoteConnectionId, entry.remoteSshHost), + onExpandWorkspace: (path: string): Promise => + this.onExpandWorkspace(path, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), + onExpandedChange: (path: string, expanded: boolean): void => + this.onWorkspaceExpandedChange(path, expanded, entry.remoteConnectionId, entry.remoteSshHost, entry.workspaceId), onCreateInWorkspace: this.onCreateInWorkspace, onOpenSession: this.onOpenSession, onSessionActions: this.onSessionActions @@ -479,6 +493,9 @@ export struct SidebarDeviceGroup { sortMode: 'project', workspaceName: this.workspaceName, workspacePath: this.workspacePath, + workspaceId: this.workspaceId, + workspaceConnectionId: this.workspaceConnectionId, + workspaceSshHost: this.workspaceSshHost, workspaceKind: this.workspaceKind, recentWorkspaces: this.recentWorkspaces, workspaceFilter: '', @@ -526,9 +543,16 @@ export struct SidebarDeviceGroup { } private isCurrentWorkspace(entry: RecentWorkspaceEntry): boolean { - return this.isActive && entry.path.length > 0 && remoteWorkspaceKey(entry) === remoteWorkspaceKey({ - path: this.workspacePath, remoteConnectionId: this.workspaceConnectionId, remoteSshHost: this.workspaceSshHost - }); + if (!this.isActive || entry.path.length === 0) { + return false; + } + const current: RemoteWorkspaceIdentity = { + workspaceId: this.workspaceId.length > 0 ? this.workspaceId : undefined, + path: this.workspacePath, + remoteConnectionId: this.workspaceConnectionId.length > 0 ? this.workspaceConnectionId : undefined, + remoteSshHost: this.workspaceSshHost.length > 0 ? this.workspaceSshHost : undefined + }; + return remoteWorkspaceIdentityMatches(entry, current, this.recentWorkspaces); } private basename(path: string): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index 731188feef..9375b78fde 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -1,6 +1,7 @@ import { WorkspaceToolsState, WorkspaceToolsIntent, WorkspaceFileEntry } from '../state/WorkspaceToolsState'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; -import { SavedRuntimeConnection, RecentWorkspaceEntry } from '../../model/RemoteModels'; +import { SavedRuntimeConnection, RecentWorkspaceEntry, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; +import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey } from '../../services/RemoteSessionIdentity'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; @@ -16,9 +17,31 @@ export struct SidebarWorkspacePicker { @Param deviceName: string = ''; @Param workspaces: RecentWorkspaceEntry[] = []; @Param selectedWorkspacePath: string = ''; + /** Identity of the active workspace; the ID wins over the path when present. */ + @Param selectedWorkspaceId: string = ''; + @Param selectedWorkspaceConnectionId: string = ''; + @Param selectedWorkspaceSshHost: string = ''; @Param loading: boolean = false; @Event onClose: () => void = () => {}; - @Event onSelectWorkspace: (path: string, connectionId?: string) => void = (_path: string, _connectionId?: string) => {}; + /** + * Catalog rows report their stable `workspaceId`; the manual path entry has + * none, which is the only case where the host may resolve by path. + */ + @Event onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = + (_path: string, _connectionId?: string) => {}; + + private isSelected(workspace: RecentWorkspaceEntry): boolean { + if (this.selectedWorkspacePath.length === 0 && this.selectedWorkspaceId.length === 0) { + return false; + } + const selected: RemoteWorkspaceIdentity = { + workspaceId: this.selectedWorkspaceId.length > 0 ? this.selectedWorkspaceId : undefined, + path: this.selectedWorkspacePath, + remoteConnectionId: this.selectedWorkspaceConnectionId.length > 0 ? this.selectedWorkspaceConnectionId : undefined, + remoteSshHost: this.selectedWorkspaceSshHost.length > 0 ? this.selectedWorkspaceSshHost : undefined + }; + return remoteWorkspaceIdentityMatches(workspace, selected, this.workspaces); + } private connectionOptions(): SelectOption[] { const options: SelectOption[] = [{ value: this.deviceName }]; @@ -51,7 +74,7 @@ export struct SidebarWorkspacePicker { Column({ space: 4 }) { ForEach(this.workspaces, (workspace: RecentWorkspaceEntry) => { this.WorkspaceRow(workspace) - }, (workspace: RecentWorkspaceEntry): string => `${workspace.remoteConnectionId || 'local'}:${workspace.path}`) + }, (workspace: RecentWorkspaceEntry): string => remoteWorkspaceKey(workspace)) } .width('100%') .padding({ top: 10, bottom: 18 }) @@ -141,7 +164,7 @@ export struct SidebarWorkspacePicker { Text((workspace.name || this.basename(workspace.path)) + (workspace.remoteSshHost?.trim() ? ` · ${workspace.remoteSshHost.trim()}` : '')) .width('100%') .fontSize(MobileDesignTypography.bodyLarge.size) - .fontWeight(workspace.path === this.selectedWorkspacePath ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSelected(workspace) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -155,10 +178,10 @@ export struct SidebarWorkspacePicker { .layoutWeight(1) .alignItems(HorizontalAlign.Start) Stack({ alignContent: Alignment.Center }) { - SymbolGlyph(workspace.path === this.selectedWorkspacePath ? + SymbolGlyph(this.isSelected(workspace) ? $r('sys.symbol.checkmark') : $r('sys.symbol.chevron_right')) - .fontSize(workspace.path === this.selectedWorkspacePath ? 17 : 15) - .fontColor([workspace.path === this.selectedWorkspacePath ? INK : MUTED]) + .fontSize(this.isSelected(workspace) ? 17 : 15) + .fontColor([this.isSelected(workspace) ? INK : MUTED]) } .width(40) .height(40) @@ -167,8 +190,8 @@ export struct SidebarWorkspacePicker { .height(68) .padding({ left: 10, right: 8 }) .borderRadius(14) - .backgroundColor(workspace.path === this.selectedWorkspacePath ? SOFT : PAGE_BG) - .onClick(() => this.onSelectWorkspace(workspace.path, workspace.remoteConnectionId)) + .backgroundColor(this.isSelected(workspace) ? SOFT : PAGE_BG) + .onClick(() => this.onSelectWorkspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId)) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index 383bdbc134..a38e044f2b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -43,6 +43,8 @@ export struct SidebarWorkspaceSection { @Param workspacePath: string = ''; @Param workspaceConnectionId: string = ''; @Param workspaceSshHost: string = ''; + /** Stable record ID of the active workspace; empty for pre-ID hosts. */ + @Param workspaceId: string = ''; @Param workspaceKind: string = 'normal'; @Param connectionState: string = 'idle'; @Param controlTargetType: string = 'none'; @@ -56,12 +58,12 @@ export struct SidebarWorkspaceSection { SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; @Event onSessionActions: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string) => void = - (_deviceId: string, _path: string, _agentType: string) => {}; - @Event onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string) => void = + @Event onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string, + workspaceId?: string) => void = (_deviceId: string, _path: string, _agentType: string) => {}; + @Event onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => void = (_deviceId: string, _path: string) => {}; - @Event onExpandWorkspace: (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string) => Promise = - async (_deviceId: string, _path: string) => {}; + @Event onExpandWorkspace: (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, + workspaceId?: string) => Promise = async (_deviceId: string, _path: string) => {}; @Event onSelectDevice: (deviceId: string) => void = (_deviceId: string) => {}; @Event onReconnect: () => void = () => {}; @Event onRetryDevice: (deviceId: string) => void = (_deviceId: string) => {}; @@ -259,6 +261,7 @@ export struct SidebarWorkspaceSection { workspacePath: this.liveWorkspacePath(entry), workspaceConnectionId: this.isActiveDevice(entry.deviceId) ? this.workspaceConnectionId : '', workspaceSshHost: this.isActiveDevice(entry.deviceId) ? this.workspaceSshHost : '', + workspaceId: this.isActiveDevice(entry.deviceId) ? this.workspaceId : '', workspaceKind: this.liveWorkspaceKind(entry), selectedSessionId: this.selectedSessionIdFor(entry), runningSessionId: this.isActiveDevice(entry.deviceId) ? this.runningSessionId : '', @@ -272,17 +275,18 @@ export struct SidebarWorkspaceSection { }, onOpenSession: this.onOpenSession, onSessionActions: this.onSessionActions, - onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string) => { - this.onCreateInWorkspace(entry.deviceId, path, agentType, connectionId, sshHost); + onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string) => { + this.onCreateInWorkspace(entry.deviceId, path, agentType, connectionId, sshHost, workspaceId); }, - onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string) => { - this.onOpenWorkspace(entry.deviceId, path, connectionId, sshHost); + onOpenWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => { + this.onOpenWorkspace(entry.deviceId, path, connectionId, sshHost, workspaceId); }, - onExpandWorkspace: async (path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise => { - await this.onExpandWorkspace(entry.deviceId, path, remoteConnectionId, remoteSshHost); + onExpandWorkspace: async (path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise => { + await this.onExpandWorkspace(entry.deviceId, path, remoteConnectionId, remoteSshHost, workspaceId); }, - onWorkspaceExpandedChange: (path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string): void => { - this.directoryState.setWorkspaceExpanded(entry.deviceId, path, expanded, remoteConnectionId, remoteSshHost); + onWorkspaceExpandedChange: (path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string, + workspaceId?: string): void => { + this.directoryState.setWorkspaceExpanded(entry.deviceId, path, expanded, remoteConnectionId, remoteSshHost, workspaceId); } }) } @@ -337,14 +341,18 @@ export struct SidebarWorkspaceSection { this.workspacePickerDevice().recentWorkspaces : this.workspacesForDevice(this.workspacePickerDevice()), selectedWorkspacePath: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? this.workspacePath : '', + selectedWorkspaceId: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? this.workspaceId : '', + selectedWorkspaceConnectionId: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? + this.workspaceConnectionId : '', + selectedWorkspaceSshHost: this.isActiveDevice(this.workspacePickerDevice().deviceId) ? this.workspaceSshHost : '', loading: this.workspacePickerDevice().status === 'loading', onClose: () => { this.showWorkspacePicker = false; }, - onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string) => { + onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => { const deviceId = this.workspacePickerDeviceId; this.showWorkspacePicker = false; - this.onOpenWorkspace(deviceId, path, connectionId, sshHost); + this.onOpenWorkspace(deviceId, path, connectionId, sshHost, workspaceId); } }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index 40879b9ef6..ff7aecab26 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -180,6 +180,7 @@ export struct WideConversationHost { workspacePath: this.remotePageState.workspacePath, workspaceConnectionId: this.remotePageState.workspaceConnectionId, workspaceSshHost: this.remotePageState.workspaceSshHost, + workspaceId: this.remotePageState.workspaceId, workspaceKind: this.remotePageState.workspaceKind, connectionState: this.remotePageState.connectionState, controlTargetType: this.remotePageState.controlTargetType, @@ -193,14 +194,16 @@ export struct WideConversationHost { REMOTE_CAPABILITY_HARNESS_PROFILES_V1), workspacePickerPlacement: this.sessionDetailsPlacement, onOpenSession: this.actions.onRemoteHome.openSessionInPlace, - onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string) => { - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType, deviceId, connectionId, sshHost); + onCreateInWorkspace: (deviceId: string, path: string, agentType: string, connectionId?: string, sshHost?: string, + workspaceId?: string) => { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType, deviceId, connectionId, sshHost, workspaceId); }, - onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string) => { - this.actions.onRemoteHome.selectWorkspace(path, deviceId, connectionId, sshHost); + onOpenWorkspace: (deviceId: string, path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => { + this.actions.onRemoteHome.selectWorkspace(path, deviceId, connectionId, sshHost, workspaceId); }, - onExpandWorkspace: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise => { - await this.actions.onRemoteHome.loadWorkspaceSessions(deviceId, path, remoteConnectionId, remoteSshHost); + onExpandWorkspace: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, + workspaceId?: string): Promise => { + await this.actions.onRemoteHome.loadWorkspaceSessions(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId); }, onSelectDevice: this.actions.onRemoteHome.selectDirectoryDevice, onRetryDevice: this.actions.onRemoteHome.retryDirectoryDevice, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 612b4a5b6b..b004038f5d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -108,6 +108,9 @@ export struct RemoteSurfaceHost { statusFilter: this.presentationState.statusFilter, workspaceName: this.remotePageState.workspaceName, workspacePath: this.remotePageState.workspacePath, + workspaceId: this.remotePageState.workspaceId, + workspaceConnectionId: this.remotePageState.workspaceConnectionId, + workspaceSshHost: this.remotePageState.workspaceSshHost, workspaceKind: this.remotePageState.workspaceKind, recentWorkspaces: this.remotePageState.recentWorkspaces, actionPresentation: SessionActionPresentation.Popover, @@ -126,8 +129,10 @@ export struct RemoteSurfaceHost { (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), onCreate: () => this.createSession('code'), onCreateAssistantSession: () => this.createAssistantSession(), - onCreateInWorkspace: (path: string, agentType: string) => this.createSessionInWorkspace(path, agentType), - onSelectWorkspace: (path: string) => this.actions.onRemoteHome.selectWorkspace(path), + onCreateInWorkspace: (path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string) => + this.createSessionInWorkspace(path, agentType, connectionId, sshHost, workspaceId), + onSelectWorkspace: (path: string, connectionId?: string, sshHost?: string, workspaceId?: string) => + this.actions.onRemoteHome.selectWorkspace(path, undefined, connectionId, sshHost, workspaceId), onOpenSession: (session: RemoteSession) => this.openSession(session), onDeleteSession: (session: RemoteSession) => this.actions.onRemoteHome.deleteSession(session), onLoadMore: () => this.actions.onRemoteHome.loadMore() @@ -313,12 +318,14 @@ export struct RemoteSurfaceHost { } } - private createSessionInWorkspace(path: string, agentType: string): void { + private createSessionInWorkspace( + path: string, agentType: string, connectionId?: string, sshHost?: string, workspaceId?: string + ): void { if (this.compact) { this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType); + this.actions.onRemoteHome.createInWorkspace(path, agentType, undefined, connectionId, sshHost, workspaceId); } else { - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType, undefined, connectionId, sshHost, workspaceId); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets index b7bc453661..d9667d196a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets @@ -1,5 +1,7 @@ -import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { LegacyWorkspaceCompatibility } from '../../services/LegacyWorkspaceCompatibility'; +import { remoteWorkspaceIdentityMatches, remoteWorkspaceKey } from '../../services/RemoteSessionIdentity'; import { TimeFormat } from '../../services/TimeFormat'; import { ConversationSessionFilterPolicy } from './ConversationSessionFilterPolicy'; @@ -10,6 +12,10 @@ export interface SessionListInputs { sortMode: string; workspaceName: string; workspacePath: string; + /** Stable record ID of the active workspace; absent on pre-ID hosts. */ + workspaceId?: string; + workspaceConnectionId?: string; + workspaceSshHost?: string; workspaceKind: string; recentWorkspaces: RecentWorkspaceEntry[]; workspaceFilter: string; @@ -34,6 +40,7 @@ export interface SessionListProjection { yesterday: RemoteSession[]; earlier: RemoteSession[]; projects: RecentWorkspaceEntry[]; + /** Keyed by `remoteWorkspaceKey` of the owning project entry. */ projectSessions: Map; metadata: Map; hasActiveFilter: boolean; @@ -41,6 +48,16 @@ export interface SessionListProjection { const EMPTY_SESSIONS: RemoteSession[] = []; +/** The catalog rows and current workspace, resolved once per projection. */ +interface WorkspaceAttribution { + /** Every reference the legacy resolver may attribute an ID-less session to. */ + catalog: RemoteWorkspaceIdentity[]; + /** Section keys of assistant-kind rows. */ + assistantKeys: Set; + /** The active workspace as a catalog row, when the host has one open. */ + current: RecentWorkspaceEntry | undefined; +} + /** * Turns the raw session list into the shape the sidebar renders, in one pass. * @@ -55,7 +72,7 @@ const EMPTY_SESSIONS: RemoteSession[] = []; */ export class SessionListProjector { static project(inputs: SessionListInputs): SessionListProjection { - const assistantPaths: Set = SessionListProjector.assistantWorkspacePaths(inputs.recentWorkspaces); + const attribution = SessionListProjector.workspaceAttribution(inputs); const hasActiveFilter = inputs.query.trim().length > 0 || inputs.workspaceFilter.length > 0 || inputs.agentFilter.length > 0 || inputs.statusFilter.length > 0; const wantsMetadata = inputs.showWorkspaceMetadata || inputs.showUpdatedMetadata || inputs.showStatusMetadata; @@ -65,12 +82,21 @@ export class SessionListProjector { const projectSessions: Map = new Map(); const metadata: Map = new Map(); const nowMs = Date.now(); + const projectEntries = SessionListProjector.projectEntries(inputs, attribution); + // Sessions pinned to a workspace ID land in their section by one map + // lookup; only ID-less sessions pay for the legacy resolver. + const keysByWorkspaceId: Map = new Map(); + projectEntries.forEach((entry: RecentWorkspaceEntry) => { + if (entry.workspaceId) { + keysByWorkspaceId.set(entry.workspaceId, remoteWorkspaceKey(entry)); + } + }); inputs.sessions.forEach((item: RemoteSession) => { // Decided once and carried through the rest of the pass. It is the // expensive predicate — it searches the recent-workspace list — and it // feeds the filter, the chat/project split and the agent grouping alike. - const assistant = SessionListProjector.isAssistantSession(item, inputs, assistantPaths); + const assistant = SessionListProjector.isAssistantSession(item, inputs, attribution); const matches = ConversationSessionFilterPolicy.matches( item, inputs.query, @@ -87,8 +113,7 @@ export class SessionListProjector { if (assistant) { chats.push(item); } else { - const key = ConversationSessionFilterPolicy.normalizeWorkspacePath( - item.workspacePath ? item.workspacePath : inputs.workspacePath); + const key = SessionListProjector.sectionKey(item, inputs, attribution, projectEntries, keysByWorkspaceId); const bucket = projectSessions.get(key); if (bucket === undefined) { projectSessions.set(key, [item]); @@ -101,7 +126,7 @@ export class SessionListProjector { } }); - const projects = SessionListProjector.projectEntries(inputs, assistantPaths, hasActiveFilter, projectSessions); + const projects = SessionListProjector.visibleProjectEntries(projectEntries, hasActiveFilter, projectSessions); // Sorting by time parses two timestamps per session per comparison, so it // stays behind the mode that asks for it. const byTime = inputs.sortMode === 'time' ? SessionListProjector.sortByTime(filtered) : EMPTY_SESSIONS; @@ -120,12 +145,32 @@ export class SessionListProjector { }; } - /** The sessions filed under a workspace, or an empty list when there are none. */ - static sessionsForProject(projection: SessionListProjection, path: string): RemoteSession[] { - const bucket = projection.projectSessions.get(ConversationSessionFilterPolicy.normalizeWorkspacePath(path)); + /** + * The sessions filed under a workspace, or an empty list when there are none. + * + * A reference with an ID reads its section directly. A pre-ID reference is + * attributed to a project entry through the legacy resolver first, so a + * trailing slash or a missing connection id cannot hide a section. + */ + static sessionsForProject(projection: SessionListProjection, workspace: RemoteWorkspaceIdentity): RemoteSession[] { + const direct = projection.projectSessions.get(remoteWorkspaceKey(workspace)); + if (direct !== undefined) { + return direct; + } + if (workspace.workspaceId) { + return EMPTY_SESSIONS; + } + const owner = projection.projects.find((entry: RecentWorkspaceEntry): boolean => + remoteWorkspaceIdentityMatches(workspace, entry, projection.projects)); + const bucket = owner === undefined ? undefined : projection.projectSessions.get(remoteWorkspaceKey(owner)); return bucket === undefined ? EMPTY_SESSIONS : bucket; } + /** The key a project entry's section is filed under. */ + static sectionKeyFor(entry: RemoteWorkspaceIdentity): string { + return remoteWorkspaceKey(entry); + } + static metadataFor(projection: SessionListProjection, sessionId: string): string { const value = projection.metadata.get(sessionId); return value === undefined ? '' : value; @@ -146,77 +191,161 @@ export class SessionListProjector { }; } - private static assistantWorkspacePaths(entries: RecentWorkspaceEntry[]): Set { - const paths: Set = new Set(); - entries.forEach((item: RecentWorkspaceEntry) => { + /** + * The active workspace as a catalog row. + * + * The page state may carry less than the catalog does — an older cache + * without the connection id, or a host that has not sent the ID — so an + * ID-less current workspace is completed from its unique catalog match. + */ + private static currentWorkspaceEntry(inputs: SessionListInputs): RecentWorkspaceEntry | undefined { + const workspaceId = inputs.workspaceId?.trim() || ''; + if (inputs.workspacePath.length === 0 && workspaceId.length === 0) { + return undefined; + } + const reference: RemoteWorkspaceIdentity = { + workspaceId: workspaceId.length > 0 ? workspaceId : undefined, + path: inputs.workspacePath, + remoteConnectionId: inputs.workspaceConnectionId?.trim() || undefined, + remoteSshHost: inputs.workspaceSshHost?.trim() || undefined + }; + const resolved = LegacyWorkspaceCompatibility.resolve(reference, inputs.recentWorkspaces); + return { + workspaceId: reference.workspaceId || resolved?.workspaceId, + path: inputs.workspacePath.length > 0 ? inputs.workspacePath : (resolved?.path || ''), + name: inputs.workspaceName, + remoteConnectionId: reference.remoteConnectionId !== undefined ? reference.remoteConnectionId : resolved?.remoteConnectionId, + remoteSshHost: reference.remoteSshHost !== undefined ? reference.remoteSshHost : resolved?.remoteSshHost, + lastOpened: '', + workspaceKind: inputs.workspaceKind.toLowerCase() === 'assistant' ? 'assistant' : 'normal' + }; + } + + private static workspaceAttribution(inputs: SessionListInputs): WorkspaceAttribution { + const current = SessionListProjector.currentWorkspaceEntry(inputs); + const catalog: RemoteWorkspaceIdentity[] = []; + const assistantKeys: Set = new Set(); + inputs.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { + catalog.push(item); if (item.workspaceKind.toLowerCase() === 'assistant') { - paths.add(item.path); + assistantKeys.add(remoteWorkspaceKey(item)); } }); - return paths; + if (current !== undefined) { + // The current workspace joins the catalog only when no row already + // names it; a duplicate would make every path lookup ambiguous. + const known = inputs.recentWorkspaces.some((item: RecentWorkspaceEntry): boolean => + remoteWorkspaceIdentityMatches(item, current, inputs.recentWorkspaces)); + if (!known) { + catalog.push(current); + } + if (current.workspaceKind === 'assistant') { + assistantKeys.add(remoteWorkspaceKey(current)); + } + } + return { catalog, assistantKeys, current }; } - private static isAssistantWorkspace( - path: string, - inputs: SessionListInputs, - assistantPaths: Set - ): boolean { - if (path.length === 0) { - return inputs.workspaceKind.toLowerCase() === 'assistant'; + /** + * Whether a reference names an assistant workspace. + * + * Kind is a fact of the catalog row, so the reference is attributed to its + * row first: by ID when it has one, otherwise through the legacy resolver. + * An unresolvable reference is not an assistant. + */ + private static isAssistantWorkspace(reference: RemoteWorkspaceIdentity, attribution: WorkspaceAttribution): boolean { + if (attribution.assistantKeys.size === 0) { + return false; } - if (path === inputs.workspacePath && inputs.workspaceKind.toLowerCase() === 'assistant') { - return true; + if (reference.workspaceId) { + return attribution.assistantKeys.has(remoteWorkspaceKey(reference)); } - return assistantPaths.has(path); + const resolved = LegacyWorkspaceCompatibility.resolve(reference, attribution.catalog); + return resolved !== undefined && attribution.assistantKeys.has(remoteWorkspaceKey(resolved)); } private static isAssistantSession( item: RemoteSession, inputs: SessionListInputs, - assistantPaths: Set + attribution: WorkspaceAttribution ): boolean { const agentType = (item.agentType ? item.agentType : '').toLowerCase(); if (agentType === 'claw' || agentType === 'assistant' || agentType === 'chat') { return true; } - return SessionListProjector.isAssistantWorkspace( - item.workspacePath ? item.workspacePath : '', inputs, assistantPaths); + if (item.workspaceIdentity) { + return SessionListProjector.isAssistantWorkspace(item.workspaceIdentity, attribution); + } + const path = item.workspacePath ? item.workspacePath : ''; + if (path.length === 0) { + return inputs.workspaceKind.toLowerCase() === 'assistant'; + } + return SessionListProjector.isAssistantWorkspace({ path }, attribution); } - private static projectEntries( + /** The section a filtered, non-assistant session is filed under. */ + private static sectionKey( + item: RemoteSession, inputs: SessionListInputs, - assistantPaths: Set, - hasActiveFilter: boolean, - projectSessions: Map - ): RecentWorkspaceEntry[] { + attribution: WorkspaceAttribution, + projectEntries: RecentWorkspaceEntry[], + keysByWorkspaceId: Map + ): string { + const identity = item.workspaceIdentity; + if (identity !== undefined && identity.workspaceId) { + const known = keysByWorkspaceId.get(identity.workspaceId); + // An ID the catalog does not list still keys its own section rather than + // being guessed onto a row by path. + return known === undefined ? remoteWorkspaceKey(identity) : known; + } + // Pre-ID sessions: a legacy reference, attributed to a row through the + // single compatibility resolver. Sessions that name no workspace at all + // belong to the workspace the list was fetched for. + const path = identity !== undefined ? identity.path : (item.workspacePath ? item.workspacePath : ''); + if (path.length === 0 && identity === undefined) { + return attribution.current !== undefined ? remoteWorkspaceKey(attribution.current) : + remoteWorkspaceKey({ path: inputs.workspacePath }); + } + const reference: RemoteWorkspaceIdentity = identity !== undefined ? identity : { path }; + const owner = projectEntries.find((entry: RecentWorkspaceEntry): boolean => + remoteWorkspaceIdentityMatches(reference, entry, attribution.catalog)); + if (owner !== undefined) { + return remoteWorkspaceKey(owner); + } + const resolved = LegacyWorkspaceCompatibility.resolve(reference, attribution.catalog); + return remoteWorkspaceKey(resolved !== undefined ? resolved : reference); + } + + private static projectEntries(inputs: SessionListInputs, attribution: WorkspaceAttribution): RecentWorkspaceEntry[] { const entries: RecentWorkspaceEntry[] = []; - const currentIsAssistant = SessionListProjector.isAssistantWorkspace(inputs.workspacePath, inputs, assistantPaths); - if (inputs.workspacePath.length > 0 && !currentIsAssistant) { - entries.push({ - path: inputs.workspacePath, - name: inputs.workspaceName, - remoteSshHost: inputs.recentWorkspaces.find((item: RecentWorkspaceEntry) => item.path === inputs.workspacePath)?.remoteSshHost, - lastOpened: '', - workspaceKind: 'normal' - }); + const current = attribution.current; + if (current !== undefined && current.workspaceKind !== 'assistant' && current.path.length > 0) { + entries.push(current); } inputs.recentWorkspaces.forEach((item: RecentWorkspaceEntry) => { - if (item.path.length === 0 || - SessionListProjector.isAssistantWorkspace(item.path, inputs, assistantPaths)) { + if (item.path.length === 0 || SessionListProjector.isAssistantWorkspace(item, attribution)) { return; } - if (entries.some((entry: RecentWorkspaceEntry) => entry.path === item.path)) { + if (entries.some((entry: RecentWorkspaceEntry) => remoteWorkspaceIdentityMatches(entry, item, attribution.catalog))) { return; } entries.push(item); }); + return entries; + } + + private static visibleProjectEntries( + entries: RecentWorkspaceEntry[], + hasActiveFilter: boolean, + projectSessions: Map + ): RecentWorkspaceEntry[] { if (!hasActiveFilter) { return entries; } // A filter narrow enough to empty a project takes that project's header off // the list too, so the sidebar does not read as a wall of empty groups. return entries.filter((item: RecentWorkspaceEntry) => { - const bucket = projectSessions.get(ConversationSessionFilterPolicy.normalizeWorkspacePath(item.path)); + const bucket = projectSessions.get(remoteWorkspaceKey(item)); return bucket !== undefined && bucket.length > 0; }); } @@ -342,6 +471,7 @@ export class SessionListProjectionCache { // depends on. Off — which is the default — nothing here moves on its own. const minute = inputs.showUpdatedMetadata ? Math.floor(Date.now() / 60000) : 0; return `${inputs.query}${inputs.sortMode}${inputs.workspaceName}${inputs.workspacePath}` + + `${inputs.workspaceId || ''}${inputs.workspaceConnectionId || ''}${inputs.workspaceSshHost || ''}` + `${inputs.workspaceKind}${inputs.workspaceFilter}${inputs.agentFilter}` + `${inputs.statusFilter}${inputs.showWorkspaceMetadata ? 1 : 0}` + `${inputs.showUpdatedMetadata ? 1 : 0}${inputs.showStatusMetadata ? 1 : 0}${minute}`; @@ -349,14 +479,16 @@ export class SessionListProjectionCache { private static sessionFingerprint(sessions: RemoteSession[]): string[] { return sessions.map((item: RemoteSession): string => { + const identity = item.workspaceIdentity; return `${item.deviceId || ''}${item.id}${item.title}${item.status}${item.agentType}` + - `${item.workspacePath}${item.workspaceName}${item.updatedAt}${item.createdAt}`; + `${item.workspacePath}${item.workspaceName}${item.updatedAt}${item.createdAt}` + + `${identity === undefined ? '' : remoteWorkspaceKey(identity)}`; }); } private static workspaceFingerprint(entries: RecentWorkspaceEntry[]): string[] { return entries.map((item: RecentWorkspaceEntry): string => { - return `${item.path}${item.name}${item.workspaceKind}\u0001${item.remoteSshHost || ''}`; + return `${remoteWorkspaceKey(item)}${item.name}${item.workspaceKind}${item.remoteSshHost || ''}`; }); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 9732e1b224..a28a5d5363 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -1,10 +1,16 @@ -import { RemoteSession, SessionSummary, SessionListResult } from '../../model/RemoteModels'; +import { + RecentWorkspaceEntry, + RemoteSession, + RemoteWorkspaceIdentity, + SessionSummary, + SessionListResult +} from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { RemoteLogger } from '../../services/RemoteLogger'; import { - remoteWorkspaceKey, + remoteWorkspaceIdentityMatches, stampRemoteSessionsDevice } from '../../services/RemoteSessionIdentity'; import { RemoteUiState } from '../../services/RemoteUiState'; @@ -240,14 +246,14 @@ export class AppRootRuntime extends AppRootRuntimeComposition { this.remoteActivityViewModel.stopHeartbeat(); } - async selectWorkspace(path: string, connectionId?: string, sshHost?: string): Promise { + async selectWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { this.filePreviewController.close(); - await this.remoteWorkspaceViewModel.selectWorkspace(path, connectionId, sshHost); + await this.remoteWorkspaceViewModel.selectWorkspace(path, connectionId, sshHost, workspaceId); } - async selectWorkspaceOnDevice(path: string, deviceId?: string, connectionId?: string, sshHost?: string): Promise { + async selectWorkspaceOnDevice(path: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { await this.conversationController.ensureRemoteControlTarget(deviceId); - await this.selectWorkspace(path, connectionId, sshHost); + await this.selectWorkspace(path, connectionId, sshHost, workspaceId); } async selectDirectoryDevice(deviceId: string): Promise { @@ -304,22 +310,24 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.deviceDirectoryViewModel.retryDevice(deviceId); } - async loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise { + async loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise { const targetDeviceId = deviceId.trim(); const targetPath = path.trim(); if (targetDeviceId.length === 0 || targetPath.length === 0) { return; } - await this.deviceDirectoryViewModel.loadWorkspaceSessions(targetDeviceId, targetPath, remoteConnectionId, remoteSshHost); + await this.deviceDirectoryViewModel.loadWorkspaceSessions(targetDeviceId, targetPath, remoteConnectionId, remoteSshHost, workspaceId); } - publishWorkspaceSessionPage(path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string): void { - this.remoteWorkspaceSessions = this.replaceWorkspaceSessions(path, result.sessions, remoteConnectionId, remoteSshHost); - if (remoteWorkspaceKey({ path, remoteConnectionId, remoteSshHost }) === remoteWorkspaceKey({ - path: this.remotePageState.workspacePath, - remoteConnectionId: this.remotePageState.workspaceConnectionId, - remoteSshHost: this.remotePageState.workspaceSshHost - })) { + publishWorkspaceSessionPage(path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void { + const workspace: RemoteWorkspaceIdentity = { + workspaceId: workspaceId?.trim() || undefined, + path, + remoteConnectionId, + remoteSshHost + }; + this.remoteWorkspaceSessions = this.replaceWorkspaceSessions(workspace, result.sessions); + if (this.isCurrentWorkspace(workspace)) { // Update the controller as well as the sidebar so a later title change // cannot republish the old current-workspace list. this.remoteSessionController.setSessions(result.sessions, result.hasMore); @@ -333,9 +341,37 @@ export class AppRootRuntime extends AppRootRuntimeComposition { RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)); } - async selectAssistant(path: string): Promise { + async selectAssistant(path: string, workspaceId?: string): Promise { this.filePreviewController.close(); - await this.remoteWorkspaceViewModel.selectAssistant(path); + await this.remoteWorkspaceViewModel.selectAssistant(path, workspaceId); + } + + /** Catalog rows the legacy resolver may attribute an ID-less reference to. */ + private workspaceAttributionCatalog(): RemoteWorkspaceIdentity[] { + const recent = this.remotePageState.recentWorkspaces; + const rows: RemoteWorkspaceIdentity[] = []; + recent.forEach((entry: RecentWorkspaceEntry) => rows.push(entry)); + const current = this.remotePageState.workspaceIdentity(); + if (current.path.length > 0 || current.workspaceId !== undefined) { + // The current workspace joins the catalog only when no row already names + // it; a duplicate row would make every legacy path lookup ambiguous. + const known = recent.some((entry: RecentWorkspaceEntry): boolean => + remoteWorkspaceIdentityMatches(entry, current, recent)); + if (!known) { + rows.push(current); + } + } + return rows; + } + + /** Whether a reference names the desktop's active workspace. */ + private isCurrentWorkspace(workspace: RemoteWorkspaceIdentity): boolean { + return remoteWorkspaceIdentityMatches(workspace, this.remotePageState.workspaceIdentity(), this.workspaceAttributionCatalog()); + } + + private sessionOwnedBy(item: RemoteSession, workspace: RemoteWorkspaceIdentity): boolean { + return item.workspaceIdentity !== undefined && + remoteWorkspaceIdentityMatches(item.workspaceIdentity, workspace, this.workspaceAttributionCatalog()); } openAppSidebar(): void { @@ -458,11 +494,13 @@ export class AppRootRuntime extends AppRootRuntimeComposition { */ async reconcileCreatedRemoteSession(session: SessionSummary): Promise { const workspacePath = session.workspacePath.trim(); - const remoteConnectionId = session.remoteConnectionId; - const remoteSshHost = session.remoteSshHost; - if (workspacePath.length === 0 || remoteWorkspaceKey({ path: workspacePath, remoteConnectionId, remoteSshHost }) === - remoteWorkspaceKey({ path: this.remotePageState.workspacePath, - remoteConnectionId: this.remotePageState.workspaceConnectionId, remoteSshHost: this.remotePageState.workspaceSshHost })) { + const workspace: RemoteWorkspaceIdentity = { + workspaceId: session.workspaceId?.trim() || undefined, + path: workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost + }; + if ((workspacePath.length === 0 && !workspace.workspaceId) || this.isCurrentWorkspace(workspace)) { await this.remoteSessionViewModel.refreshSessions(); return; } @@ -477,56 +515,47 @@ export class AppRootRuntime extends AppRootRuntimeComposition { createdAt: now, messageCount: session.initialTurnId ? 1 : 0, workspacePath, - workspaceIdentity: { path: workspacePath, remoteConnectionId, remoteSshHost } + workspaceIdentity: workspace }; this.remoteWorkspaceSessions = this.replaceWorkspaceSessions( - workspacePath, - this.mergeSessions([created], this.sessionsForWorkspace(workspacePath, remoteConnectionId, remoteSshHost)), - remoteConnectionId, remoteSshHost + workspace, + this.mergeSessions([created], this.sessionsForWorkspace(workspace)) ); this.publishRemoteWorkspaceSessions(); try { - const refreshed = await this.sessionManager.listSessionsForWorkspace(workspacePath, 50, remoteConnectionId, remoteSshHost); + const refreshed = await this.sessionManager.listSessionsForWorkspace(workspace, 50); const containsCreated = refreshed.some((item: RemoteSession): boolean => item.id === session.sessionId); this.remoteWorkspaceSessions = this.replaceWorkspaceSessions( - workspacePath, - containsCreated ? refreshed : this.mergeSessions([created], refreshed), - remoteConnectionId, remoteSshHost + workspace, + containsCreated ? refreshed : this.mergeSessions([created], refreshed) ); this.publishRemoteWorkspaceSessions(); } catch (err) { // The create already succeeded and is on screen. Keep that confirmed row // instead of turning a follow-up list failure into a false create error. - RemoteLogger.warn(`created session workspace refresh failed path=${workspacePath}: ${String(err)}`); + RemoteLogger.warn(`created session workspace refresh failed workspace=${workspace.workspaceId || ''} path=${workspacePath}: ${String(err)}`); } } - private sessionsForWorkspace(path: string, remoteConnectionId?: string, remoteSshHost?: string): RemoteSession[] { - const key = remoteWorkspaceKey({ path, remoteConnectionId, remoteSshHost }); - return this.remotePageState.sessions.filter((item: RemoteSession): boolean => - item.workspaceIdentity !== undefined && remoteWorkspaceKey(item.workspaceIdentity) === key); + private sessionsForWorkspace(workspace: RemoteWorkspaceIdentity): RemoteSession[] { + return this.remotePageState.sessions.filter((item: RemoteSession): boolean => this.sessionOwnedBy(item, workspace)); } - private replaceWorkspaceSessions(path: string, replacement: RemoteSession[], remoteConnectionId?: string, remoteSshHost?: string): RemoteSession[] { - const key = remoteWorkspaceKey({ path, remoteConnectionId, remoteSshHost }); + private replaceWorkspaceSessions(workspace: RemoteWorkspaceIdentity, replacement: RemoteSession[]): RemoteSession[] { const preserved = this.remotePageState.sessions.filter((item: RemoteSession): boolean => { - return !item.workspaceIdentity || remoteWorkspaceKey(item.workspaceIdentity) !== key; + return !this.sessionOwnedBy(item, workspace); }); return replacement.concat(preserved); } private publishRemoteWorkspaceSessions(): void { - const currentKey = remoteWorkspaceKey({ - path: this.remotePageState.workspacePath, - remoteConnectionId: this.remotePageState.workspaceConnectionId, - remoteSshHost: this.remotePageState.workspaceSshHost - }); + const currentWorkspace = this.remotePageState.workspaceIdentity(); const current = this.remotePageState.sessions.filter((item: RemoteSession): boolean => { - return !item.workspaceIdentity || remoteWorkspaceKey(item.workspaceIdentity) === currentKey; + return !item.workspaceIdentity || this.sessionOwnedBy(item, currentWorkspace); }); const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession): boolean => { - return !item.workspaceIdentity || remoteWorkspaceKey(item.workspaceIdentity) !== currentKey; + return !item.workspaceIdentity || !this.sessionOwnedBy(item, currentWorkspace); }); this.publishRemoteSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); } @@ -545,15 +574,17 @@ export class AppRootRuntime extends AppRootRuntimeComposition { const stamped = stampRemoteSessionsDevice(scoped, deviceId); this.remotePageState.setSessions(stamped, hasMore); this.remoteSessionListCache.save(stamped, hasMore); - const currentPath = this.remotePageState.workspacePath; - const remoteConnectionId = this.remotePageState.workspaceConnectionId; - const remoteSshHost = this.remotePageState.workspaceSshHost; - const currentKey = remoteWorkspaceKey({ path: currentPath, remoteConnectionId, remoteSshHost }); - if (currentPath.length > 0) { - const currentSessions = stamped.filter((item: RemoteSession): boolean => { - return item.workspaceIdentity !== undefined && remoteWorkspaceKey(item.workspaceIdentity) === currentKey; - }); - this.deviceDirectoryViewModel.captureWorkspaceSessions(deviceId, currentPath, currentSessions, remoteConnectionId, remoteSshHost); + const currentWorkspace = this.remotePageState.workspaceIdentity(); + if (currentWorkspace.path.length > 0) { + const currentSessions = stamped.filter((item: RemoteSession): boolean => this.sessionOwnedBy(item, currentWorkspace)); + this.deviceDirectoryViewModel.captureWorkspaceSessions( + deviceId, + currentWorkspace.path, + currentSessions, + currentWorkspace.remoteConnectionId, + currentWorkspace.remoteSshHost, + currentWorkspace.workspaceId + ); } this.deviceDirectoryViewModel.captureLive( deviceId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 9c1092b8f7..5b453d8aae 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -125,22 +125,22 @@ export abstract class AppRootRuntimeComposition { abstract hasRemoteBindingForResume(): boolean; abstract isRemoteConversationContext(sessionId: string): boolean; abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; - abstract loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise; + abstract loadWorkspaceSessionsOnDevice(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise; abstract openAddConnectionFromSettings(): void; abstract openAppSidebar(): void; abstract openRemoteControlSettings(): void; abstract openWorkspacePickerOnDevice(deviceId: string): Promise; abstract pickImages(): Promise; abstract publishRemoteSessions(sessions: RemoteSession[], hasMore: boolean): void; - abstract publishWorkspaceSessionPage(path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string): void; + abstract publishWorkspaceSessionPage(path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void; abstract reconcileCreatedRemoteSession(session: SessionSummary): Promise; abstract reconnect(): Promise; abstract reconnectActiveRemote(): Promise; abstract retryDirectoryDevice(deviceId: string): Promise; abstract selectDirectoryDevice(deviceId: string): Promise; - abstract selectAssistant(path: string): Promise; - abstract selectWorkspace(path: string, connectionId?: string, sshHost?: string): Promise; - abstract selectWorkspaceOnDevice(path: string, deviceId?: string, connectionId?: string, sshHost?: string): Promise; + abstract selectAssistant(path: string, workspaceId?: string): Promise; + abstract selectWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise; + abstract selectWorkspaceOnDevice(path: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise; abstract showVoiceInputError(message: string): void; abstract stopVoiceInput(showStatus: boolean): Promise; abstract toggleVoiceInput(): Promise; @@ -617,8 +617,8 @@ export abstract class AppRootRuntimeComposition { (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected ); }, - onSelectWorkspace: async (path: string): Promise => { - await this.selectWorkspace(path); + onSelectWorkspace: async (path: string, workspaceId?: string): Promise => { + await this.selectWorkspace(path, undefined, undefined, workspaceId); } } ); @@ -715,10 +715,15 @@ export abstract class AppRootRuntimeComposition { }, activeDeviceConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, - loadActiveWorkspaceSessions: (path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise => - this.sessionManager.listSessionPageForWorkspace(path, 50, remoteConnectionId, remoteSshHost), - onActiveWorkspaceSessions: (path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string): void => - this.publishWorkspaceSessionPage(path, result, remoteConnectionId, remoteSshHost) + loadActiveWorkspaceSessions: (path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise => + this.sessionManager.listSessionPageForWorkspace({ + workspaceId: workspaceId || undefined, + path, + remoteConnectionId, + remoteSshHost + }, 50), + onActiveWorkspaceSessions: (path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void => + this.publishWorkspaceSessionPage(path, result, remoteConnectionId, remoteSshHost, workspaceId) }, this.remoteSessionListStore ); @@ -749,8 +754,8 @@ export abstract class AppRootRuntimeComposition { isFilePreviewVisible: (): boolean => this.filePreviewState.visible, stopVoiceInput: async (): Promise => this.stopVoiceInput(false), showToast: (message: string): boolean => this.host.showToast(message, 2600), - selectAssistantWorkspace: async (path: string): Promise => { - await this.selectAssistant(path); + selectAssistantWorkspace: async (path: string, workspaceId?: string): Promise => { + await this.selectAssistant(path, workspaceId); } } } @@ -810,10 +815,10 @@ export abstract class AppRootRuntimeComposition { refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, showAssistants: (): void => { this.remoteWorkspaceViewModel.toggleAssistants(); }, - selectWorkspace: (path: string, deviceId?: string, connectionId?: string, sshHost?: string): void => { - void this.selectWorkspaceOnDevice(path, deviceId, connectionId, sshHost); + selectWorkspace: (path: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string): void => { + void this.selectWorkspaceOnDevice(path, deviceId, connectionId, sshHost, workspaceId); }, - selectAssistant: (path: string): void => { this.selectAssistant(path); }, + selectAssistant: (path: string, workspaceId?: string): void => { this.selectAssistant(path, workspaceId); }, cancelWorkspace: (): void => this.remotePageState.setWorkspacePickerVisible(false), cancelAssistant: (): void => this.remotePageState.setAssistantPickerVisible(false), queryChanged: (query: string): void => this.remotePageState.setQuery(query), @@ -826,11 +831,11 @@ export abstract class AppRootRuntimeComposition { createInPlace: (agentType: string): void => { this.conversationController.createRemoteSession(agentType, true); }, - createInWorkspace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string): void => { - this.conversationController.createRemoteSessionInWorkspace(path, agentType, false, deviceId, connectionId, sshHost); + createInWorkspace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, false, deviceId, connectionId, sshHost, workspaceId); }, - createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string): void => { - this.conversationController.createRemoteSessionInWorkspace(path, agentType, true, deviceId, connectionId, sshHost); + createInWorkspaceInPlace: (path: string, agentType: string, deviceId?: string, connectionId?: string, sshHost?: string, workspaceId?: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, true, deviceId, connectionId, sshHost, workspaceId); }, selectDirectoryDevice: (deviceId: string): void => { void this.selectDirectoryDevice(deviceId); @@ -841,8 +846,8 @@ export abstract class AppRootRuntimeComposition { retryDirectoryDevice: (deviceId: string): void => { void this.retryDirectoryDevice(deviceId); }, - loadWorkspaceSessions: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise => { - await this.loadWorkspaceSessionsOnDevice(deviceId, path, remoteConnectionId, remoteSshHost); + loadWorkspaceSessions: async (deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise => { + await this.loadWorkspaceSessionsOnDevice(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId); }, openSession: (session: RemoteSession): void => this.conversationController.openHomeSession(session), openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), @@ -1013,7 +1018,8 @@ export abstract class AppRootRuntimeComposition { this.remotePageState.workspaceBranch, this.remotePageState.workspaceKind, this.remotePageState.workspaceConnectionId, - this.remotePageState.workspaceSshHost + this.remotePageState.workspaceSshHost, + this.remotePageState.workspaceId ); this.remotePageState.setDownloadStatus( this.remotePageState.downloadingFilePath, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets index 2b75ee21b0..0c512a258b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceDirectoryState.ets @@ -1,4 +1,5 @@ -import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity } from '../../model/RemoteModels'; +import { remoteWorkspaceIdentityMatches } from '../../services/RemoteSessionIdentity'; export type DeviceDirectoryStatus = 'idle' | 'cached' | 'loading' | 'ready' | 'failed'; export type WorkspaceDirectoryStatus = 'idle' | 'loading' | 'ready' | 'failed'; @@ -10,9 +11,21 @@ export class WorkspaceDirectoryState { @Trace path: string = ''; @Trace remoteConnectionId: string = ''; @Trace remoteSshHost: string = ''; + /** Stable record ID of the disclosed row; empty for rows from pre-ID hosts. */ + @Trace workspaceId: string = ''; @Trace expanded: boolean = false; @Trace status: WorkspaceDirectoryStatus = 'idle'; @Trace hasLoadedSessions: boolean = false; + + /** The disclosed row as a workspace reference for listings and attribution. */ + workspaceIdentity(): RemoteWorkspaceIdentity { + return { + workspaceId: this.workspaceId.length > 0 ? this.workspaceId : undefined, + path: this.path, + remoteConnectionId: this.remoteConnectionId.length > 0 ? this.remoteConnectionId : undefined, + remoteSshHost: this.remoteSshHost.length > 0 ? this.remoteSshHost : undefined + }; + } } /** @@ -64,33 +77,33 @@ export class DeviceDirectoryState { return this.devices.find((item: DeviceDirectoryEntry): boolean => item.deviceId === deviceId); } - workspaceExpanded(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): boolean { - return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost)?.expanded || false; + workspaceExpanded(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): boolean { + return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId)?.expanded || false; } - workspaceStatus(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): WorkspaceDirectoryStatus { - return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost)?.status || 'idle'; + workspaceStatus(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): WorkspaceDirectoryStatus { + return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId)?.status || 'idle'; } - workspaceHasLoadedSessions(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): boolean { - return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost)?.hasLoadedSessions || false; + workspaceHasLoadedSessions(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): boolean { + return this.findWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId)?.hasLoadedSessions || false; } /** A request retains this object so a reset cannot publish into its successor. */ - workspaceLoadState(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): WorkspaceDirectoryState { - return this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost); + workspaceLoadState(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): WorkspaceDirectoryState { + return this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId); } isCurrentWorkspaceLoad(state: WorkspaceDirectoryState): boolean { - return this.findWorkspace(state.deviceId, state.path, state.remoteConnectionId, state.remoteSshHost) === state; + return this.findWorkspace(state.deviceId, state.path, state.remoteConnectionId, state.remoteSshHost, state.workspaceId) === state; } - setWorkspaceExpanded(deviceId: string, path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string): void { - this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost).expanded = expanded; + setWorkspaceExpanded(deviceId: string, path: string, expanded: boolean, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void { + this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId).expanded = expanded; } - setWorkspaceStatus(deviceId: string, path: string, status: WorkspaceDirectoryStatus, remoteConnectionId?: string, remoteSshHost?: string): void { - this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost).status = status; + setWorkspaceStatus(deviceId: string, path: string, status: WorkspaceDirectoryStatus, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void { + this.ensureWorkspace(deviceId, path, remoteConnectionId, remoteSshHost, workspaceId).status = status; } /** @@ -111,31 +124,57 @@ export class DeviceDirectoryState { ); } - private findWorkspace(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): WorkspaceDirectoryState | undefined { + /** + * Rows are matched like every other workspace reference: by ID when both + * sides carry one, by the legacy triple otherwise. Disclosure rows are + * device-local, so no catalog is needed to attribute an ID-less side. + */ + private findWorkspace( + deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string + ): WorkspaceDirectoryState | undefined { const targetDeviceId = deviceId.trim(); - const normalizedPath = DeviceDirectoryState.normalizeWorkspacePath(path); + const target: RemoteWorkspaceIdentity = DeviceDirectoryState.identityOf(path, remoteConnectionId, remoteSshHost, workspaceId); return this.workspaceDirectory.find((item: WorkspaceDirectoryState): boolean => - item.deviceId === targetDeviceId && item.path === normalizedPath && - item.remoteConnectionId === (remoteConnectionId?.trim() || '') && item.remoteSshHost === (remoteSshHost?.trim() || '') + item.deviceId === targetDeviceId && remoteWorkspaceIdentityMatches(item.workspaceIdentity(), target, []) ); } - private ensureWorkspace(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): WorkspaceDirectoryState { + private ensureWorkspace( + deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string + ): WorkspaceDirectoryState { const targetDeviceId = deviceId.trim(); - const normalizedPath = DeviceDirectoryState.normalizeWorkspacePath(path); - const existing = this.findWorkspace(targetDeviceId, normalizedPath, remoteConnectionId, remoteSshHost); + const target: RemoteWorkspaceIdentity = DeviceDirectoryState.identityOf(path, remoteConnectionId, remoteSshHost, workspaceId); + const existing = this.findWorkspace(targetDeviceId, target.path, target.remoteConnectionId, target.remoteSshHost, target.workspaceId); if (existing) { + if (existing.workspaceId.length === 0 && target.workspaceId !== undefined) { + existing.workspaceId = target.workspaceId; + } return existing; } const created = new WorkspaceDirectoryState(); created.deviceId = targetDeviceId; - created.path = normalizedPath; - created.remoteConnectionId = remoteConnectionId?.trim() || ''; - created.remoteSshHost = remoteSshHost?.trim() || ''; + created.path = target.path; + created.remoteConnectionId = target.remoteConnectionId || ''; + created.remoteSshHost = target.remoteSshHost || ''; + created.workspaceId = target.workspaceId || ''; this.workspaceDirectory = this.workspaceDirectory.concat([created]); return created; } + private static identityOf( + path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string + ): RemoteWorkspaceIdentity { + const id = workspaceId?.trim() || ''; + const connectionId = remoteConnectionId?.trim() || ''; + const sshHost = remoteSshHost?.trim() || ''; + return { + workspaceId: id.length > 0 ? id : undefined, + path: DeviceDirectoryState.normalizeWorkspacePath(path), + remoteConnectionId: connectionId.length > 0 ? connectionId : undefined, + remoteSshHost: sshHost.length > 0 ? sshHost : undefined + }; + } + private static normalizeWorkspacePath(path: string): string { const normalized = path.trim().replace(/\/+$/, ''); return normalized.length > 0 ? normalized : path.trim(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index a2951e7363..b66b7cda5c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -8,6 +8,7 @@ import { RecentWorkspaceEntry, RemoteModelCatalog, RemoteSession, + RemoteWorkspaceIdentity, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; @@ -43,6 +44,8 @@ export class RemotePageState { @Trace showRemoteUrlInput: boolean = false; @Trace workspaceName: string = ''; @Trace workspacePath: string = ''; + /** Stable record ID of the active workspace; empty for pre-ID hosts. */ + @Trace workspaceId: string = ''; @Trace workspaceConnectionId: string = ''; @Trace workspaceSshHost: string = ''; @Trace workspaceBranch: string = ''; @@ -197,17 +200,32 @@ export class RemotePageState { workspaceBranch: string = '', workspaceKind: string = 'normal', remoteConnectionId: string = '', - remoteSshHost: string = '' + remoteSshHost: string = '', + workspaceId: string = '' ): void { this.workspaceName = workspaceName; this.workspacePath = workspacePath; this.workspaceConnectionId = remoteConnectionId; this.workspaceSshHost = remoteSshHost; + this.workspaceId = workspaceId; this.assistantId = assistantId; this.workspaceBranch = workspaceBranch; this.workspaceKind = workspaceKind; } + /** + * The active workspace as a reference. Empty strings from the page state + * become absent fields so the identity compares like catalog rows do. + */ + workspaceIdentity(): RemoteWorkspaceIdentity { + return { + workspaceId: this.workspaceId.length > 0 ? this.workspaceId : undefined, + path: this.workspacePath, + remoteConnectionId: this.workspaceConnectionId.length > 0 ? this.workspaceConnectionId : undefined, + remoteSshHost: this.workspaceSshHost.length > 0 ? this.workspaceSshHost : undefined + }; + } + setRecentWorkspaces(recentWorkspaces: RecentWorkspaceEntry[]): void { this.recentWorkspaces = recentWorkspaces.slice(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index a00854db2b..71f8762672 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -167,9 +167,10 @@ export class ConversationController { inPlace: boolean = false, deviceId: string = '', remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + workspaceId?: string ): Promise { - await this.createFlow.createRemoteSessionInWorkspace(path, agentType, inPlace, deviceId, remoteConnectionId, remoteSshHost); + await this.createFlow.createRemoteSessionInWorkspace(path, agentType, inPlace, deviceId, remoteConnectionId, remoteSshHost, workspaceId); } async ensureRemoteControlTarget(deviceId?: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets index 8e99cd755e..fbbb5bb7cd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets @@ -26,7 +26,7 @@ export interface RemoteConversationHooks { readonly isFilePreviewVisible: () => boolean; readonly stopVoiceInput: () => Promise; readonly showToast: (message: string) => boolean; - readonly selectAssistantWorkspace: (path: string) => Promise; + readonly selectAssistantWorkspace: (path: string, workspaceId?: string) => Promise; } export interface RemoteConversationDependencies { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets index 6d832b9963..826928478a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/DeviceDirectoryViewModel.ets @@ -1,4 +1,4 @@ -import { RecentWorkspaceEntry, RemoteSession, SessionListResult } from '../../model/RemoteModels'; +import { RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity, SessionListResult } from '../../model/RemoteModels'; import { AccountDeviceDirectory, AccountDeviceDirectorySource @@ -23,8 +23,8 @@ export interface DeviceDirectoryViewModelHooks { readonly activeDeviceId: () => string; /** Whether the conversation link to [activeDeviceId] is currently live. */ readonly activeDeviceConnected: () => boolean; - readonly loadActiveWorkspaceSessions?: (path: string, remoteConnectionId?: string, remoteSshHost?: string) => Promise; - readonly onActiveWorkspaceSessions?: (path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string) => void; + readonly loadActiveWorkspaceSessions?: (path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string) => Promise; + readonly onActiveWorkspaceSessions?: (path: string, result: SessionListResult, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string) => void; } interface DeviceCatalogRequest { @@ -327,14 +327,14 @@ export class DeviceDirectoryViewModel { * every workspace here was both expensive and the source of unrelated row * updates while the user was only opening one branch. */ - async loadWorkspaceSessions(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise { + async loadWorkspaceSessions(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): Promise { const entry = this.state.find(deviceId); const normalizedPath = path.trim(); const active = deviceId === this.hooks.activeDeviceId() && this.hooks.activeDeviceConnected(); if (normalizedPath.length === 0 || (!active && (!entry || !entry.online))) { return; } - const scope = this.state.workspaceLoadState(deviceId, normalizedPath, remoteConnectionId, remoteSshHost); + const scope = this.state.workspaceLoadState(deviceId, normalizedPath, remoteConnectionId, remoteSshHost, workspaceId); const pending = this.workspaceInFlight.get(scope); if (pending) { return pending; @@ -358,7 +358,7 @@ export class DeviceDirectoryViewModel { if (!this.state.isCurrentWorkspaceLoad(scope) || !scope.expanded || this.state.selectedDeviceId !== selected) return; try { - await this.loadWorkspaceSessions(scope.deviceId, scope.path, scope.remoteConnectionId, scope.remoteSshHost); + await this.loadWorkspaceSessions(scope.deviceId, scope.path, scope.remoteConnectionId, scope.remoteSshHost, scope.workspaceId); } catch (_err) { // Each failed branch exposes its own retry row and retains its cache. } @@ -379,18 +379,26 @@ export class DeviceDirectoryViewModel { (!active || (scope.deviceId === this.hooks.activeDeviceId() && this.hooks.activeDeviceConnected())); }; scope.status = 'loading'; + // The disclosed row keyed by its ID when the catalog supplied one; the + // legacy triple is only what pre-ID rows have. + const workspace: RemoteWorkspaceIdentity = { + workspaceId: scope.workspaceId.length > 0 ? scope.workspaceId : undefined, + path: path, + remoteConnectionId: scope.remoteConnectionId.length > 0 ? scope.remoteConnectionId : undefined, + remoteSshHost: scope.remoteSshHost.length > 0 ? scope.remoteSshHost : undefined + }; try { if (active && this.hooks.loadActiveWorkspaceSessions && this.hooks.onActiveWorkspaceSessions) { - const result = await this.hooks.loadActiveWorkspaceSessions(path, scope.remoteConnectionId, scope.remoteSshHost); + const result = await this.hooks.loadActiveWorkspaceSessions(path, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId); if (!isCurrent()) return; - this.captureWorkspaceSessions(scope.deviceId, path, result.sessions, scope.remoteConnectionId, scope.remoteSshHost); - this.hooks.onActiveWorkspaceSessions(path, result, scope.remoteConnectionId, scope.remoteSshHost); + this.captureWorkspaceSessions(scope.deviceId, path, result.sessions, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId); + this.hooks.onActiveWorkspaceSessions(path, result, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId); } else { const source = this.directorySource(); if (!source || !entry) throw new Error('Account device directory is unavailable.'); - const sessions = await new DeviceDirectoryCoordinator(source).loadWorkspaceSessions(scope.deviceId, path, scope.remoteConnectionId, scope.remoteSshHost); + const sessions = await new DeviceDirectoryCoordinator(source).loadWorkspaceSessions(scope.deviceId, workspace); if (!isCurrent()) return; - entry.sessions = DeviceDirectoryViewModel.replaceWorkspaceSessions(entry.sessions, path, sessions, scope.remoteConnectionId, scope.remoteSshHost, entry.workspaces); + entry.sessions = DeviceDirectoryViewModel.replaceWorkspaceSessions(entry.sessions, workspace, sessions, entry.workspaces); await this.persistDevice(scope.deviceId, entry.workspaces, entry.sessions); } if (isCurrent()) { @@ -410,16 +418,23 @@ export class DeviceDirectoryViewModel { } /** Replaces one refreshed workspace while preserving every cached sibling. */ - captureWorkspaceSessions(deviceId: string, path: string, sessions: RemoteSession[], remoteConnectionId?: string, remoteSshHost?: string): void { + captureWorkspaceSessions(deviceId: string, path: string, sessions: RemoteSession[], remoteConnectionId?: string, remoteSshHost?: string, workspaceId?: string): void { const entry = this.state.find(deviceId.trim()); const normalizedPath = path.trim(); if (!entry || normalizedPath.length === 0) { return; } + const workspace: RemoteWorkspaceIdentity = { + workspaceId: workspaceId?.trim() || undefined, + path: normalizedPath, + remoteConnectionId, + remoteSshHost + }; entry.sessions = DeviceDirectoryViewModel.replaceWorkspaceSessions( entry.sessions, - normalizedPath, - stampRemoteSessionsDevice(stampRemoteSessionsWorkspace(sessions, normalizedPath, remoteConnectionId, remoteSshHost), deviceId.trim()), remoteConnectionId, remoteSshHost, entry.workspaces + workspace, + stampRemoteSessionsDevice(stampRemoteSessionsWorkspace(sessions, workspace), deviceId.trim()), + entry.workspaces ); } @@ -585,11 +600,12 @@ export class DeviceDirectoryViewModel { private static replaceWorkspaceSessions( current: RemoteSession[], - path: string, - replacement: RemoteSession[], remoteConnectionId?: string, remoteSshHost?: string, catalog: RecentWorkspaceEntry[] = [] + workspace: RemoteWorkspaceIdentity, + replacement: RemoteSession[], + catalog: RecentWorkspaceEntry[] = [] ): RemoteSession[] { const preserved = current.filter((item: RemoteSession): boolean => { - return !remoteSessionBelongsToWorkspace(item, { path, remoteConnectionId, remoteSshHost }, catalog); + return !remoteSessionBelongsToWorkspace(item, workspace, catalog); }); return replacement.concat(preserved); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index 75cf9f52b2..d4b146bb4a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -228,7 +228,8 @@ export class RemoteConnectionController { applyWorkspace(workspace: WorkspaceInfo): void { this.pageState.setWorkspace(workspace.name, workspace.path, workspace.assistantId || '', workspace.gitBranch, - workspace.workspaceKind || 'normal', workspace.remoteConnectionId || '', workspace.remoteSshHost || ''); + workspace.workspaceKind || 'normal', workspace.remoteConnectionId || '', workspace.remoteSshHost || '', + workspace.workspaceId || ''); } private applyRemoteUrl(remoteUrl: string): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets index 3cc905512e..74c7005c85 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteCreateFlowController.ets @@ -79,7 +79,8 @@ export class RemoteCreateFlowController { inPlace: boolean = false, deviceId: string = '', remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + workspaceId?: string ): Promise { const runtime = requireRemoteRuntime(this.remoteRuntime); runtime.filePreview.close(); @@ -91,7 +92,8 @@ export class RemoteCreateFlowController { inPlace ? (sessionId: string): void => this.navigateInPlace(sessionId) : (sessionId: string): void => this.navigateCreated(sessionId), '', true, remoteConnectionId, - remoteSshHost + remoteSshHost, + workspaceId ); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index 8390bac1cc..b7795e3af8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -25,7 +25,7 @@ export interface RemoteSessionViewModelHooks { readonly onLoadActiveMessages: () => Promise; readonly onCreatedSession: (session: SessionSummary) => Promise; readonly onRefreshSessions: () => Promise; - readonly onSelectWorkspace: (path: string) => Promise; + readonly onSelectWorkspace: (path: string, workspaceId?: string) => Promise; } /** Owns remote session commands and their page lifecycle effects. */ @@ -129,7 +129,8 @@ export class RemoteSessionViewModel { modelId: string, workspacePath: string = '', remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + workspaceId?: string ): Promise { const created = await this.sessions.create( agentType, @@ -156,7 +157,8 @@ export class RemoteSessionViewModel { modelId, workspacePath, remoteConnectionId, - remoteSshHost + remoteSshHost, + workspaceId ); if (created && (created.initialTurnId || '').length > 0) { // The first poll may have observed the newly allocated session before @@ -176,7 +178,8 @@ export class RemoteSessionViewModel { modelId: string = '', routeBeforeCreate: boolean = true, remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + workspaceId?: string ): Promise { if (this.hooks.isBusy() || !this.hooks.remoteAvailable()) { return; @@ -185,7 +188,7 @@ export class RemoteSessionViewModel { this.beginSessionCreation(onRouteChat, routeBeforeCreate); let created: SessionSummary | undefined; try { - created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId, path, remoteConnectionId, remoteSshHost); + created = await this.performCreateSession(agentType, instruction, onRouteChat, modelId, path, remoteConnectionId, remoteSshHost, workspaceId); if (!created && routeBeforeCreate) { this.restoreAfterFailedCreation(previousSession, onRouteChat); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 9299d6dc07..b45ed0b083 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -56,12 +56,12 @@ export class RemoteWorkspaceViewModel { await this.loadAssistants(); } - async selectWorkspace(path: string, connectionId?: string, sshHost?: string): Promise { - return await this.select(path, false, connectionId, sshHost); + async selectWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { + return await this.select(path, false, connectionId, sshHost, workspaceId); } - async selectAssistant(path: string): Promise { - return await this.select(path, true); + async selectAssistant(path: string, workspaceId?: string): Promise { + return await this.select(path, true, undefined, undefined, workspaceId); } async loadRecentWorkspacesInBackground(): Promise { @@ -164,8 +164,10 @@ export class RemoteWorkspaceViewModel { } } - private async select(path: string, assistant: boolean, connectionId?: string, sshHost?: string): Promise { - if (this.hooks.isBusy() || path.trim().length === 0 || !this.hooks.isRemoteAvailable()) { + private async select(path: string, assistant: boolean, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { + // A reference is complete with either its ID or, for pre-ID rows, its path. + const id = workspaceId?.trim() || undefined; + if (this.hooks.isBusy() || (path.trim().length === 0 && id === undefined) || !this.hooks.isRemoteAvailable()) { return false; } try { @@ -173,7 +175,7 @@ export class RemoteWorkspaceViewModel { this.hooks.onStatus(RemoteI18n.t(assistant ? 'status.switchingAssistant' : 'status.switchingWorkspace')); const workspace = assistant ? - await this.coordinator.switchAssistant(path) : await this.coordinator.switchWorkspace(path, connectionId, sshHost); + await this.coordinator.switchAssistant(path, id) : await this.coordinator.switchWorkspace(path, connectionId, sshHost, id); this.hooks.onWorkspaceSelected(workspace); this.pageState.closePickers(); this.pageState.clear(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index 74a7cef15b..4517517d4a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -550,7 +550,8 @@ export class SettingsController { initialSync.workspace.gitBranch, initialSync.workspace.workspaceKind || 'normal', initialSync.workspace.remoteConnectionId || '', - initialSync.workspace.remoteSshHost || '' + initialSync.workspace.remoteSshHost || '', + initialSync.workspace.workspaceId || '' ); cloud.remoteState.setSessions( stampRemoteSessionsDevice(initialSync.sessions, deviceId), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceDirectory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceDirectory.ets index e52e660335..7adb8110c4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceDirectory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceDirectory.ets @@ -4,10 +4,14 @@ import { AssistantListResponse, RecentWorkspaceEntry, RecentWorkspaceListResponse, + REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, RemoteSession, - SessionListResponse + RemoteWorkspaceIdentity, + SessionListResponse, + WorkspaceInfoResponse } from '../model/RemoteModels'; import { CloudAccountBinding } from './CloudAccountClient'; +import { RemoteI18n } from '../i18n/RemoteI18n'; import { AccountDeviceCommandTransport, INTERACTIVE_DIRECTORY_TIMEOUT_MS @@ -20,12 +24,20 @@ export interface AccountDeviceDirectorySource { listWorkspaceCatalog(deviceId: string): Promise; listRecentWorkspaces(deviceId: string): Promise; listAssistants(deviceId: string): Promise; - listSessions(deviceId: string, workspacePath: string, remoteConnectionId?: string, remoteSshHost?: string): Promise; + listSessions(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise; } /** Read-only per-device RPCs that never touch the active conversation transport. */ export class AccountDeviceDirectory implements AccountDeviceDirectorySource { private readonly context: CloudAccountBinding; + /** + * Capabilities each directory device advertised, probed once per device. + * + * Directory listings do not go through the handshake of the active + * connection, so a device's capability set is learned here before the first + * command that would carry a `workspace_id` to it. + */ + private readonly deviceCapabilities: Map = new Map(); constructor(context: CloudAccountBinding) { this.context = context; @@ -50,22 +62,43 @@ export class AccountDeviceDirectory implements AccountDeviceDirectorySource { return response.assistants || []; } - async listSessions(deviceId: string, workspacePath: string, remoteConnectionId?: string, remoteSshHost?: string): Promise { - const path = workspacePath.trim(); - if (path.length === 0) { + async listSessions(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise { + const scope: RemoteWorkspaceIdentity = { + workspaceId: workspace.workspaceId || undefined, + path: workspace.path.trim(), + remoteConnectionId: workspace.remoteConnectionId, + remoteSshHost: workspace.remoteSshHost + }; + if (!scope.workspaceId && scope.path.length === 0) { return []; } + if (scope.workspaceId) { + await this.requireWorkspaceIdReferences(deviceId); + } const response = await this.transport(deviceId).send( - RemoteCommandFactory.listSessions(path, 50, 0, '', remoteConnectionId, remoteSshHost), + RemoteCommandFactory.listSessions(scope, 50, 0, ''), INTERACTIVE_DIRECTORY_TIMEOUT_MS ); - const sessions = stampRemoteSessionsWorkspace( - RemoteResponseMapper.sessions(response.sessions || []), - path, remoteConnectionId, remoteSshHost - ); + const sessions = stampRemoteSessionsWorkspace(RemoteResponseMapper.sessions(response.sessions || []), scope); return stampRemoteSessionsDevice(sessions, deviceId); } + /** Rejects an ID-carrying listing to a device that never advertised ID references. */ + private async requireWorkspaceIdReferences(deviceId: string): Promise { + let capabilities = this.deviceCapabilities.get(deviceId); + if (capabilities === undefined) { + const response = await this.transport(deviceId).send( + RemoteCommandFactory.getWorkspaceInfo(), + INTERACTIVE_DIRECTORY_TIMEOUT_MS + ); + capabilities = (response.capabilities || []).slice(); + this.deviceCapabilities.set(deviceId, capabilities); + } + if (capabilities.indexOf(REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1) < 0) { + throw new Error(RemoteI18n.t('errors.workspaceIdUnsupported')); + } + } + private transport(deviceId: string): AccountDeviceCommandTransport { return new AccountDeviceCommandTransport( this.context.client, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceDirectoryCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceDirectoryCoordinator.ets index 52b3eb8ea6..9fd2eb8efd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceDirectoryCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceDirectoryCoordinator.ets @@ -1,5 +1,5 @@ import { WorkspaceCatalog, mergeLegacyWorkspaces } from './WorkspaceCatalog'; -import { AssistantEntry, RecentWorkspaceEntry, RemoteSession } from '../model/RemoteModels'; +import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, RemoteWorkspaceIdentity } from '../model/RemoteModels'; import { AccountDeviceDirectorySource } from './AccountDeviceDirectory'; import { CONCURRENT_WORKSPACE_LISTINGS, @@ -55,7 +55,7 @@ export class DeviceDirectoryCoordinator { const groups = await mapInConcurrentBatches( workspaces, (entry: RecentWorkspaceEntry): Promise => { - return this.safeSessionsForWorkspace(deviceId, entry.path, entry.remoteConnectionId, entry.remoteSshHost); + return this.safeSessionsForWorkspace(deviceId, entry); }, this.concurrency ); @@ -63,16 +63,16 @@ export class DeviceDirectoryCoordinator { } /** One explicit workspace listing; used by the lazy sidebar disclosure. */ - async loadWorkspaceSessions(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise { - return stampRemoteSessionsWorkspace(await this.directory.listSessions(deviceId, path, remoteConnectionId, remoteSshHost), path, remoteConnectionId, remoteSshHost); + async loadWorkspaceSessions(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise { + return stampRemoteSessionsWorkspace(await this.directory.listSessions(deviceId, workspace), workspace); } - private async safeSessionsForWorkspace(deviceId: string, path: string, remoteConnectionId?: string, remoteSshHost?: string): Promise { + private async safeSessionsForWorkspace(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise { try { - return await this.loadWorkspaceSessions(deviceId, path, remoteConnectionId, remoteSshHost); + return await this.loadWorkspaceSessions(deviceId, workspace); } catch (err) { // One unreadable workspace is not a reason to lose the other twenty. - RemoteLogger.warn(`load workspace sessions failed device=${deviceId} path=${path}: ${String(err)}`); + RemoteLogger.warn(`load workspace sessions failed device=${deviceId} workspace=${workspace.workspaceId || ''} path=${workspace.path}: ${String(err)}`); return []; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/LegacyWorkspaceCompatibility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/LegacyWorkspaceCompatibility.ets new file mode 100644 index 0000000000..d38f9f01e4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/LegacyWorkspaceCompatibility.ets @@ -0,0 +1,55 @@ +import { RemoteWorkspaceIdentity } from '../model/RemoteModels'; + +/** + * Upgrade-only conversion for pre-ID caches and host projections. + * + * Paths are not workspace keys. New state and commands retain `workspaceId`; + * this is the single place where a reference without one may be attributed to + * a catalog record. Mirrors the Kotlin `LegacyWorkspaceCompatibility.resolve` + * and the Rust `resolve_legacy_workspace_reference`: + * - an explicit ID is authoritative and never falls back to a path; + * - a unique path match adopts the record; + * - an ambiguous path stays unresolved; + * - `localhost` without a saved connection is a 1.0.0 local marker, not a host. + * + * Remove when all supported caches have migrated and every peer negotiates + * `workspace_id_references_v1`. + */ +export class LegacyWorkspaceCompatibility { + static resolve( + reference: RemoteWorkspaceIdentity, + catalog: RemoteWorkspaceIdentity[] + ): RemoteWorkspaceIdentity | undefined { + const workspaceId = (reference.workspaceId || '').trim(); + if (workspaceId.length > 0) { + // Two catalog rows sharing an ID is a corrupt catalog; refusing is the only safe answer. + const byId = catalog.filter((candidate: RemoteWorkspaceIdentity): boolean => + candidate.workspaceId === workspaceId); + return byId.length === 1 ? byId[0] : undefined; + } + const root = LegacyWorkspaceCompatibility.normalizeRoot(reference.path); + const connectionId = (reference.remoteConnectionId || '').trim(); + const sshHost = (reference.remoteSshHost || '').trim(); + const localMarker = connectionId.length === 0 && sshHost === 'localhost'; + const matches = catalog.filter((candidate: RemoteWorkspaceIdentity): boolean => { + if (LegacyWorkspaceCompatibility.normalizeRoot(candidate.path) !== root) { + return false; + } + if (connectionId.length > 0 && (candidate.remoteConnectionId || '').trim() !== connectionId) { + return false; + } + if (sshHost.length > 0 && !localMarker && (candidate.remoteSshHost || '').trim() !== sshHost) { + return false; + } + return true; + }); + return matches.length === 1 ? matches[0] : undefined; + } + + /** Trailing separators are not identity; a bare root keeps its slash. */ + private static normalizeRoot(path: string): string { + const trimmed = path.trim(); + const normalized = trimmed.replace(/\/+$/, ''); + return normalized.length > 0 ? normalized : trimmed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 1c4a8f2eb5..bfe4898c3e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -1,5 +1,20 @@ -import { CreateSessionOptions, RemoteCommand, RemoteImageContext, RemotePermissionMode, RemoteQuestionAnswerPayload } from '../model/RemoteModels'; +import { + CreateSessionOptions, + RemoteCommand, + RemoteImageContext, + RemotePermissionMode, + RemoteQuestionAnswerPayload, + RemoteWorkspaceIdentity +} from '../model/RemoteModels'; +/** + * Builds the RemoteCommand wire shapes. + * + * Workspace references follow one rule: when the caller knows the + * `workspaceId`, the command carries only `workspace_id`, so an ID-aware host + * can never silently fall back to a path. The legacy `(path, connection, + * ssh host)` projection is sent only for references that never had an ID. + */ export class RemoteCommandFactory { static getWorkspaceInfo(): RemoteCommand { return { cmd: 'get_workspace_info' }; @@ -9,37 +24,59 @@ export class RemoteCommandFactory { return { cmd: 'list_recent_workspaces' }; } - static setWorkspace(path: string): RemoteCommand { - return { + static setWorkspace(path: string, workspaceId?: string, remoteConnectionId?: string, remoteSshHost?: string): RemoteCommand { + if (workspaceId) { + return { cmd: 'set_workspace', workspace_id: workspaceId }; + } + // Upgrade-only command for pre-ID references: the host resolves the path + // through its legacy resolver and rejects unknown or ambiguous roots. + const command: RemoteCommand = { cmd: 'set_workspace', path }; + if (remoteConnectionId !== undefined) { + command.remote_connection_id = remoteConnectionId; + } + if (remoteSshHost !== undefined) { + command.remote_ssh_host = remoteSshHost; + } + return command; } static listAssistants(): RemoteCommand { return { cmd: 'list_assistants' }; } - static setAssistant(path: string): RemoteCommand { + static setAssistant(path: string, workspaceId?: string): RemoteCommand { + if (workspaceId) { + return { cmd: 'set_assistant', workspace_id: workspaceId }; + } return { cmd: 'set_assistant', path }; } - static listSessions(workspacePath: string, limit: number, offset: number, query: string, - remoteConnectionId?: string, remoteSshHost?: string): RemoteCommand { - const command: RemoteCommand = { - cmd: 'list_sessions', - workspace_path: workspacePath, - limit, - offset - }; + static listSessions(workspace: RemoteWorkspaceIdentity, limit: number, offset: number, query: string): RemoteCommand { + const command: RemoteCommand = { cmd: 'list_sessions' }; + if (workspace.workspaceId) { + command.workspace_id = workspace.workspaceId; + } else { + command.workspace_path = workspace.path; + } + command.limit = limit; + command.offset = offset; if (query.trim().length > 0) { command.query = query.trim(); } - if (remoteConnectionId !== undefined) command.remote_connection_id = remoteConnectionId; - if (remoteSshHost !== undefined) command.remote_ssh_host = remoteSshHost; + if (!workspace.workspaceId) { + if (workspace.remoteConnectionId !== undefined) { + command.remote_connection_id = workspace.remoteConnectionId; + } + if (workspace.remoteSshHost !== undefined) { + command.remote_ssh_host = workspace.remoteSshHost; + } + } return command; } @@ -47,14 +84,21 @@ export class RemoteCommandFactory { const normalizedAgentType = (options.agentType || '').toLowerCase(); const defaultTitle = normalizedAgentType === 'claw' || normalizedAgentType === 'assistant' || normalizedAgentType === 'chat' ? 'Remote Assistant Session' : normalizedAgentType === 'cowork' ? 'Remote Cowork Session' : 'Remote Code Session'; - return { + const command: RemoteCommand = { cmd: 'create_session', agent_type: options.agentType, - session_name: options.title.trim() || defaultTitle, - workspace_path: workspacePath || undefined, - remote_connection_id: workspacePath ? options.remoteConnectionId || '' : undefined, - remote_ssh_host: workspacePath ? options.remoteSshHost : undefined + session_name: options.title.trim() || defaultTitle }; + if (options.workspaceId) { + command.workspace_id = options.workspaceId; + return command; + } + if (workspacePath) { + command.workspace_path = workspacePath; + command.remote_connection_id = options.remoteConnectionId || ''; + command.remote_ssh_host = options.remoteSshHost; + } + return command; } static sendMessage( diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets index 89bf10ffa5..ac7417a1f1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets @@ -18,12 +18,15 @@ export class RemoteResponseMapper { const workspacePath = response.path || ''; const workspaceName = response.project_name || RemoteResponseMapper.basename(workspacePath); return { + workspaceId: response.workspace_id, path: workspacePath, name: workspaceName || 'Workspace', hasWorkspace: response.has_workspace !== false && workspacePath.length > 0, gitBranch: response.git_branch || '', workspaceKind: response.workspace_kind || 'normal', - assistantId: response.assistant_id + assistantId: response.assistant_id, + remoteConnectionId: (response.workspace_kind === 'normal' || response.workspace_kind === 'assistant') ? undefined : response.remote_connection_id, + remoteSshHost: (response.workspace_kind === 'normal' || response.workspace_kind === 'assistant') ? undefined : response.remote_ssh_host }; } @@ -31,20 +34,22 @@ export class RemoteResponseMapper { const workspacePath = response.path || response.workspace_path || ''; const workspaceName = response.project_name || response.workspace_name || RemoteResponseMapper.basename(workspacePath); return { + workspaceId: response.workspace_id, path: workspacePath, name: workspaceName || 'Workspace', hasWorkspace: response.has_workspace !== false && workspacePath.length > 0, gitBranch: response.git_branch || '', workspaceKind: response.workspace_kind || 'normal', assistantId: response.assistant_id, - remoteConnectionId: response.remote_connection_id, - remoteSshHost: response.remote_ssh_host + remoteConnectionId: (response.workspace_kind === 'normal' || response.workspace_kind === 'assistant') ? undefined : response.remote_connection_id, + remoteSshHost: (response.workspace_kind === 'normal' || response.workspace_kind === 'assistant') ? undefined : response.remote_ssh_host }; } static recentWorkspaces(items: RecentWorkspaceEntryResponse[]): RecentWorkspaceEntry[] { return items.map((item: RecentWorkspaceEntryResponse) => { const workspace: RecentWorkspaceEntry = { + workspaceId: item.workspace_id, path: item.path || '', name: item.name || RemoteResponseMapper.basename(item.path || '') || 'Workspace', lastOpened: RemoteResponseMapper.firstTimeValue([ @@ -53,8 +58,8 @@ export class RemoteResponseMapper { item.updated_at, item.updatedAt ]), - remoteSshHost: item.remote_ssh_host, - remoteConnectionId: item.remote_connection_id, + remoteSshHost: (item.workspace_kind === 'normal' || item.workspace_kind === 'assistant') ? undefined : item.remote_ssh_host, + remoteConnectionId: (item.workspace_kind === 'normal' || item.workspace_kind === 'assistant') ? undefined : item.remote_connection_id, workspaceKind: item.workspace_kind || 'normal' }; return workspace; @@ -106,6 +111,12 @@ export class RemoteResponseMapper { parentSessionId: item.parent_session_id, relationshipKind: item.relationship_kind }; + // An ID-aware host pins every session to its workspace record. Keep that + // ID as the identity; the listing scope may later fill in display facts. + const workspaceId = (item.workspace_id || '').trim(); + if (workspaceId.length > 0) { + session.workspaceIdentity = { workspaceId: workspaceId, path: item.workspace_path || '' }; + } return session; }); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets index 98a1492bd0..23cbd5b3a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionController.ets @@ -173,7 +173,8 @@ export class RemoteSessionController { modelId: string = '', workspacePath: string = '', remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + workspaceId?: string ): Promise { if (isBusy || !remoteAvailable) { return undefined; @@ -189,13 +190,14 @@ export class RemoteSessionController { await onCreated(session); }; const session = await this.client.createSession({ + workspaceId: workspaceId?.trim() || undefined, agentType, title: '', instruction, modelId, workspacePath, remoteConnectionId, - remoteSshHost + remoteSshHost }, handleAllocated); if (!allocationHandled) { await handleAllocated(session); @@ -236,6 +238,7 @@ export class RemoteSessionController { const session: SessionSummary = { sessionId: item.id, title: item.title, + workspaceId: item.workspaceIdentity?.workspaceId, workspacePath: item.workspaceIdentity?.path || workspacePath, remoteConnectionId: item.workspaceIdentity?.remoteConnectionId, remoteSshHost: item.workspaceIdentity?.remoteSshHost, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionIdentity.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionIdentity.ets index 030e62fb92..6e31c7ac62 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionIdentity.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionIdentity.ets @@ -1,22 +1,61 @@ import { RemoteSession, RemoteWorkspaceIdentity } from '../model/RemoteModels'; +import { LegacyWorkspaceCompatibility } from './LegacyWorkspaceCompatibility'; +/** + * Stable grouping key for a workspace reference. + * + * A reference that carries `workspaceId` is keyed by that ID alone; path and + * SSH facts are display strings that must not split or merge groups. Only + * references without an ID (pre-ID hosts, old caches) use the legacy triple. + */ export function remoteWorkspaceKey(scope: RemoteWorkspaceIdentity): string { + const workspaceId = scope.workspaceId; + if (workspaceId !== undefined && workspaceId.length > 0) { + return `workspace:${workspaceId.length}:${workspaceId}`; + } + return legacyRemoteWorkspaceKey(scope); +} + +/** Upgrade-only key for caches and peers predating workspace IDs. */ +export function legacyRemoteWorkspaceKey(scope: RemoteWorkspaceIdentity): string { const trimmedPath = scope.path.trim(); const path = trimmedPath.length > 0 ? trimmedPath.replace(/\/+$/, '') || '/' : ''; return [scope.remoteConnectionId?.trim() || '', scope.remoteSshHost?.trim() || '', path] .map((part: string): string => `${part.length}:${part}`).join(''); } +/** + * Whether two references name the same workspace. + * + * With IDs on both sides only the IDs are compared. A side without an ID is + * first attributed through the legacy compatibility resolver against the + * catalog; an unresolvable or ambiguous legacy side never matches an + * identified one. + */ +export function remoteWorkspaceIdentityMatches( + left: RemoteWorkspaceIdentity, + right: RemoteWorkspaceIdentity, + catalog: RemoteWorkspaceIdentity[] +): boolean { + if (left.workspaceId && right.workspaceId) { + return left.workspaceId === right.workspaceId; + } + const resolvedLeft = LegacyWorkspaceCompatibility.resolve(left, catalog) || left; + const resolvedRight = LegacyWorkspaceCompatibility.resolve(right, catalog) || right; + return remoteWorkspaceKey(resolvedLeft) === remoteWorkspaceKey(resolvedRight); +} + /** Unknown legacy sessions are eligible only for an unambiguous local catalog row. */ export function remoteSessionBelongsToWorkspace( session: RemoteSession, scope: RemoteWorkspaceIdentity, catalog: RemoteWorkspaceIdentity[] ): boolean { - if (session.workspaceIdentity) return remoteWorkspaceKey(session.workspaceIdentity) === remoteWorkspaceKey(scope); + if (session.workspaceIdentity) { + return remoteWorkspaceIdentityMatches(session.workspaceIdentity, scope, catalog); + } if (scope.remoteConnectionId?.trim() || scope.remoteSshHost?.trim() || !session.workspacePath?.trim()) return false; const local: RemoteWorkspaceIdentity = { path: session.workspacePath }; - if (remoteWorkspaceKey(local) !== remoteWorkspaceKey(scope)) return false; - return catalog.filter((item: RemoteWorkspaceIdentity): boolean => - remoteWorkspaceKey({ path: item.path }) === remoteWorkspaceKey(local)).length === 1; + const resolved = LegacyWorkspaceCompatibility.resolve(local, catalog); + return resolved !== undefined && remoteWorkspaceKey(resolved) === remoteWorkspaceKey(scope); } /** Stable key for a session that may exist on more than one desktop. */ @@ -52,12 +91,47 @@ export function stampRemoteSessionsDevice(sessions: RemoteSession[], deviceId: s return sessions.map((item: RemoteSession): RemoteSession => stampRemoteSessionDevice(item, deviceId)); } -/** Fills the scope omitted by older list_sessions payloads without mutating it. */ -export function stampRemoteSessionWorkspace(session: RemoteSession, workspacePath: string, - remoteConnectionId?: string, remoteSshHost?: string): RemoteSession { - if (workspacePath.length === 0 || session.workspaceIdentity) { +/** + * Fills the scope omitted by older list_sessions payloads without mutating it. + * + * A session the host already pinned to a workspace ID keeps that ID; the + * listing scope may only complete its display path and SSH projection, and + * only when the scope names the same workspace or has no ID of its own. A + * session already scoped by a legacy triple is left untouched. + */ +export function stampRemoteSessionWorkspace(session: RemoteSession, scope: RemoteWorkspaceIdentity): RemoteSession { + const scopeId = scope.workspaceId || ''; + if (scope.path.length === 0 && scopeId.length === 0) { return session; } + const existing = session.workspaceIdentity; + let identity: RemoteWorkspaceIdentity; + if (existing === undefined) { + identity = { + workspaceId: scopeId.length > 0 ? scopeId : undefined, + path: session.workspacePath || scope.path, + remoteConnectionId: scope.remoteConnectionId, + remoteSshHost: scope.remoteSshHost + }; + } else { + const existingId = existing.workspaceId || ''; + if (existingId.length === 0 || (scopeId.length > 0 && scopeId !== existingId)) { + return session; + } + const path = existing.path.length > 0 ? existing.path : (session.workspacePath || scope.path); + const remoteConnectionId = existing.remoteConnectionId !== undefined ? existing.remoteConnectionId : scope.remoteConnectionId; + const remoteSshHost = existing.remoteSshHost !== undefined ? existing.remoteSshHost : scope.remoteSshHost; + if (path === existing.path && remoteConnectionId === existing.remoteConnectionId && + remoteSshHost === existing.remoteSshHost && (session.workspacePath || '').length > 0) { + return session; + } + identity = { + workspaceId: existingId, + path: path, + remoteConnectionId: remoteConnectionId, + remoteSshHost: remoteSshHost + }; + } return { id: session.id, title: session.title, @@ -66,8 +140,8 @@ export function stampRemoteSessionWorkspace(session: RemoteSession, workspacePat updatedAt: session.updatedAt, createdAt: session.createdAt, messageCount: session.messageCount, - workspacePath: session.workspacePath || workspacePath, - workspaceIdentity: { path: session.workspacePath || workspacePath, remoteConnectionId, remoteSshHost }, + workspacePath: session.workspacePath || identity.path, + workspaceIdentity: identity, workspaceName: session.workspaceName, pinned: session.pinned, titleGenerated: session.titleGenerated, @@ -77,10 +151,7 @@ export function stampRemoteSessionWorkspace(session: RemoteSession, workspacePat export function stampRemoteSessionsWorkspace( sessions: RemoteSession[], - workspacePath: string, - remoteConnectionId?: string, - remoteSshHost?: string + scope: RemoteWorkspaceIdentity ): RemoteSession[] { - return sessions.map((item: RemoteSession): RemoteSession => - stampRemoteSessionWorkspace(item, workspacePath, remoteConnectionId, remoteSshHost)); + return sessions.map((item: RemoteSession): RemoteSession => stampRemoteSessionWorkspace(item, scope)); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index ed02271ec3..3caed3555b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -2,7 +2,7 @@ import { WorkspaceCatalog, projectWorkspaceCatalog } from './WorkspaceCatalog'; import { RuntimeFileLocation } from '../model/RemoteModels'; import { DurableSessionCallbacks, DurableSessionStream } from './DurableSessionStream'; import { SavedRuntimeConnection, SavedRuntimeConnectionsResponse } from '../model/RemoteModels'; -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; import { RemoteI18n } from '../i18n/RemoteI18n'; @@ -42,6 +42,8 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; + /** Capabilities the connected host advertised in its handshake response. */ + private hostCapabilities: string[] = []; subscribeSession(logicalId: string, callbacks: DurableSessionCallbacks): DurableSessionStream { const transport = this.transport; @@ -64,6 +66,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile this.transport?.reset(); this.transport = undefined; this.workspace = undefined; + this.hostCapabilities = []; return this.transportGeneration; } @@ -95,21 +98,25 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile } try { const workspace = RemoteResponseMapper.workspaceFromResponse(workspaceResponse); + const capabilities = workspaceResponse.capabilities || []; + const scope = RemoteSessionManager.workspaceScope(workspace); + RemoteSessionManager.requireWorkspaceIdReferences(scope, capabilities); const sessionResponse = await this.sendOnTransport( transport, generation, - RemoteCommandFactory.listSessions(workspace.path, 50, 0, '', workspace.remoteConnectionId, workspace.remoteSshHost) + RemoteCommandFactory.listSessions(scope, 50, 0, '') ); - const sessions = stampRemoteSessionsWorkspace(RemoteResponseMapper.sessions(sessionResponse.sessions || []), workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost); + const sessions = stampRemoteSessionsWorkspace(RemoteResponseMapper.sessions(sessionResponse.sessions || []), scope); this.requireCurrentGeneration(generation); this.transport = transport; this.workspace = workspace; + this.hostCapabilities = capabilities.slice(); return { workspace, sessions, hasMoreSessions: sessionResponse.has_more || false, authenticatedUserId: session.userId, - capabilities: workspaceResponse.capabilities || [] + capabilities }; } catch (err) { transport.reset(); @@ -139,10 +146,52 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.value || []; } - async setWorkspace(path: string, connectionId?: string, sshHost?: string): Promise { - const command = RemoteCommandFactory.setWorkspace(path); - command.remote_connection_id = connectionId; - command.remote_ssh_host = sshHost; + /** + * Whether the connected host resolves `workspace_id` in commands. + * + * Read from the `capabilities` array of the handshake response. Absent on + * pre-ID hosts, where only references that never had an ID may be sent. + */ + supportsWorkspaceIdReferences(): boolean { + return (this.hostCapabilities || []).indexOf(REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1) >= 0; + } + + /** + * Guards every command that would carry a `workspace_id`. + * + * A reference with an ID must never be downgraded to its display path: an + * ID-aware host could then open a different workspace under the same path, + * and a pre-ID host would silently do so. Surfacing the unsupported state is + * the only correct degradation. + */ + private static requireWorkspaceIdReferences(scope: RemoteWorkspaceIdentity, capabilities: string[]): void { + if (!scope.workspaceId) return; + if (capabilities.indexOf(REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1) >= 0) return; + throw new Error(RemoteI18n.t('errors.workspaceIdUnsupported')); + } + + private requireWorkspaceIdReference(scope: RemoteWorkspaceIdentity): void { + RemoteSessionManager.requireWorkspaceIdReferences(scope, this.hostCapabilities || []); + } + + private static workspaceScope(workspace: WorkspaceInfo | undefined): RemoteWorkspaceIdentity { + return { + workspaceId: workspace?.workspaceId || undefined, + path: workspace?.path || '', + remoteConnectionId: workspace?.remoteConnectionId, + remoteSshHost: workspace?.remoteSshHost + }; + } + + async setWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { + const scope: RemoteWorkspaceIdentity = { + workspaceId: workspaceId || undefined, + path, + remoteConnectionId: connectionId, + remoteSshHost: sshHost + }; + this.requireWorkspaceIdReference(scope); + const command = RemoteCommandFactory.setWorkspace(path, scope.workspaceId, connectionId, sshHost); const response = await this.send(command); if (response.success !== true) { throw new Error(response.error || 'Failed to set workspace.'); @@ -155,8 +204,10 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.assistants || []; } - async setAssistant(path: string): Promise { - const response = await this.send(RemoteCommandFactory.setAssistant(path)); + async setAssistant(path: string, workspaceId?: string): Promise { + const scope: RemoteWorkspaceIdentity = { workspaceId: workspaceId || undefined, path }; + this.requireWorkspaceIdReference(scope); + const response = await this.send(RemoteCommandFactory.setAssistant(path, scope.workspaceId)); if (response.success !== true) { throw new Error(response.error || 'Failed to set assistant workspace.'); } @@ -168,29 +219,34 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return result.sessions; } - async listSessionsForWorkspace(workspacePath: string, limit: number = 50, remoteConnectionId?: string, remoteSshHost?: string): Promise { - return (await this.listSessionPageForWorkspace(workspacePath, limit, remoteConnectionId, remoteSshHost)).sessions; + async listSessionsForWorkspace(workspace: RemoteWorkspaceIdentity, limit: number = 50): Promise { + return (await this.listSessionPageForWorkspace(workspace, limit)).sessions; } - async listSessionPageForWorkspace(workspacePath: string, limit: number = 50, remoteConnectionId?: string, remoteSshHost?: string): Promise { - const path = workspacePath.trim(); - if (path.length === 0) { + async listSessionPageForWorkspace(workspace: RemoteWorkspaceIdentity, limit: number = 50): Promise { + const scope: RemoteWorkspaceIdentity = { + workspaceId: workspace.workspaceId || undefined, + path: workspace.path.trim(), + remoteConnectionId: workspace.remoteConnectionId, + remoteSshHost: workspace.remoteSshHost + }; + if (!scope.workspaceId && scope.path.length === 0) { return { sessions: [], hasMore: false }; } + this.requireWorkspaceIdReference(scope); const response = await this.send( - RemoteCommandFactory.listSessions(path, limit, 0, '', remoteConnectionId, remoteSshHost), + RemoteCommandFactory.listSessions(scope, limit, 0, ''), INTERACTIVE_DIRECTORY_TIMEOUT_MS ); return { - sessions: stampRemoteSessionsWorkspace(RemoteResponseMapper.sessions(response.sessions || []), path, remoteConnectionId, remoteSshHost), + sessions: stampRemoteSessionsWorkspace(RemoteResponseMapper.sessions(response.sessions || []), scope), hasMore: response.has_more || false }; } async listSessions(limit: number, offset: number, query: string, agentType: string): Promise { - const workspacePath = this.workspace?.path || ''; - const connectionId = this.workspace?.remoteConnectionId; - const sshHost = this.workspace?.remoteSshHost; + const scope = RemoteSessionManager.workspaceScope(this.workspace); + this.requireWorkspaceIdReference(scope); const normalizedAgentType = RemoteSessionManager.normalizedSessionFilter(agentType); const trimmedQuery = query.trim(); const pageSize = normalizedAgentType ? Math.max(100, limit) : Math.max(1, limit); @@ -199,9 +255,9 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile let pageOffset = 0; let hasMore = true; while (hasMore && filteredSessions.length < targetCount) { - const command = RemoteCommandFactory.listSessions(workspacePath, pageSize, pageOffset, trimmedQuery, connectionId, sshHost); + const command = RemoteCommandFactory.listSessions(scope, pageSize, pageOffset, trimmedQuery); const response = await this.send(command); - const sessions = stampRemoteSessionsWorkspace(RemoteResponseMapper.allSessions(response.sessions || []), workspacePath, connectionId, sshHost); + const sessions = stampRemoteSessionsWorkspace(RemoteResponseMapper.allSessions(response.sessions || []), scope); for (const session of sessions) { if (RemoteResponseMapper.isVisibleSession(session) && (!normalizedAgentType || RemoteSessionManager.agentMatchesFilter(session.agentType, normalizedAgentType))) { @@ -225,11 +281,16 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile onAllocated?: (session: SessionSummary) => Promise ): Promise { const requestedPath = options.workspacePath?.trim() || ''; + const requestedWorkspaceId = options.workspaceId?.trim() || ''; const workspacePath = requestedPath; + this.requireWorkspaceIdReference({ workspaceId: requestedWorkspaceId || undefined, path: workspacePath }); const command = RemoteCommandFactory.createSession(options, workspacePath); const response = await this.send(command); const sessionId = response.session_id || response.id || ''; if (!sessionId) throw new Error('Session creation returned no session identity'); + // The host answers with the record it actually created the session in; its + // ID wins over whatever the caller asked for. + const workspaceId = (response.workspace_id || '').trim() || (requestedWorkspaceId || undefined); const resolvedPath = response.workspace_path ?? workspacePath; const remoteConnectionId = response.workspace_path !== undefined ? response.remote_connection_id : (requestedPath ? options.remoteConnectionId : undefined); @@ -243,6 +304,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile await onAllocated({ sessionId, title, + workspaceId, workspacePath: resolvedPath, remoteConnectionId, remoteSshHost, @@ -260,6 +322,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return { sessionId, title, + workspaceId, workspacePath: resolvedPath, remoteConnectionId, remoteSshHost, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets index 287e5aa201..940aac4648 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceCoordinator.ets @@ -1,5 +1,12 @@ import { WorkspaceCatalog } from './WorkspaceCatalog'; -import { SavedRuntimeConnection, AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { + SavedRuntimeConnection, + AssistantEntry, + RecentWorkspaceEntry, + RemoteSession, + RemoteWorkspaceIdentity, + WorkspaceInfo +} from '../model/RemoteModels'; import { CONCURRENT_WORKSPACE_LISTINGS, mapInConcurrentBatches, @@ -30,37 +37,37 @@ export class RemoteWorkspaceCoordinator { async savedConnections(): Promise { return await this.repository.savedConnections(); } - async switchWorkspace(path: string, connectionId?: string, sshHost?: string): Promise { - return await this.repository.setWorkspace(path, connectionId, sshHost); + async switchWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { + return await this.repository.setWorkspace(path, connectionId, sshHost, workspaceId); } - async switchAssistant(path: string): Promise { - return await this.repository.setAssistant(path); + async switchAssistant(path: string, workspaceId?: string): Promise { + return await this.repository.setAssistant(path, workspaceId); } /** * Every session the given workspaces know about, first occurrence winning. * * Listings run a batch at a time rather than all at once or one at a time, - * and results are merged in the order the paths were given, so widening the - * batch changes how long this takes and nothing about what it returns. + * and results are merged in the order the workspaces were given, so widening + * the batch changes how long this takes and nothing about what it returns. */ - async sessionsForWorkspaces(paths: string[]): Promise { + async sessionsForWorkspaces(workspaces: RemoteWorkspaceIdentity[]): Promise { const listings = await mapInConcurrentBatches( - paths, - (path: string): Promise => this.sessionsForWorkspace(path), + workspaces, + (workspace: RemoteWorkspaceIdentity): Promise => this.sessionsForWorkspace(workspace), CONCURRENT_WORKSPACE_LISTINGS ); return mergeUniqueSessions(listings); } /** One workspace's sessions, or none when that workspace cannot be read. */ - private async sessionsForWorkspace(path: string): Promise { + private async sessionsForWorkspace(workspace: RemoteWorkspaceIdentity): Promise { try { - return await this.repository.listSessionsForWorkspace(path, 50); + return await this.repository.listSessionsForWorkspace(workspace, 50); } catch (err) { // One unreadable workspace is not a reason to lose the other twenty. - RemoteLogger.warn(`load workspace sessions failed path=${path}: ${String(err)}`); + RemoteLogger.warn(`load workspace sessions failed workspace=${workspace.workspaceId || ''} path=${workspace.path}: ${String(err)}`); return []; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets index e041e56e13..f3a59c3393 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteWorkspaceRepository.ets @@ -1,15 +1,22 @@ import { WorkspaceCatalog } from './WorkspaceCatalog'; -import { SavedRuntimeConnection, AssistantEntry, RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../model/RemoteModels'; +import { + SavedRuntimeConnection, + AssistantEntry, + RecentWorkspaceEntry, + RemoteSession, + RemoteWorkspaceIdentity, + WorkspaceInfo +} from '../model/RemoteModels'; import { RemoteSessionManager } from './RemoteSessionManager'; export interface RemoteWorkspaceDataSource { listWorkspaceCatalog(): Promise; savedConnections(): Promise; listRecentWorkspaces(): Promise; - setWorkspace(path: string, connectionId?: string, sshHost?: string): Promise; + setWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise; listAssistants(): Promise; - setAssistant(path: string): Promise; - listSessionsForWorkspace(path: string, limit: number): Promise; + setAssistant(path: string, workspaceId?: string): Promise; + listSessionsForWorkspace(workspace: RemoteWorkspaceIdentity, limit: number): Promise; ping(): Promise; reset(): void; } @@ -32,8 +39,8 @@ export class RemoteWorkspaceRepository implements RemoteWorkspaceDataSource { return await this.sessionManager.listRecentWorkspaces(); } - async setWorkspace(path: string, connectionId?: string, sshHost?: string): Promise { - await this.sessionManager.setWorkspace(path, connectionId, sshHost); + async setWorkspace(path: string, connectionId?: string, sshHost?: string, workspaceId?: string): Promise { + await this.sessionManager.setWorkspace(path, connectionId, sshHost, workspaceId); return await this.sessionManager.getWorkspaceInfo(); } @@ -41,13 +48,13 @@ export class RemoteWorkspaceRepository implements RemoteWorkspaceDataSource { return await this.sessionManager.listAssistants(); } - async setAssistant(path: string): Promise { - await this.sessionManager.setAssistant(path); + async setAssistant(path: string, workspaceId?: string): Promise { + await this.sessionManager.setAssistant(path, workspaceId); return await this.sessionManager.getWorkspaceInfo(); } - async listSessionsForWorkspace(path: string, limit: number): Promise { - return await this.sessionManager.listSessionsForWorkspace(path, limit); + async listSessionsForWorkspace(workspace: RemoteWorkspaceIdentity, limit: number): Promise { + return await this.sessionManager.listSessionsForWorkspace(workspace, limit); } async ping(): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WorkspaceCatalog.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WorkspaceCatalog.ets index c24634853a..b4f3c0983b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WorkspaceCatalog.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WorkspaceCatalog.ets @@ -1,5 +1,6 @@ import { AssistantEntry, RecentWorkspaceEntry, RecentWorkspaceListResponse } from '../model/RemoteModels'; import { RemoteResponseMapper } from './RemoteResponseMapper'; +import { remoteWorkspaceKey } from './RemoteSessionIdentity'; export type WorkspaceCatalogSource = 'opened' | 'recent'; @@ -21,12 +22,14 @@ export function projectWorkspaceCatalog( const seen: Set = new Set(); const workspaces: RecentWorkspaceEntry[] = []; rows.forEach((row: RecentWorkspaceEntry) => { - const key = JSON.stringify([row.remoteConnectionId || null, row.remoteSshHost || null, row.path]); + // Rows with an ID dedup by ID alone; only pre-ID rows fall back to the legacy triple. + const key = remoteWorkspaceKey(row); if (!row.path || seen.has(key)) return; seen.add(key); const assistant = !row.remoteConnectionId && !row.remoteSshHost ? - assistants.find((item: AssistantEntry): boolean => item.path === row.path && item.name.trim().length > 0) : undefined; + assistants.find((item: AssistantEntry): boolean => (row.workspaceId ? item.workspace_id === row.workspaceId : !item.workspace_id && item.path === row.path) && item.name.trim().length > 0) : undefined; workspaces.push({ + workspaceId: row.workspaceId, path: row.path, name: assistant ? assistant.name : row.name, lastOpened: row.lastOpened, workspaceKind: assistant ? 'assistant' : row.workspaceKind, remoteConnectionId: row.remoteConnectionId, remoteSshHost: row.remoteSshHost @@ -35,14 +38,25 @@ export function projectWorkspaceCatalog( return { workspaces, recentWorkspaces: recent, source: opened ? 'opened' : 'recent' }; } +/** + * Upgrade-only merge for hosts without `opened_workspaces`. + * + * Assistant rows come first; a recent row is skipped when a row with the same + * key exists, where the key is the workspace ID when present and the legacy + * (connection, ssh host, path) triple otherwise. + */ export function mergeLegacyWorkspaces(recent: RecentWorkspaceEntry[], assistants: AssistantEntry[]): RecentWorkspaceEntry[] { const rows: RecentWorkspaceEntry[] = assistants.map((item: AssistantEntry): RecentWorkspaceEntry => ({ + workspaceId: item.workspace_id, path: item.path, name: item.name, lastOpened: '', workspaceKind: 'assistant' })); + const seen: Set = new Set(); + rows.forEach((row: RecentWorkspaceEntry) => seen.add(remoteWorkspaceKey(row))); recent.forEach((item: RecentWorkspaceEntry) => { - if (!rows.some((row: RecentWorkspaceEntry): boolean => row.path === item.path && - (row.remoteConnectionId || '') === (item.remoteConnectionId || '') && - (row.remoteSshHost || '') === (item.remoteSshHost || ''))) rows.push(item); + const key = remoteWorkspaceKey(item); + if (seen.has(key)) return; + seen.add(key); + rows.push(item); }); return rows; } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 30f5ab6309..e82eed79cd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -114,6 +114,7 @@ import { RemoteModelCatalog, ReadFileResult, RemoteSession, + RemoteWorkspaceIdentity, SelectedImageAttachment, SessionListResult, SessionMessagesResult, @@ -1601,7 +1602,7 @@ export default function conversationStateUnitTest() { ]); const coordinator = new RemoteWorkspaceCoordinator(source); - const sessions = await coordinator.sessionsForWorkspaces(['/one', '/two']); + const sessions = await coordinator.sessionsForWorkspaces([{ path: '/one' }, { path: '/two' }]); expect(sessions.length).assertEqual(3); expect(sessions[0].id).assertEqual('shared-session'); expect(sessions[1].id).assertEqual('one-session'); @@ -1612,15 +1613,15 @@ export default function conversationStateUnitTest() { // the order they happened to finish in. it('keeps results in path order across concurrency batches', 0, async () => { const source = new FakeRemoteWorkspaceDataSource(); - const paths: string[] = []; + const workspaces: RemoteWorkspaceIdentity[] = []; for (let index = 0; index < 9; index++) { const path = `/workspace-${index}`; - paths.push(path); + workspaces.push({ path }); source.sessionsByPath.set(path, [remoteSession(`session-${index}`, `Session ${index}`)]); } const coordinator = new RemoteWorkspaceCoordinator(source); - const sessions = await coordinator.sessionsForWorkspaces(paths); + const sessions = await coordinator.sessionsForWorkspaces(workspaces); expect(sessions.length).assertEqual(9); expect(sessions[0].id).assertEqual('session-0'); expect(sessions[4].id).assertEqual('session-4'); @@ -1635,11 +1636,25 @@ export default function conversationStateUnitTest() { source.failingPaths = ['/two']; const coordinator = new RemoteWorkspaceCoordinator(source); - const sessions = await coordinator.sessionsForWorkspaces(['/one', '/two', '/three']); + const sessions = await coordinator.sessionsForWorkspaces([{ path: '/one' }, { path: '/two' }, { path: '/three' }]); expect(sessions.length).assertEqual(2); expect(sessions[0].id).assertEqual('one-session'); expect(sessions[1].id).assertEqual('three-session'); }); + + it('lists identified workspaces by their ID rather than their display path', 0, async () => { + const source = new FakeRemoteWorkspaceDataSource(); + source.sessionsByPath.set('ws-1', [remoteSession('by-id', 'By ID')]); + source.sessionsByPath.set('/renamed', [remoteSession('by-path', 'By path')]); + const coordinator = new RemoteWorkspaceCoordinator(source); + + const sessions = await coordinator.sessionsForWorkspaces([{ workspaceId: 'ws-1', path: '/renamed' }]); + expect(sessions.length).assertEqual(1); + expect(sessions[0].id).assertEqual('by-id'); + expect(source.listedPaths.join(',')).assertEqual('ws-1'); + await coordinator.switchAssistant('', 'ws-2'); + expect(source.lastSetAssistantId || '').assertEqual('ws-2'); + }); }); describe('ProductSurfaceIsolation', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets index 4585939fe8..13afb0ffa1 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/DeviceDirectoryUnit.test.ets @@ -1,6 +1,12 @@ import { WorkspaceCatalog, mergeLegacyWorkspaces } from '../main/ets/services/WorkspaceCatalog'; import { describe, it, expect } from '@ohos/hypium'; -import { AssistantEntry, RecentWorkspaceEntry, RemoteSession, SessionListResult } from '../main/ets/model/RemoteModels'; +import { + AssistantEntry, + RecentWorkspaceEntry, + RemoteSession, + RemoteWorkspaceIdentity, + SessionListResult +} from '../main/ets/model/RemoteModels'; import { AccountDeviceDirectorySource } from '../main/ets/services/AccountDeviceDirectory'; import { CloudAccountDevice } from '../main/ets/services/CloudAccountClient'; import { @@ -40,12 +46,15 @@ class FakeAccountDeviceDirectory implements AccountDeviceDirectorySource { return this.assistantsByDevice.get(deviceId) || []; } - async listSessions(deviceId: string, workspacePath: string): Promise { - this.listedWorkspacePaths.push(`${deviceId}:${workspacePath}`); - if (this.failingPaths.includes(`${deviceId}:${workspacePath}`)) { + /** Sessions are keyed by `${deviceId}:${workspaceId}` when the row has an ID, else by device and path. */ + async listSessions(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise { + const workspacePath = workspace.path; + const key = workspace.workspaceId ? `${deviceId}:${workspace.workspaceId}` : `${deviceId}:${workspacePath}`; + this.listedWorkspacePaths.push(key); + if (this.failingPaths.includes(key)) { throw new Error(`workspace unavailable: ${workspacePath}`); } - return this.sessionsByDevicePath.get(`${deviceId}:${workspacePath}`) || []; + return this.sessionsByDevicePath.get(key) || []; } } @@ -83,10 +92,10 @@ class GatedAccountDeviceDirectory extends FakeAccountDeviceDirectory { return super.listRecentWorkspaces(deviceId); } - async listSessions(deviceId: string, workspacePath: string): Promise { + async listSessions(deviceId: string, workspace: RemoteWorkspaceIdentity): Promise { this.notifyWorkspacesListed(); await this.sessionHold; - return super.listSessions(deviceId, workspacePath); + return super.listSessions(deviceId, workspace); } } diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index a336257500..880298360f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -124,6 +124,7 @@ import { ReadFileChunkResult, ReadFileResult, RemoteSession, + RemoteWorkspaceIdentity, SelectedImageAttachment, SteerTurnResult, SessionListResult, @@ -179,8 +180,14 @@ export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource return this.recent; } - async setWorkspace(_path: string): Promise { + /** The last identified selection, so tests can assert IDs reach the data source. */ + lastSetWorkspaceId: string | undefined = undefined; + lastSetAssistantId: string | undefined = undefined; + + async setWorkspace(_path: string, _connectionId?: string, _sshHost?: string, workspaceId?: string): Promise { + this.lastSetWorkspaceId = workspaceId; return { + workspaceId: workspaceId, name: 'workspace', path: '/workspace', hasWorkspace: true, gitBranch: 'main', workspaceKind: 'normal', assistantId: '' }; @@ -190,19 +197,23 @@ export class FakeRemoteWorkspaceDataSource implements RemoteWorkspaceDataSource return []; } - async setAssistant(_path: string): Promise { + async setAssistant(_path: string, workspaceId?: string): Promise { + this.lastSetAssistantId = workspaceId; return { + workspaceId: workspaceId, name: 'assistant', path: '/assistant', hasWorkspace: true, gitBranch: '', workspaceKind: 'assistant', assistantId: 'assistant' }; } - async listSessionsForWorkspace(path: string, _limit: number): Promise { - this.listedPaths.push(path); - if (this.failingPaths.includes(path)) { - throw new Error(`workspace unavailable: ${path}`); + /** Rows with an ID are looked up by ID; pre-ID rows by their path. */ + async listSessionsForWorkspace(workspace: RemoteWorkspaceIdentity, _limit: number): Promise { + const key = workspace.workspaceId ? workspace.workspaceId : workspace.path; + this.listedPaths.push(key); + if (this.failingPaths.includes(key)) { + throw new Error(`workspace unavailable: ${key}`); } - return this.sessionsByPath.get(path) || []; + return this.sessionsByPath.get(key) || []; } async ping(): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index b837a53b37..2f02b4ff94 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -2419,12 +2419,38 @@ export default function remoteControllersUnitTest() { expect(projection.chats.map((item: RemoteSession) => item.id).join(',')).assertEqual('chat-1,chat-2'); // A trailing slash is the same workspace, so both code sessions land in // the one bucket the sidebar renders under that project. - const bucket = SessionListProjector.sessionsForProject(projection, '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/workspace/openbitfun/'); + const bucket = SessionListProjector.sessionsForProject(projection, { path: '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/workspace/openbitfun/' }); expect(bucket.map((item: RemoteSession) => item.id).join(',')).assertEqual('code-1,code-2'); expect(projection.projects.map((item: RecentWorkspaceEntry) => item.path).join(',')) .assertEqual('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/workspace/openbitfun'); expect(projection.hasActiveFilter).assertFalse(); - expect(SessionListProjector.sessionsForProject(projection, '/nowhere').length).assertEqual(0); + expect(SessionListProjector.sessionsForProject(projection, { path: '/nowhere' }).length).assertEqual(0); + }); + + it('keys project sections by workspace ID and attributes pre-ID sessions through the resolver', 0, () => { + const identified = inputs([ + session('code-1', 'Fix layout', 'code', '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/workspace/openbitfun'), + session('code-2', 'Trailing slash', 'code', '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/workspace/openbitfun/'), + session('code-3', 'Renamed root', 'code', '/moved') + ]); + identified.workspaceId = 'ws-main'; + identified.recentWorkspaces = [ + { workspaceId: 'ws-main', path: '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/workspace/openbitfun', name: 'OpenBitFun', lastOpened: '', workspaceKind: 'normal' }, + { workspaceId: 'ws-notes', path: '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/workspace/notes', name: 'Notes', lastOpened: '', workspaceKind: 'assistant' } + ]; + identified.sessions[2].workspaceIdentity = { workspaceId: 'ws-main', path: '/moved' }; + const projection = SessionListProjector.project(identified); + expect(projection.projects.length).assertEqual(1); + expect(projection.projects[0].workspaceId || '').assertEqual('ws-main'); + // The ID alone selects the section, whatever path the reference carries. + const byId = SessionListProjector.sessionsForProject(projection, { workspaceId: 'ws-main', path: '/elsewhere' }); + expect(byId.map((item: RemoteSession) => item.id).join(',')).assertEqual('code-1,code-2,code-3'); + // A pre-ID reference still finds the section through its unique catalog row. + const byPath = SessionListProjector.sessionsForProject(projection, { path: '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/workspace/openbitfun/' }); + expect(byPath.map((item: RemoteSession) => item.id).join(',')).assertEqual('code-1,code-2,code-3'); + expect(SessionListProjector.sessionsForProject(projection, { workspaceId: 'ws-unknown', path: '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/workspace/openbitfun' }).length) + .assertEqual(0); + expect(SessionListProjector.sectionKeyFor(projection.projects[0])).assertEqual('workspace:7:ws-main'); }); it('drops projects a filter has emptied', 0, () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 3ff37f064b..aa160c7564 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1900,15 +1900,28 @@ export default function transportAndGeneralChatUnitTest() { it('builds workspace and assistant commands', 0, () => { expectCommandJson(RemoteCommandFactory.getWorkspaceInfo(), '{"cmd":"get_workspace_info"}'); expectCommandJson(RemoteCommandFactory.listRecentWorkspaces(), '{"cmd":"list_recent_workspaces"}'); + // Pre-ID references keep the legacy path projection. expectCommandJson(RemoteCommandFactory.setWorkspace('/repo'), '{"cmd":"set_workspace","path":"/repo"}'); + expectCommandJson( + RemoteCommandFactory.setWorkspace('/repo', undefined, 'saved', 'host'), + '{"cmd":"set_workspace","path":"/repo","remote_connection_id":"saved","remote_ssh_host":"host"}' + ); expectCommandJson(RemoteCommandFactory.listAssistants(), '{"cmd":"list_assistants"}'); expectCommandJson(RemoteCommandFactory.setAssistant('/assistant'), '{"cmd":"set_assistant","path":"/assistant"}'); + // An identified reference sends only its ID so the host cannot fall back to the path. + expectCommandJson(RemoteCommandFactory.setWorkspace('/repo', 'ws-1', 'saved', 'host'), '{"cmd":"set_workspace","workspace_id":"ws-1"}'); + expectCommandJson(RemoteCommandFactory.setAssistant('/assistant', 'ws-2'), '{"cmd":"set_assistant","workspace_id":"ws-2"}'); }); it('builds session list and creation commands without unsupported fields', 0, () => { - const listCommand = RemoteCommandFactory.listSessions('/repo', 8, 16, ' auth '); + const listCommand = RemoteCommandFactory.listSessions({ path: '/repo' }, 8, 16, ' auth '); expectCommandJson(listCommand, '{"cmd":"list_sessions","workspace_path":"/repo","limit":8,"offset":16,"query":"auth"}'); expect(JSON.stringify(listCommand).indexOf('agent_type')).assertEqual(-1); + const sshList = RemoteCommandFactory.listSessions({ path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 8, 0, ''); + expectCommandJson(sshList, '{"cmd":"list_sessions","workspace_path":"/repo","limit":8,"offset":0,"remote_connection_id":"saved","remote_ssh_host":"host"}'); + const identifiedList = RemoteCommandFactory.listSessions( + { workspaceId: 'ws-1', path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 8, 16, 'auth'); + expectCommandJson(identifiedList, '{"cmd":"list_sessions","workspace_id":"ws-1","limit":8,"offset":16,"query":"auth"}'); const createCommand = RemoteCommandFactory.createSession({ agentType: 'cowork', @@ -1926,6 +1939,11 @@ export default function transportAndGeneralChatUnitTest() { }, '/repo'); expect(minimal.agent_type || '').assertEqual('minimal'); expect(ultimate.agent_type || '').assertEqual('Ultra'); + + const identified = RemoteCommandFactory.createSession({ + agentType: 'code', title: '', instruction: '', workspaceId: 'ws-1', remoteConnectionId: 'saved', remoteSshHost: 'host' + }, '/repo'); + expectCommandJson(identified, '{"cmd":"create_session","agent_type":"code","session_name":"Remote Code Session","workspace_id":"ws-1"}'); }); it('builds message and polling commands', 0, () => { diff --git a/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs b/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs index 4090a357b8..19989b8469 100644 --- a/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/device-directory-lifecycle.test.cjs @@ -14,11 +14,16 @@ function load(relative, dependencies = {}) { new Function('require', 'exports', js)(name => dependencies[name] || {}, exports); return exports; } -const stateModule = load('pages/state/DeviceDirectoryState'); +const identityModule = load('services/RemoteSessionIdentity', { + './LegacyWorkspaceCompatibility': load('services/LegacyWorkspaceCompatibility') +}); +const stateModule = load('pages/state/DeviceDirectoryState', { + '../../services/RemoteSessionIdentity': identityModule +}); const { DeviceDirectoryViewModel } = load('pages/viewmodel/DeviceDirectoryViewModel', { '../state/DeviceDirectoryState': stateModule, '../../services/RemoteLogger': { RemoteLogger: { info() {}, warn() {} } }, - '../../services/RemoteSessionIdentity': load('services/RemoteSessionIdentity'), + '../../services/RemoteSessionIdentity': identityModule, '../../services/DeviceDirectoryCoordinator': { DeviceDirectoryCoordinator: class { constructor(source) { this.source = source; } loadWorkspaceCatalog(id) { return this.source.listWorkspaceCatalog(id); } diff --git a/src/apps/mobile/harmonyos/tools/tests/workspace-catalog.test.cjs b/src/apps/mobile/harmonyos/tools/tests/workspace-catalog.test.cjs index 055d2c16c3..f7df7eccc2 100644 --- a/src/apps/mobile/harmonyos/tools/tests/workspace-catalog.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/workspace-catalog.test.cjs @@ -48,8 +48,7 @@ test('session ownership distinguishes same paths on local and SSH hosts', () => const local = { path: '/project' }; const a = { path: '/project', remoteConnectionId: 'a', remoteSshHost: 'host-a' }; const b = { path: '/project', remoteConnectionId: 'b', remoteSshHost: 'host-b' }; - const session = stampRemoteSessionDevice(stampRemoteSessionWorkspace({ id: '1' }, a.path, - a.remoteConnectionId, a.remoteSshHost), 'desktop'); + const session = stampRemoteSessionDevice(stampRemoteSessionWorkspace({ id: '1' }, a), 'desktop'); for (const scope of [local, a, b]) { assert.equal(remoteSessionBelongsToWorkspace(session, scope, [local, a, b]), scope === a); } @@ -75,20 +74,39 @@ test('workspace identity normalizes trailing separators without aliasing missing }); test('publishing an already scoped session preserves its owning SSH workspace', () => { - const original = stampRemoteSessionWorkspace({ id: 'a' }, '/project', 'ssh-a', 'host-a'); - assert.equal(stampRemoteSessionWorkspace(original, '/project', 'ssh-b', 'host-b'), original); - assert.equal(stampRemoteSessionWorkspace(original, '/local'), original); + const original = stampRemoteSessionWorkspace({ id: 'a' }, { path: '/project', remoteConnectionId: 'ssh-a', remoteSshHost: 'host-a' }); + assert.equal(stampRemoteSessionWorkspace(original, { path: '/project', remoteConnectionId: 'ssh-b', remoteSshHost: 'host-b' }), original); + assert.equal(stampRemoteSessionWorkspace(original, { path: '/local' }), original); + // A host-pinned ID is never overwritten by a listing scope naming another workspace. + const pinned = stampRemoteSessionWorkspace({ id: 'b' }, { workspaceId: 'ws-a', path: '/project' }); + assert.equal(stampRemoteSessionWorkspace(pinned, { workspaceId: 'ws-b', path: '/project' }), pinned); }); const { RemoteCommandFactory } = load('RemoteCommandFactory'); test('workspace list and creation commands preserve SSH scope without changing legacy omission', () => { - const listing = RemoteCommandFactory.listSessions('/repo', 50, 0, '', 'saved', 'host'); + // Pre-ID references: the legacy (path, connection, ssh host) projection is the only thing the host can resolve. + const listing = RemoteCommandFactory.listSessions({ path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 50, 0, ''); + assert.equal(listing.workspace_path, '/repo'); assert.equal(listing.remote_connection_id, 'saved'); assert.equal(listing.remote_ssh_host, 'host'); + assert.equal(listing.workspace_id, undefined); const created = RemoteCommandFactory.createSession({ agentType: 'code', title: '', remoteConnectionId: 'saved', remoteSshHost: 'host' }, '/repo'); assert.equal(created.remote_connection_id, 'saved'); assert.equal(created.remote_ssh_host, 'host'); - const legacy = RemoteCommandFactory.listSessions('/repo', 50, 0, ''); + const legacy = RemoteCommandFactory.listSessions({ path: '/repo' }, 50, 0, ''); assert.equal(JSON.stringify(legacy).includes('remote_connection_id'), false); assert.equal(JSON.stringify(legacy).includes('remote_ssh_host'), false); + // Identified references: the ID alone, so an ID-aware host can never fall back to the path. + const identified = RemoteCommandFactory.listSessions({ workspaceId: 'ws-1', path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 50, 0, ''); + assert.deepEqual(identified, { cmd: 'list_sessions', workspace_id: 'ws-1', limit: 50, offset: 0 }); +}); + +test('1.0.0 localhost markers follow workspace kind without losing real SSH identity', () => { + const catalog = projectWorkspaceCatalog({ workspaces: [ + { path: '/local', workspace_kind: 'normal', remote_ssh_host: 'localhost' }, + { path: '/remote', workspace_kind: 'remote', remote_connection_id: 'saved', remote_ssh_host: 'localhost' }, + ] }, []); + assert.equal(catalog.workspaces[0].remoteSshHost, undefined); + assert.equal(catalog.workspaces[1].remoteSshHost, 'localhost'); + assert.equal(catalog.workspaces[1].remoteConnectionId, 'saved'); }); diff --git a/src/apps/mobile/harmonyos/tools/tests/workspace-identity.test.cjs b/src/apps/mobile/harmonyos/tools/tests/workspace-identity.test.cjs new file mode 100644 index 0000000000..2fea795e1e --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/workspace-identity.test.cjs @@ -0,0 +1,280 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const STUBS = { + 'i18n/RemoteI18n': { RemoteI18n: { t: key => key, f: (key, ...args) => `${key}:${args.join(',')}` } }, + 'services/RemoteLogger': { RemoteLogger: { info() {}, warn() {}, error() {} } }, +}; +const cache = new Map(); + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (STUBS[relative]) return STUBS[relative]; + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS + } }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + // System modules (@ohos.*, @kit.*) are only reached by code paths these tests do not run. + if (!name.startsWith('.')) return {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)).split(path.sep).join('/'); + return load(target); + }, exported); + return exported; +} + +const { + remoteWorkspaceKey, legacyRemoteWorkspaceKey, remoteWorkspaceIdentityMatches, + remoteSessionBelongsToWorkspace, stampRemoteSessionWorkspace +} = load('services/RemoteSessionIdentity'); +const { LegacyWorkspaceCompatibility } = load('services/LegacyWorkspaceCompatibility'); +const { RemoteCommandFactory } = load('services/RemoteCommandFactory'); +const { RemoteResponseMapper } = load('services/RemoteResponseMapper'); +const { RemoteSessionManager } = load('services/RemoteSessionManager'); +const { mergeLegacyWorkspaces, projectWorkspaceCatalog } = load('services/WorkspaceCatalog'); +const { REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1 } = load('model/RemoteModels'); +const { SessionListProjector } = load('pages/policy/SessionListProjection'); +const { DeviceDirectoryState } = load('pages/state/DeviceDirectoryState'); + +const WS = 'ws-0001'; + +test('an identified reference keys by its ID alone; only ID-less references use the legacy triple', () => { + const byId = { workspaceId: WS, path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + assert.equal(remoteWorkspaceKey(byId), `workspace:${WS.length}:${WS}`); + assert.equal(remoteWorkspaceKey(byId), remoteWorkspaceKey({ workspaceId: WS, path: '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/moved/elsewhere' })); + assert.notEqual(remoteWorkspaceKey(byId), remoteWorkspaceKey({ workspaceId: 'ws-0002', path: '/repo' })); + const legacy = { path: '/repo/', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + assert.equal(remoteWorkspaceKey(legacy), legacyRemoteWorkspaceKey(legacy)); + assert.equal(remoteWorkspaceKey(legacy), remoteWorkspaceKey({ path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' })); + assert.notEqual(remoteWorkspaceKey({ path: '/repo' }), remoteWorkspaceKey({ path: '/repo', remoteConnectionId: 'saved' })); + assert.equal(remoteWorkspaceKey({ workspaceId: '', path: '/repo' }), remoteWorkspaceKey({ path: '/repo' })); +}); + +test('two identified references compare by ID only, whatever their paths say', () => { + const catalog = [{ workspaceId: WS, path: '/repo' }]; + assert.equal(remoteWorkspaceIdentityMatches({ workspaceId: WS, path: '/repo' }, { workspaceId: WS, path: '/other' }, catalog), true); + assert.equal(remoteWorkspaceIdentityMatches({ workspaceId: WS, path: '/repo' }, { workspaceId: 'ws-x', path: '/repo' }, catalog), false); +}); + +test('legacy attribution adopts the unique catalog row and reports its ID', () => { + const local = { workspaceId: WS, path: '/repo' }; + const ssh = { workspaceId: 'ws-ssh', path: '/srv/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + const catalog = [local, ssh]; + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/repo/' }, catalog), local); + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/srv/repo', remoteConnectionId: 'saved' }, catalog), ssh); + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/srv/repo', remoteConnectionId: 'other' }, catalog), undefined); + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/missing' }, catalog), undefined); + assert.equal(remoteWorkspaceIdentityMatches({ path: '/repo' }, local, catalog), true); + assert.equal(remoteWorkspaceIdentityMatches({ path: '/repo' }, ssh, catalog), false); +}); + +test('an unknown ID is an error state and never falls back to the path', () => { + const local = { workspaceId: WS, path: '/repo' }; + assert.equal(LegacyWorkspaceCompatibility.resolve({ workspaceId: 'ws-unknown', path: '/repo' }, [local]), undefined); + assert.equal(remoteWorkspaceIdentityMatches({ workspaceId: 'ws-unknown', path: '/repo' }, { path: '/repo' }, [local]), false); + assert.equal(remoteWorkspaceIdentityMatches({ workspaceId: 'ws-unknown', path: '/repo' }, local, [local]), false); +}); + +test('an ambiguous legacy path stays unresolved instead of guessing a row', () => { + const local = { workspaceId: WS, path: '/repo' }; + const ssh = { workspaceId: 'ws-ssh', path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/repo' }, [local, ssh]), undefined); + assert.equal(remoteWorkspaceIdentityMatches({ path: '/repo' }, local, [local, ssh]), false); + assert.equal(remoteWorkspaceIdentityMatches({ path: '/repo' }, ssh, [local, ssh]), false); + assert.equal(remoteSessionBelongsToWorkspace({ id: 's', workspacePath: '/repo' }, local, [local, ssh]), false); +}); + +test('a 1.0.0 localhost marker without a saved connection is not an SSH host', () => { + const local = { workspaceId: WS, path: '/repo' }; + const ssh = { workspaceId: 'ws-ssh', path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'localhost' }; + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/repo', remoteSshHost: 'localhost' }, [local]), local); + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/repo', remoteSshHost: 'localhost' }, [local, ssh]), undefined); + assert.equal(LegacyWorkspaceCompatibility.resolve({ path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'localhost' }, [local, ssh]), ssh); +}); + +test('sessions stamped from an identified listing carry the workspace ID and keep a host-pinned ID', () => { + const scope = { workspaceId: WS, path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + const stamped = stampRemoteSessionWorkspace({ id: 's1' }, scope); + assert.deepEqual(stamped.workspaceIdentity, scope); + assert.equal(stamped.workspacePath, '/repo'); + const pinned = stampRemoteSessionWorkspace({ id: 's2', workspaceIdentity: { workspaceId: 'ws-other', path: '/x' } }, scope); + assert.equal(pinned.workspaceIdentity.workspaceId, 'ws-other'); + const completed = stampRemoteSessionWorkspace({ id: 's3', workspaceIdentity: { workspaceId: WS, path: '/repo' } }, scope); + assert.equal(completed.workspaceIdentity.remoteConnectionId, 'saved'); + assert.equal(completed.workspaceIdentity.workspaceId, WS); + assert.equal(remoteSessionBelongsToWorkspace(stamped, { workspaceId: WS, path: '/renamed' }, []), true); + assert.equal(remoteSessionBelongsToWorkspace(stamped, { workspaceId: 'ws-other', path: '/repo' }, []), false); +}); + +test('session_list rows map workspace_id into the session identity', () => { + const [withId, legacy] = RemoteResponseMapper.allSessions([ + { id: 'a', workspace_id: WS, workspace_path: '/repo', agent_type: 'code' }, + { id: 'b', workspace_path: '/repo', agent_type: 'code' }, + ]); + assert.deepEqual(withId.workspaceIdentity, { workspaceId: WS, path: '/repo' }); + assert.equal(legacy.workspaceIdentity, undefined); + assert.equal(legacy.workspacePath, '/repo'); +}); + +test('list_sessions carries only workspace_id when the reference has one', () => { + const command = RemoteCommandFactory.listSessions({ workspaceId: WS, path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 20, 40, ' q '); + assert.deepEqual(command, { cmd: 'list_sessions', workspace_id: WS, limit: 20, offset: 40, query: 'q' }); + const legacy = RemoteCommandFactory.listSessions({ path: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }, 50, 0, ''); + assert.deepEqual(legacy, { cmd: 'list_sessions', workspace_path: '/repo', limit: 50, offset: 0, remote_connection_id: 'saved', remote_ssh_host: 'host' }); +}); + +test('set_assistant and set_workspace carry only workspace_id when the reference has one', () => { + assert.deepEqual(RemoteCommandFactory.setAssistant('/assistant', WS), { cmd: 'set_assistant', workspace_id: WS }); + assert.deepEqual(RemoteCommandFactory.setAssistant('/assistant'), { cmd: 'set_assistant', path: '/assistant' }); + assert.deepEqual(RemoteCommandFactory.setWorkspace('/repo', WS, 'saved', 'host'), { cmd: 'set_workspace', workspace_id: WS }); + assert.deepEqual(RemoteCommandFactory.setWorkspace('/repo'), { cmd: 'set_workspace', path: '/repo' }); + assert.deepEqual(RemoteCommandFactory.setWorkspace('/repo', undefined, 'saved', 'host'), + { cmd: 'set_workspace', path: '/repo', remote_connection_id: 'saved', remote_ssh_host: 'host' }); +}); + +test('create_session carries only workspace_id when the reference has one', () => { + const command = RemoteCommandFactory.createSession( + { agentType: 'code', title: '', instruction: '', workspaceId: WS, remoteConnectionId: 'saved', remoteSshHost: 'host' }, '/repo'); + assert.deepEqual(command, { cmd: 'create_session', agent_type: 'code', session_name: 'Remote Code Session', workspace_id: WS }); + const legacy = RemoteCommandFactory.createSession({ agentType: 'code', title: '', instruction: '', remoteConnectionId: 'saved', remoteSshHost: 'host' }, '/repo'); + assert.equal(legacy.workspace_id, undefined); + assert.equal(legacy.workspace_path, '/repo'); + assert.equal(legacy.remote_connection_id, 'saved'); +}); + +function manager(capabilities, workspace) { + const instance = Object.create(RemoteSessionManager.prototype); + instance.hostCapabilities = capabilities; + instance.workspace = workspace; + instance.sent = []; + return instance; +} + +test('session_created.workspace_id is consumed and wins over the requested ID', async () => { + const m = manager([REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1]); + m.send = async command => { + m.sent.push(command); + return { resp: 'session_created', session_id: 's1', workspace_id: 'ws-host', workspace_path: '/repo', remote_connection_id: 'saved', remote_ssh_host: 'host' }; + }; + const allocated = []; + const summary = await m.createSession({ agentType: 'code', title: '', instruction: '', workspaceId: WS, workspacePath: '/repo' }, + async session => { allocated.push(session); }); + assert.deepEqual(m.sent, [{ cmd: 'create_session', agent_type: 'code', session_name: 'Remote Code Session', workspace_id: WS }]); + assert.equal(summary.workspaceId, 'ws-host'); + assert.equal(summary.workspacePath, '/repo'); + assert.equal(summary.remoteConnectionId, 'saved'); + assert.equal(summary.remoteSshHost, 'host'); + assert.equal(allocated[0].workspaceId, 'ws-host'); +}); + +test('a pre-ID host answering session_created without workspace_id keeps the legacy projection', async () => { + const m = manager([]); + m.send = async () => ({ resp: 'session_created', session_id: 's1' }); + const summary = await m.createSession({ agentType: 'code', title: '', instruction: '', workspacePath: '/repo', remoteConnectionId: 'saved', remoteSshHost: 'host' }); + assert.equal(summary.workspaceId, undefined); + assert.equal(summary.workspacePath, '/repo'); + assert.equal(summary.remoteConnectionId, 'saved'); +}); + +test('an identified reference against a host without workspace_id_references_v1 is an explicit unsupported state', async () => { + const m = manager(['harness_profiles_v1'], { workspaceId: WS, path: '/repo' }); + m.send = async command => { m.sent.push(command); return { resp: 'ok', success: true, sessions: [] }; }; + await assert.rejects(m.setWorkspace('/repo', undefined, undefined, WS), /errors\.workspaceIdUnsupported/); + await assert.rejects(m.setAssistant('/assistant', WS), /errors\.workspaceIdUnsupported/); + await assert.rejects(m.createSession({ agentType: 'code', title: '', instruction: '', workspaceId: WS }), /errors\.workspaceIdUnsupported/); + await assert.rejects(m.listSessionPageForWorkspace({ workspaceId: WS, path: '/repo' }), /errors\.workspaceIdUnsupported/); + await assert.rejects(m.listSessions(10, 0, '', ''), /errors\.workspaceIdUnsupported/); + assert.equal(m.supportsWorkspaceIdReferences(), false); + assert.deepEqual(m.sent, [], 'no command may be downgraded to a path'); + const legacy = manager(['harness_profiles_v1'], { path: '/repo' }); + legacy.send = async command => { legacy.sent.push(command); return { resp: 'session_list', sessions: [], has_more: false }; }; + await legacy.listSessionPageForWorkspace({ path: '/repo', remoteConnectionId: 'saved' }); + assert.deepEqual(legacy.sent, [{ cmd: 'list_sessions', workspace_path: '/repo', limit: 50, offset: 0, remote_connection_id: 'saved' }]); +}); + +test('manager listings use the active workspace ID and stamp it on every row', async () => { + const m = manager([REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1], { workspaceId: WS, path: '/repo', remoteConnectionId: 'saved' }); + m.send = async command => { + m.sent.push(command); + return { resp: 'session_list', sessions: [{ id: 'a', workspace_id: WS, workspace_path: '/repo', agent_type: 'code' }, { id: 'b', agent_type: 'code' }], has_more: false }; + }; + assert.equal(m.supportsWorkspaceIdReferences(), true); + const page = await m.listSessions(10, 0, '', ''); + assert.deepEqual(m.sent, [{ cmd: 'list_sessions', workspace_id: WS, limit: 10, offset: 0 }]); + assert.deepEqual(page.sessions.map(s => s.workspaceIdentity.workspaceId), [WS, WS]); + assert.equal(page.sessions[0].workspaceIdentity.remoteConnectionId, 'saved'); + m.sent.length = 0; + await m.listSessionPageForWorkspace({ workspaceId: 'ws-2', path: '/other' }, 7); + assert.deepEqual(m.sent, [{ cmd: 'list_sessions', workspace_id: 'ws-2', limit: 7, offset: 0 }]); +}); + +test('legacy catalog merge dedups by workspaceId when present and by legacy triple otherwise', () => { + const rows = mergeLegacyWorkspaces([ + { workspaceId: WS, path: '/moved', name: 'Moved', lastOpened: '', workspaceKind: 'normal' }, + { path: '/assistant', name: 'Folder', lastOpened: '', workspaceKind: 'normal' }, + { path: '/assistant', name: 'SSH', lastOpened: '', workspaceKind: 'normal', remoteConnectionId: 'saved' }, + { workspaceId: 'ws-9', path: '/assistant', name: 'Other ID', lastOpened: '', workspaceKind: 'normal' }, + ], [{ workspace_id: WS, path: '/assistant', name: 'Assistant' }]); + assert.deepEqual(rows.map(row => row.name), ['Assistant', 'Folder', 'SSH', 'Other ID']); + assert.equal(rows[0].workspaceId, WS); +}); + +test('old-shape cached catalog rows without workspace_id remain readable and keep their identity', () => { + const catalog = projectWorkspaceCatalog({ workspaces: [ + { path: '/repo', name: 'Old row' }, + { path: '/repo', name: 'Old SSH row', remote_connection_id: 'saved', remote_ssh_host: 'host', workspace_kind: 'remote' }, + { workspace_id: WS, path: '/repo', name: 'New row' }, + ] }, []); + assert.deepEqual(catalog.workspaces.map(row => row.workspaceId), [undefined, undefined, WS]); + assert.deepEqual(catalog.workspaces.map(row => row.name), ['Old row', 'Old SSH row', 'New row']); +}); + +test('sidebar sections key by workspaceId and attribute pre-ID sessions through the resolver', () => { + const current = { workspaceId: WS, path: '/repo', name: 'Repo', lastOpened: '', workspaceKind: 'normal' }; + const ssh = { workspaceId: 'ws-ssh', path: '/repo', name: 'Repo on host', lastOpened: '', workspaceKind: 'remote', remoteConnectionId: 'saved', remoteSshHost: 'host' }; + const assistant = { workspaceId: 'ws-assistant', path: '/assistant', name: 'Assistant', lastOpened: '', workspaceKind: 'assistant' }; + const other = { path: '/legacy', name: 'Legacy', lastOpened: '', workspaceKind: 'normal' }; + const sessions = [ + { id: 'byId', title: 'a', agentType: 'code', status: 'idle', updatedAt: '', createdAt: '', messageCount: 0, workspacePath: '/renamed', workspaceIdentity: { workspaceId: WS, path: '/renamed' } }, + { id: 'sshById', title: 'b', agentType: 'code', status: 'idle', updatedAt: '', createdAt: '', messageCount: 0, workspacePath: '/repo', workspaceIdentity: { workspaceId: 'ws-ssh', path: '/repo' } }, + { id: 'ambiguous', title: 'c', agentType: 'code', status: 'idle', updatedAt: '', createdAt: '', messageCount: 0, workspacePath: '/repo' }, + { id: 'legacy', title: 'd', agentType: 'code', status: 'idle', updatedAt: '', createdAt: '', messageCount: 0, workspacePath: '/legacy/' }, + { id: 'assistantById', title: 'e', agentType: 'code', status: 'idle', updatedAt: '', createdAt: '', messageCount: 0, workspacePath: '/assistant', workspaceIdentity: { workspaceId: 'ws-assistant', path: '/assistant' } }, + ]; + const projection = SessionListProjector.project({ + sessions, query: '', sortMode: 'project', workspaceName: 'Repo', workspacePath: '/repo', workspaceId: WS, + workspaceKind: 'normal', recentWorkspaces: [current, ssh, assistant, other], workspaceFilter: '', agentFilter: '', + statusFilter: '', showWorkspaceMetadata: false, showUpdatedMetadata: false, showStatusMetadata: false + }); + assert.deepEqual(projection.projects.map(p => p.workspaceId), [WS, 'ws-ssh', undefined]); + assert.deepEqual(SessionListProjector.sessionsForProject(projection, { workspaceId: WS, path: '/whatever' }).map(s => s.id), ['byId']); + assert.deepEqual(SessionListProjector.sessionsForProject(projection, ssh).map(s => s.id), ['sshById']); + assert.deepEqual(SessionListProjector.sessionsForProject(projection, other).map(s => s.id), ['legacy']); + assert.deepEqual(SessionListProjector.sessionsForProject(projection, { path: '/legacy' }).map(s => s.id), ['legacy']); + assert.deepEqual(projection.chats.map(s => s.id), ['assistantById']); + assert.equal(projection.filtered.some(s => s.id === 'ambiguous'), true, 'unattributed sessions are kept, not dropped'); + assert.equal(SessionListProjector.sectionKeyFor(current), `workspace:${WS.length}:${WS}`); +}); + +test('disclosure rows match by workspaceId first and keep same-path SSH rows apart without IDs', () => { + const state = new DeviceDirectoryState(); + state.setWorkspaceExpanded('desktop', '/repo', true, 'saved', 'host', WS); + assert.equal(state.workspaceExpanded('desktop', '/renamed', undefined, undefined, WS), true); + assert.equal(state.workspaceExpanded('desktop', '/repo', 'saved', 'host', 'ws-other'), false); + const load = state.workspaceLoadState('desktop', '/renamed', undefined, undefined, WS); + assert.equal(load.expanded, true); + assert.equal(load.workspaceId, WS); + assert.equal(state.isCurrentWorkspaceLoad(load), true); + const legacyA = state.workspaceLoadState('desktop', '/same', 'a', 'host-a'); + const legacyB = state.workspaceLoadState('desktop', '/same', 'b', 'host-b'); + assert.notEqual(legacyA, legacyB); + assert.equal(state.workspaceDirectory.length, 3); +}); diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 1764a19678..2e6ed8d508 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -308,7 +308,7 @@ private extension MobileAppModel { MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) ] - workspaceCatalog = [(path: "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/workspace/OpenBitFun", name: "OpenBitFun", selected: true, remoteConnectionId: nil, remoteSshHost: nil)] + workspaceCatalog = [(path: "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/workspace/OpenBitFun", name: "OpenBitFun", selected: true, remoteConnectionId: nil, remoteSshHost: nil, workspaceId: nil)] remoteAssistants = [ MobileAssistantOption(path: "/workspace/OpenBitFun/.openbitfun/assistants/review", name: "代码审查助手") ] diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift index fc7cc35d20..d38e876511 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift @@ -12,8 +12,8 @@ struct RemoteCreateSessionView: View { @State private var instruction = "" @State private var harnessProfile = HarnessProfile.standard @Environment(\.scenePhase) private var scenePhase - @State private var selectedWorkspacePath = "" - @State private var selectedWorkspaceConnectionId: String? + /// The workspace the draft targets, ID-first; nil is the plain chat (assistant) draft. + @State private var selectedWorkspace: MobileWorkspaceScope? @State private var newWorkspacePath = "" @State private var directoryVisible = false @State private var directoryConnectionId: String? @@ -110,7 +110,7 @@ struct RemoteCreateSessionView: View { .onChange(of: scenePhase) { if $0 != .active { speech.stop() } } .onChange(of: model.remoteTargetEpoch) { _ in speech.stop() - selectedWorkspacePath = "" + selectedWorkspace = nil } .onChange(of: model.remoteWorkspaces) { _ in reconcileSelectedWorkspace() @@ -129,10 +129,12 @@ struct RemoteCreateSessionView: View { return model.accountDeviceName ?? model.localized("选择桌面设备") } + private var selectedWorkspacePath: String { selectedWorkspace?.path ?? "" } + private var selectedWorkspaceName: String { - guard !selectedWorkspacePath.isEmpty else { return model.localized("对话") } - return model.remoteWorkspaces.first(where: { $0.path == selectedWorkspacePath })?.name - ?? selectedWorkspacePath + guard let selectedWorkspace else { return model.localized("对话") } + return model.remoteWorkspaces.first(where: { $0.scope.refersTo(selectedWorkspace) })?.name + ?? selectedWorkspace.path } private var selectedModel: ComposerModelOption? { @@ -314,8 +316,10 @@ struct RemoteCreateSessionView: View { title: "", instruction: instruction, modelID: selectedModelID, - workspacePath: selectedWorkspacePath.isEmpty ? nil : selectedWorkspacePath, - remoteConnectionId: selectedWorkspaceConnectionId + workspacePath: selectedWorkspace?.path, + remoteConnectionId: selectedWorkspace?.remoteConnectionId, + remoteSshHost: selectedWorkspace?.remoteSshHost, + workspaceId: selectedWorkspace?.workspaceId ) } else if model.workspaceLoadFailed { model.retryRemoteWorkspaces() @@ -338,8 +342,10 @@ struct RemoteCreateSessionView: View { title: "", instruction: value, modelID: selectedModelID, - workspacePath: selectedWorkspacePath.isEmpty ? nil : selectedWorkspacePath, - remoteConnectionId: selectedWorkspaceConnectionId + workspacePath: selectedWorkspace?.path, + remoteConnectionId: selectedWorkspace?.remoteConnectionId, + remoteSshHost: selectedWorkspace?.remoteSshHost, + workspaceId: selectedWorkspace?.workspaceId ) return } @@ -374,8 +380,7 @@ struct RemoteCreateSessionView: View { enabled: device.online || device.selected ) { pickerKind = nil - selectedWorkspacePath = "" - selectedWorkspaceConnectionId = nil + selectedWorkspace = nil model.selectRemoteDevice(device) } } @@ -405,11 +410,10 @@ struct RemoteCreateSessionView: View { icon: "message", title: model.localized("对话"), subtitle: "", - selected: selectedWorkspacePath.isEmpty, + selected: selectedWorkspace == nil, enabled: model.remoteCreateInteraction.canSelectWorkspace ) { - selectedWorkspacePath = "" - selectedWorkspaceConnectionId = nil + selectedWorkspace = nil pickerKind = nil } VStack { @@ -441,11 +445,10 @@ struct RemoteCreateSessionView: View { icon: "folder", title: workspace.name, subtitle: workspace.path, - selected: workspace.path == selectedWorkspacePath, + selected: selectedWorkspace.map { workspace.scope.refersTo($0) } ?? false, enabled: model.remoteCreateInteraction.canSelectWorkspace ) { - selectedWorkspacePath = workspace.path - selectedWorkspaceConnectionId = workspace.remoteConnectionId + selectedWorkspace = workspace.scope pickerKind = nil } } @@ -586,10 +589,10 @@ struct RemoteCreateSessionView: View { private func reconcileSelectedWorkspace() { if let selected = model.remoteWorkspaces.first(where: \.selected) { - selectedWorkspacePath = selected.path - } else if !selectedWorkspacePath.isEmpty, - !model.remoteWorkspaces.contains(where: { $0.path == selectedWorkspacePath }) { - selectedWorkspacePath = "" + selectedWorkspace = selected.scope + } else if let current = selectedWorkspace, + !model.remoteWorkspaces.contains(where: { $0.scope.refersTo(current) }) { + selectedWorkspace = nil } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift index 4b4fbe9038..d8690b1dd9 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift @@ -40,10 +40,13 @@ struct RemoteViewSettingsView: View { action: { model.remoteWorkspaceFilter = "" } ) ForEach(workspaces) { workspace in + // The filter is the option key (`workspaceId ?: legacy triple`); a filter + // persisted as a bare path before IDs existed still matches by path. filterRow( workspace.name, - selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), - action: { model.remoteWorkspaceFilter = workspace.path } + selected: model.remoteWorkspaceFilter == workspace.key || + normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), + action: { model.remoteWorkspaceFilter = workspace.key } ) } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index 1462601e7a..8b911ea9f8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -140,7 +140,7 @@ struct SidebarView: View { .overlayPreferenceValue(SidebarWorkspaceCreateAnchorKey.self) { anchors in GeometryReader { proxy in if let path = workspaceCreatePath, - let workspace = workspaceCreateTarget ?? model.remoteWorkspaces.first(where: { ($0.path + ":" + ($0.remoteConnectionId ?? "")) == path }), + let workspace = workspaceCreateTarget ?? model.remoteWorkspaces.first(where: { $0.scopeKey == path }), let anchor = anchors[path] { let frame = proxy[anchor] let menuHeight = HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) @@ -172,7 +172,7 @@ struct SidebarView: View { workspaceCreatePath == nil, let workspace = model.remoteWorkspaces.first { try? await Task.sleep(nanoseconds: 450_000_000) - workspaceCreatePath = workspace.path + ":" + (workspace.remoteConnectionId ?? "") + workspaceCreatePath = workspace.scopeKey } else if ProcessInfo.processInfo.arguments.contains("--sidebar-actions"), compactActionSession == nil, let session = model.sessionListSections.flatMap(\.sessions).first { @@ -427,6 +427,7 @@ struct SidebarView: View { let visibleWorkspaceCount = visibleDeviceWorkspaceCounts[device.id] ?? 3 ForEach(device.workspaces.prefix(visibleWorkspaceCount)) { workspace in let scopedWorkspace = MobileWorkspaceGroup( + workspaceId: workspace.workspaceId, path: workspace.path, name: workspace.name, selected: workspace.selected, @@ -455,14 +456,14 @@ struct SidebarView: View { }, onToggleCreate: { workspaceCreateTarget = scopedWorkspace - workspaceCreatePath = scopedWorkspace.path + ":" + (scopedWorkspace.remoteConnectionId ?? "") + workspaceCreatePath = scopedWorkspace.scopeKey }, onOpenWorkspace: { model.selectDirectoryWorkspace(scopedWorkspace) }, onOpenSession: { model.selectDirectorySession($0) }, onActions: { session in if permanent { onPermanentActions?(session) } else { compactActionSession = session } }, selectedDeviceKey: model.accountSelectedDeviceID, - selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path, + selectedWorkspace: model.selectedWorkspaceScope, directoryLoadStatus: workspace.directoryStatus, onRetryDirectoryLoad: { model.retryDirectoryWorkspace(device: device, workspace: scopedWorkspace) @@ -552,10 +553,12 @@ struct SidebarView: View { ) -> some View { let workspaces = sections.flatMap { section -> [MobileWorkspaceGroup] in guard section.kind == .project else { return [] } - let sources = model.remoteWorkspaces.filter { normalizedWorkspacePath($0.path) == normalizedWorkspacePath(section.path) } + // ID-first: a section stands for one workspace identity, so a same-path sibling never joins it. + guard let sectionScope = section.workspaceScope else { return [] } + let sources = model.remoteWorkspaces.filter { $0.scope.refersTo(sectionScope) } if sources.isEmpty { return [] } return sources.map { source in - MobileWorkspaceGroup(path: source.path, name: source.name, selected: source.selected, + MobileWorkspaceGroup(workspaceId: source.workspaceId, path: source.path, name: source.name, selected: source.selected, sessions: section.sessions, deviceKey: normalizedDeviceKey(model.remoteExpectedDeviceKey), remoteConnectionId: source.remoteConnectionId, remoteSshHost: source.remoteSshHost) } @@ -563,19 +566,19 @@ struct SidebarView: View { return ForEach(workspaces) { workspace in SidebarWorkspaceRow( workspace: workspace, - expanded: expandedWorkspacePaths.contains(workspace.path) || workspace.selected, + expanded: expandedWorkspacePaths.contains(workspace.scopeKey) || workspace.selected, selectedSessionID: model.surface == .remote ? model.selectedSessionID : nil, metadata: remoteSessionMetadata, onToggle: { - if expandedWorkspacePaths.contains(workspace.path) { - expandedWorkspacePaths.remove(workspace.path) + if expandedWorkspacePaths.contains(workspace.scopeKey) { + expandedWorkspacePaths.remove(workspace.scopeKey) } else { - expandedWorkspacePaths.insert(workspace.path) + expandedWorkspacePaths.insert(workspace.scopeKey) } }, onToggleCreate: { if HarnessProfilePolicy.shared.supported(capabilities: model.remoteHostCapabilities) { - workspaceCreatePath = workspaceCreatePath == workspace.path + ":" + (workspace.remoteConnectionId ?? "") ? nil : workspace.path + ":" + (workspace.remoteConnectionId ?? "") + workspaceCreatePath = workspaceCreatePath == workspace.scopeKey ? nil : workspace.scopeKey } else { model.createRemoteSession(in: workspace, agentType: "code") } @@ -588,7 +591,7 @@ struct SidebarView: View { else { compactActionSession = session } }, selectedDeviceKey: normalizedDeviceKey(model.remoteExpectedDeviceKey), - selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path + selectedWorkspace: model.selectedWorkspaceScope ) } } @@ -684,10 +687,15 @@ struct SidebarView: View { private func remoteIsAssistant(_ session: ChatSession) -> Bool { let agent = session.agentType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["claw", "assistant", "chat"].contains(agent) { return true } - let path = normalizedWorkspacePath(session.workspacePath) - return !path.isEmpty && model.remoteAssistants.contains { - normalizedWorkspacePath($0.path) == path + let assistants = model.remoteAssistants + if let workspaceId = session.workspaceScope?.workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceId.isEmpty { + // An ID-bearing session is an assistant session only if an assistant row carries that ID. + return assistants.contains { $0.workspaceId == workspaceId } } + // Pre-ID session: the path may name an assistant, but only when it names exactly one. + let path = normalizedWorkspacePath(session.workspacePath) + guard !path.isEmpty else { return false } + return assistants.filter { normalizedWorkspacePath($0.path) == path }.count == 1 } private func remoteWorkspacePath(_ session: ChatSession) -> String { @@ -945,7 +953,7 @@ private struct SidebarWorkspaceRow: View { let onOpenSession: (ChatSession) -> Void let onActions: (ChatSession) -> Void var selectedDeviceKey: String? = nil - var selectedWorkspacePath: String? = nil + var selectedWorkspace: MobileWorkspaceScope? = nil var directoryLoadStatus = "READY" var onRetryDirectoryLoad: (() -> Void)? = nil var createMenu: AnyView? = nil @@ -953,13 +961,9 @@ private struct SidebarWorkspaceRow: View { private func isSelected(_ session: ChatSession) -> Bool { guard selectedSessionID == session.id, - normalizedDeviceKey(selectedDeviceKey) == normalizedDeviceKey(workspace.deviceKey) else { return false } - func normalized(_ path: String?) -> String { - var value = (path ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - while value.count > 1 && value.hasSuffix("/") { value.removeLast() } - return value - } - return normalized(selectedWorkspacePath) == normalized(workspace.path) + normalizedDeviceKey(selectedDeviceKey) == normalizedDeviceKey(workspace.deviceKey), + let selectedWorkspace else { return false } + return selectedWorkspace.refersTo(workspace.scope) } var body: some View { @@ -1013,7 +1017,7 @@ private struct SidebarWorkspaceRow: View { .anchorPreference( key: SidebarWorkspaceCreateAnchorKey.self, value: .bounds, - transform: { [workspace.path + ":" + (workspace.remoteConnectionId ?? ""): $0] } + transform: { [workspace.scopeKey: $0] } ) } .padding(.leading, 6) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index d7de2640fc..bb60d6dc3f 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -132,25 +132,28 @@ extension MobileAppModel { agentType: session.agentType, workspacePath: session.workspacePath, workspaceName: session.workspaceName, - workspaceScope: session.workspaceIdentity.map { - MobileWorkspaceScope(path: $0.path, remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost) - }, + workspaceScope: session.workspaceIdentity.map(Self.workspaceScope(of:)), deviceKey: deviceKey, createdAt: session.createdAt, messageCount: Int(session.messageCount) ) } let workspaces = entry.workspaces.map { workspace in - let directory = entry.workspace(path: workspace.path, remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost) - let ownedSessionIDs = Set(entry.sessionsForWorkspace(path: workspace.path, remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost).map { $0.id }) + // ID-first: the directory entry and its sessions are looked up by the row's + // identity, so two same-path workspaces never share state or sessions. + let identity = RemoteWorkspaceIdentity( + path: workspace.path, remoteConnectionId: workspace.remoteConnectionId, + remoteSshHost: workspace.remoteSshHost, workspaceId: workspace.workspaceId + ) + let directory = entry.workspace(workspace: identity) + let ownedSessionIDs = Set(entry.sessionsForWorkspace(workspace: identity).map { $0.id }) + let rowScope = Self.workspaceScope(of: identity) return MobileWorkspaceGroup( + workspaceId: workspace.workspaceId, path: workspace.path, name: workspace.name.isEmpty ? workspace.path : workspace.displayName, selected: remoteExpectedDeviceKey == deviceKey && - workspaceCatalog.contains(where: { - $0.selected && normalizedSessionWorkspacePath($0.path) == normalizedSessionWorkspacePath(workspace.path) && - $0.remoteConnectionId == workspace.remoteConnectionId && $0.remoteSshHost == workspace.remoteSshHost - }), + workspaceCatalog.contains(where: { $0.selected && Self.workspaceScope(of: $0).refersTo(rowScope) }), sessions: sessions.filter { ownedSessionIDs.contains($0.id) }, deviceKey: deviceKey, directoryExpanded: directory?.expanded ?? false, @@ -169,7 +172,7 @@ extension MobileAppModel { sessions: sessions, catalogSource: entry.catalogSource?.name, recentWorkspaces: entry.recentWorkspaces.map { workspace in - MobileWorkspaceGroup(path: workspace.path, name: workspace.displayName, + MobileWorkspaceGroup(workspaceId: workspace.workspaceId, path: workspace.path, name: workspace.displayName, selected: false, sessions: [], deviceKey: deviceKey, remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost) } @@ -197,7 +200,7 @@ extension MobileAppModel { expanded: Bool ) { guard device.online else { return } - coreAdapter?.setDirectoryWorkspaceExpanded(device.id, path: workspace.path, expanded: expanded, connectionId: workspace.remoteConnectionId, sshHost: workspace.remoteSshHost) + coreAdapter?.setDirectoryWorkspaceExpanded(device.id, path: workspace.path, expanded: expanded, connectionId: workspace.remoteConnectionId, sshHost: workspace.remoteSshHost, workspaceId: workspace.workspaceId) } func retryDirectoryWorkspace( @@ -205,7 +208,7 @@ extension MobileAppModel { workspace: MobileWorkspaceGroup ) { guard device.online else { return } - coreAdapter?.retryDirectoryWorkspace(device.id, path: workspace.path, connectionId: workspace.remoteConnectionId, sshHost: workspace.remoteSshHost) + coreAdapter?.retryDirectoryWorkspace(device.id, path: workspace.path, connectionId: workspace.remoteConnectionId, sshHost: workspace.remoteSshHost, workspaceId: workspace.workspaceId) } private func directoryTargetKey(forRawDeviceKey rawDeviceKey: String) -> String { @@ -253,6 +256,7 @@ extension MobileAppModel { normalizedWorkspacePath: normalizedSessionWorkspacePath(workspace.path), remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost, + workspaceId: workspace.workspaceId, agentType: agentType, epoch: epoch, selectionRequested: false @@ -286,7 +290,7 @@ extension MobileAppModel { selectRemoteWorkspace(workspace) return } - pendingDirectoryWorkspace = (deviceKey, workspace.path, remoteTargetEpoch &+ 1, workspace.remoteConnectionId, workspace.remoteSshHost) + pendingDirectoryWorkspace = (deviceKey, workspace.path, remoteTargetEpoch &+ 1, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId) guard targetKey != "pairing", let device = accountDevices.first(where: { $0.id == deviceKey }) else { pendingDirectoryWorkspace = nil @@ -454,9 +458,9 @@ extension MobileAppModel { showToast(localized("远程会话连接已失效,请重新选择设备后重试")) return } - let selected = remoteSidebarWorkspaceState?.selected - if normalizedSessionWorkspacePath(selected?.path ?? "") == pending.normalizedWorkspacePath && - selected?.remoteConnectionId == pending.remoteConnectionId && selected?.remoteSshHost == pending.remoteSshHost { + // ID-first: a pending draft that carries a workspace ID is satisfied only by that ID. + let selectedScope = Self.workspaceScope(of: remoteSidebarWorkspaceState?.selected) + if let selectedScope, selectedScope.refersTo(pending.scope) { guard remoteCreateInteraction.canSubmit else { mobilePerformanceLog.error("Directory create blocked connected=\(self.remoteConnected) switching=\(self.accountBusy) workspaceReady=\(self.remoteCreateWorkspacePhase == .ready) selecting=\(self.workspaceSelectionBusy) submitting=\(self.remoteCreateSubmitting) activeTurn=\(self.activeTurnID != nil) sending=\(self.isSending)") pendingDirectoryRemoteDraft = nil @@ -469,13 +473,13 @@ extension MobileAppModel { createRemoteSession( agentType: pending.agentType, title: "", instruction: "", workspacePath: pending.workspacePath, - remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost + remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost, + workspaceId: pending.workspaceId ) return } guard !pending.selectionRequested else { return } - guard workspaceCatalog.contains(where: { $0.path == pending.workspacePath && - $0.remoteConnectionId == pending.remoteConnectionId && $0.remoteSshHost == pending.remoteSshHost }) else { + guard workspaceCatalog.contains(where: { Self.workspaceScope(of: $0).refersTo(pending.scope) }) else { pendingDirectoryRemoteDraft = nil showToast(localized("暂无可用工作区")) return @@ -487,7 +491,7 @@ extension MobileAppModel { } pending.selectionRequested = true pendingDirectoryRemoteDraft = pending - coreAdapter?.selectRemoteWorkspace(path: pending.workspacePath, remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost) + coreAdapter?.selectRemoteWorkspace(path: pending.workspacePath, remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost, workspaceId: pending.workspaceId) } func resizeRuntimeTerminal(cols: Int, rows: Int) { coreAdapter?.resizeRuntimeTerminal(cols: cols, rows: rows) } @@ -495,8 +499,8 @@ extension MobileAppModel { func selectDeviceToolsPanel(terminal: Bool) { coreAdapter?.selectDeviceToolsPanel(terminal: terminal) } func closeDeviceTools() { coreAdapter?.closeDeviceTools() } func startDeviceToolsTerminal() { coreAdapter?.startDeviceToolsTerminal() } - func openDeviceFiles(_ path: String, connectionId: String?) { coreAdapter?.openDeviceFiles(path, connectionId: connectionId) } - func openDeviceTerminal(_ path: String, connectionId: String?) { coreAdapter?.openDeviceTerminal(path, connectionId: connectionId) } + func openDeviceFiles(_ path: String, connectionId: String?, workspaceId: String? = nil) { coreAdapter?.openDeviceFiles(path, connectionId: connectionId, workspaceId: workspaceId) } + func openDeviceTerminal(_ path: String, connectionId: String?, workspaceId: String? = nil) { coreAdapter?.openDeviceTerminal(path, connectionId: connectionId, workspaceId: workspaceId) } func browseRuntimeDirectories(_ path: String, connectionId: String?, append: Bool = false) { coreAdapter?.browseRuntimeDirectories(path, connectionId: connectionId, append: append) } func sortRuntimeFiles(_ sort: RuntimeFileSort) { coreAdapter?.sortRuntimeFiles(sort) } func closeRuntimeFileEditor() { runtimeFileDraft.reset(); coreAdapter?.closeRuntimeFileEditor() } @@ -549,8 +553,8 @@ extension MobileAppModel { surface = .remote drawerOpen = false workspaceSelectionBusy = true - pendingRemoteSessionRefreshWorkspace = MobileWorkspaceScope(path: normalizedSessionWorkspacePath(workspace.path), remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost) - coreAdapter?.selectRemoteWorkspace(path: workspace.path, remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost) + pendingRemoteSessionRefreshWorkspace = MobileWorkspaceScope(path: normalizedSessionWorkspacePath(workspace.path), remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost, workspaceId: workspace.workspaceId) + coreAdapter?.selectRemoteWorkspace(path: workspace.path, remoteConnectionId: workspace.remoteConnectionId, remoteSshHost: workspace.remoteSshHost, workspaceId: workspace.workspaceId) } func createRemoteSession(in workspace: MobileWorkspaceGroup, agentType: String) { @@ -564,7 +568,8 @@ extension MobileAppModel { instruction: "", workspacePath: workspace.path, remoteConnectionId: workspace.remoteConnectionId, - remoteSshHost: workspace.remoteSshHost + remoteSshHost: workspace.remoteSshHost, + workspaceId: workspace.workspaceId ) } @@ -591,7 +596,8 @@ extension MobileAppModel { func selectRemoteAssistant(_ assistant: MobileAssistantOption) { guard remoteConnected, remoteCreateInteraction.canSelectWorkspace else { return } workspaceSelectionBusy = true - coreAdapter?.selectRemoteAssistant(path: assistant.path) + // With an ID the shared store sends only `workspace_id`; the path is never a fallback. + coreAdapter?.selectRemoteAssistant(path: assistant.path, workspaceId: assistant.workspaceId) } func createRemoteSession( @@ -601,7 +607,8 @@ extension MobileAppModel { modelID: String? = nil, workspacePath: String? = nil, remoteConnectionId: String? = nil, - remoteSshHost: String? = nil + remoteSshHost: String? = nil, + workspaceId: String? = nil ) { guard remoteCreateInteraction.canSubmit else { remoteCreateError = localized("远程会话当前不可创建,请重试") @@ -632,7 +639,8 @@ extension MobileAppModel { modelID: selectedModel, workspacePath: workspacePath, remoteConnectionId: remoteConnectionId, - remoteSshHost: remoteSshHost + remoteSshHost: remoteSshHost, + workspaceId: workspacePath == nil ? nil : workspaceId ) surface = .remote } @@ -689,9 +697,7 @@ extension MobileAppModel { agentType: confirmed.agentType, workspacePath: confirmed.workspacePath, workspaceName: confirmed.workspaceName, - workspaceScope: confirmed.workspaceIdentity.map { - MobileWorkspaceScope(path: $0.path, remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost) - }, + workspaceScope: confirmed.workspaceIdentity.map(Self.workspaceScope(of:)), createdAt: confirmed.createdAt, messageCount: Int(confirmed.messageCount) ) @@ -1006,9 +1012,7 @@ extension MobileAppModel { agentType: session.agentType, workspacePath: session.workspacePath, workspaceName: session.workspaceName, - workspaceScope: session.workspaceIdentity.map { - MobileWorkspaceScope(path: $0.path, remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost) - }, + workspaceScope: session.workspaceIdentity.map(Self.workspaceScope(of:)), createdAt: session.createdAt, messageCount: Int(session.messageCount) ) @@ -1103,8 +1107,8 @@ extension MobileAppModel { remoteConnected, remoteInitialWorkspaceReady { pendingDirectoryWorkspace = nil - pendingRemoteSessionRefreshWorkspace = MobileWorkspaceScope(path: normalizedSessionWorkspacePath(pending.path), remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost) - coreAdapter?.selectRemoteWorkspace(path: pending.path, remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost) + pendingRemoteSessionRefreshWorkspace = MobileWorkspaceScope(path: normalizedSessionWorkspacePath(pending.path), remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost, workspaceId: pending.workspaceId) + coreAdapter?.selectRemoteWorkspace(path: pending.path, remoteConnectionId: pending.remoteConnectionId, remoteSshHost: pending.remoteSshHost, workspaceId: pending.workspaceId) } openPendingDirectorySessionIfReady() advancePendingDirectoryRemoteDraftIfReady() @@ -1205,9 +1209,9 @@ extension MobileAppModel { remoteSidebarWorkspaceState = ready workspaceCatalog = RemoteSidebarPresentation.shared.workspacesForSessions( workspaceState: ready, sessions: [] - ).map { ($0.path, $0.name, $0.selected, $0.remoteConnectionId, $0.remoteSshHost) } + ).map { ($0.path, $0.name, $0.selected, $0.remoteConnectionId, $0.remoteSshHost, $0.workspaceId) } remoteAssistants = ready.assistants.map { - MobileAssistantOption(path: $0.path, name: $0.name) + MobileAssistantOption(path: $0.path, name: $0.name, workspaceId: $0.workspaceId) } rebuildRemoteWorkspaceGroups() @@ -1225,9 +1229,7 @@ extension MobileAppModel { } if !ready.busy, let pendingScope = pendingRemoteSessionRefreshWorkspace { pendingRemoteSessionRefreshWorkspace = nil - if normalizedSessionWorkspacePath(ready.selected?.path ?? "") == pendingScope.path && - ready.selected?.remoteConnectionId == pendingScope.remoteConnectionId && - ready.selected?.remoteSshHost == pendingScope.remoteSshHost { + if let selectedScope = Self.workspaceScope(of: ready.selected), selectedScope.refersTo(pendingScope) { coreAdapter?.refreshRemoteSessions() } } @@ -1241,6 +1243,7 @@ extension MobileAppModel { let projectedWorkspaces = rows.map { workspace in let ownedIDs = Set(workspace.sessions.map { $0.id }) return MobileWorkspaceGroup( + workspaceId: workspace.workspaceId, path: workspace.path, name: workspace.name.isEmpty ? workspace.path : workspace.name, selected: workspace.selected, @@ -1252,6 +1255,27 @@ extension MobileAppModel { setPublishedIfChanged(\.remoteWorkspaces, to: projectedWorkspaces) } + static func workspaceScope(of identity: RemoteWorkspaceIdentity) -> MobileWorkspaceScope { + MobileWorkspaceScope(path: identity.path, remoteConnectionId: identity.remoteConnectionId, + remoteSshHost: identity.remoteSshHost, workspaceId: identity.workspaceId) + } + + static func workspaceScope(of entry: WorkspaceCatalogEntry) -> MobileWorkspaceScope { + MobileWorkspaceScope(path: entry.path, remoteConnectionId: entry.remoteConnectionId, + remoteSshHost: entry.remoteSshHost, workspaceId: entry.workspaceId) + } + + static func workspaceScope(of selected: SelectedWorkspace?) -> MobileWorkspaceScope? { + guard let selected else { return nil } + return MobileWorkspaceScope(path: selected.path, remoteConnectionId: selected.remoteConnectionId, + remoteSshHost: selected.remoteSshHost, workspaceId: selected.workspaceId) + } + + /// The selected workspace as the phone last saw it from the host catalog. + var selectedWorkspaceScope: MobileWorkspaceScope? { + workspaceCatalog.first(where: { $0.selected }).map(Self.workspaceScope(of:)) + } + private func setPublishedIfChanged( _ keyPath: ReferenceWritableKeyPath, to value: Value @@ -1265,7 +1289,12 @@ extension MobileAppModel { var sessionListWorkspaceOptions: [MobileSessionWorkspaceOption] { SessionListPresentation.shared .workspaceOptions(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) - .map { MobileSessionWorkspaceOption(path: $0.path, name: $0.name) } + .map { + MobileSessionWorkspaceOption( + path: $0.path, name: $0.name, workspaceId: $0.workspaceId, + remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost, key: $0.key + ) + } } var sessionListAgentGroups: [String] { @@ -1308,12 +1337,14 @@ extension MobileAppModel { case .chat(let value): return projection(id: "chat", kind: .chat, section: value, byID: byID) case .project(let value): + // Keyed by `workspaceId ?: legacy triple`, so same-path workspaces stay separate sections. return MobileSessionListSectionProjection( - id: "project:\(value.path)", + id: "project:\(value.key)", kind: .project, path: value.path, name: value.name, - sessions: value.sessions.compactMap { byID[$0.id] } + sessions: value.sessions.compactMap { byID[$0.id] }, + workspaceScope: Self.workspaceScope(of: value.identity) ) case .today(let value): return projection(id: "today", kind: .today, section: value, byID: byID) @@ -1338,7 +1369,7 @@ extension MobileAppModel { workspacePath: session.workspacePath, workspaceName: session.workspaceName, workspaceIdentity: session.workspaceScope.map { - RemoteWorkspaceIdentity(path: $0.path, remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost) + RemoteWorkspaceIdentity(path: $0.path, remoteConnectionId: $0.remoteConnectionId, remoteSshHost: $0.remoteSshHost, workspaceId: $0.workspaceId) }, // These rows are already past the visibility filter, so they // have no parent left to declare. @@ -1348,27 +1379,52 @@ extension MobileAppModel { } } + /// The catalog the shared projection groups sessions with. Rows keep their + /// workspace IDs and host-declared kinds; assistants are recognised through + /// their catalog row (ID-first), never through a set of paths. private var sessionListWorkspaceContext: SessionWorkspaceContext { - let assistantPaths = Set(remoteAssistants.map { normalizedSessionWorkspacePath($0.path) }) + if let ready = remoteSidebarWorkspaceState { + let recent = ready.catalog?.workspaces ?? (ready.assistants.map { + RecentWorkspace(path: $0.path, name: $0.name, lastOpened: "", kind: "assistant", remoteSshHost: nil, remoteConnectionId: nil, workspaceId: $0.workspaceId) + } + ready.workspaces) + let selected = ready.selected + return SessionWorkspaceContext( + selectedPath: selected?.path ?? "", + selectedName: selected?.name ?? "", + selectedKind: selected?.kind ?? "", + recent: recent, + selectedWorkspaceId: selected?.workspaceId, + selectedRemoteConnectionId: selected?.remoteConnectionId, + selectedRemoteSshHost: selected?.remoteSshHost + ) + } + // No host catalog yet (cached or preview rows): the projected groups are all there is. let selected = remoteWorkspaces.first(where: \.selected) + func isAssistant(_ workspace: MobileWorkspaceGroup) -> Bool { + remoteAssistants.contains { assistant in + MobileWorkspaceScope(path: assistant.path, remoteConnectionId: nil, remoteSshHost: nil, workspaceId: assistant.workspaceId) + .refersTo(workspace.scope) + } + } let recent = remoteWorkspaces.map { workspace in RecentWorkspace( path: workspace.path, name: workspace.name, lastOpened: "", - kind: assistantPaths.contains(normalizedSessionWorkspacePath(workspace.path)) - ? "assistant" - : "normal" + kind: isAssistant(workspace) ? "assistant" : "normal", + remoteSshHost: workspace.remoteSshHost, + remoteConnectionId: workspace.remoteConnectionId, + workspaceId: workspace.workspaceId ) } - let selectedKind = selected.map { - assistantPaths.contains(normalizedSessionWorkspacePath($0.path)) ? "assistant" : "normal" - } ?? "" return SessionWorkspaceContext( selectedPath: selected?.path ?? "", selectedName: selected?.name ?? "", - selectedKind: selectedKind, - recent: recent + selectedKind: selected.map { isAssistant($0) ? "assistant" : "normal" } ?? "", + recent: recent, + selectedWorkspaceId: selected?.workspaceId, + selectedRemoteConnectionId: selected?.remoteConnectionId, + selectedRemoteSshHost: selected?.remoteSshHost ) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 688cc44122..7729165c8c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -126,10 +126,11 @@ final class MobileAppModel: ObservableObject { var committedRemoteCreate: CommittedRemoteCreate? var remoteLastAppliedAuthority: RemoteAuthorityScope? var remoteSidebarWorkspaceState: RemoteWorkspaceUiStateReady? - var workspaceCatalog: [(path: String, name: String, selected: Bool, remoteConnectionId: String?, remoteSshHost: String?)] = [] + typealias WorkspaceCatalogEntry = (path: String, name: String, selected: Bool, remoteConnectionId: String?, remoteSshHost: String?, workspaceId: String?) + var workspaceCatalog: [WorkspaceCatalogEntry] = [] var pendingRemoteWorkspaceCreate: (path: String, agentType: String)? var pendingRemoteSessionRefreshWorkspace: MobileWorkspaceScope? - var pendingDirectoryWorkspace: (deviceKey: String, path: String, epoch: UInt64, remoteConnectionId: String?, remoteSshHost: String?)? + var pendingDirectoryWorkspace: (deviceKey: String, path: String, epoch: UInt64, remoteConnectionId: String?, remoteSshHost: String?, workspaceId: String?)? var pendingDirectoryRemoteDraft: PendingDirectoryRemoteDraft? var pendingRemoteAssistantCreate = false var selectedRemoteWorkspaceKind = "" diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index a55de0c56c..9cc9e441cf 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -205,18 +205,18 @@ final class MobileCoreAdapter { deviceDirectory.dispatch(intent: DeviceDirectoryIntentRetry(deviceId: deviceID)) } - func setDirectoryWorkspaceExpanded(_ deviceID: String, path: String, expanded: Bool, connectionId: String? = nil, sshHost: String? = nil) { + func setDirectoryWorkspaceExpanded(_ deviceID: String, path: String, expanded: Bool, connectionId: String? = nil, sshHost: String? = nil, workspaceId: String? = nil) { deviceDirectory.dispatch(intent: DeviceDirectoryIntentSetWorkspaceExpanded( deviceId: deviceID, path: path, - expanded: expanded, remoteConnectionId: connectionId, remoteSshHost: sshHost + expanded: expanded, remoteConnectionId: connectionId, remoteSshHost: sshHost, workspaceId: workspaceId )) } - func retryDirectoryWorkspace(_ deviceID: String, path: String, connectionId: String? = nil, sshHost: String? = nil) { + func retryDirectoryWorkspace(_ deviceID: String, path: String, connectionId: String? = nil, sshHost: String? = nil, workspaceId: String? = nil) { deviceDirectory.dispatch(intent: DeviceDirectoryIntentRetryWorkspace( deviceId: deviceID, - path: path, remoteConnectionId: connectionId, remoteSshHost: sshHost + path: path, remoteConnectionId: connectionId, remoteSshHost: sshHost, workspaceId: workspaceId )) } @@ -328,15 +328,18 @@ final class MobileCoreAdapter { modelID: String?, workspacePath: String? = nil, remoteConnectionId: String? = nil, - remoteSshHost: String? = nil + remoteSshHost: String? = nil, + workspaceId: String? = nil ) { guard let remoteSession else { log.error("Remote create unavailable target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") onCreateUnavailable?(requestID, remoteTargetKey) return } - log.info("Dispatching remote create target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") + log.info("Dispatching remote create target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public) by_id=\(workspaceId != nil, privacy: .public)") prepareDirectoryReconcile(requestID: requestID) + // The shared store sends only `workspace_id` when one is present; the legacy + // path projection is used solely for references that never had an ID. remoteSession.dispatch( intent: RemoteSessionIntentCreateSessionOperation( requestId: requestID, @@ -346,7 +349,8 @@ final class MobileCoreAdapter { modelId: modelID, workspacePath: workspacePath, remoteConnectionId: remoteConnectionId, - remoteSshHost: remoteSshHost + remoteSshHost: remoteSshHost, + workspaceId: workspaceId ) ) } @@ -416,8 +420,8 @@ final class MobileCoreAdapter { func selectDeviceToolsPanel(terminal: Bool) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectDeviceToolsPanel(panel: terminal ? .terminal : .files)) } func closeDeviceTools() { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentCloseDeviceTools.shared) } func startDeviceToolsTerminal() { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentStartDeviceToolsTerminal.shared) } - func openDeviceFiles(_ path: String, connectionId: String?) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentOpenDeviceFiles(path: path, remoteConnectionId: connectionId)) } - func openDeviceTerminal(_ path: String, connectionId: String?) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentOpenDeviceTerminal(path: path, remoteConnectionId: connectionId)) } + func openDeviceFiles(_ path: String, connectionId: String?, workspaceId: String? = nil) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentOpenDeviceFiles(path: path, remoteConnectionId: connectionId, workspaceId: workspaceId)) } + func openDeviceTerminal(_ path: String, connectionId: String?, workspaceId: String? = nil) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentOpenDeviceTerminal(path: path, remoteConnectionId: connectionId, workspaceId: workspaceId)) } func browseRuntimeDirectories(_ path: String, connectionId: String?, append: Bool) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentBrowseWorkspaceDirectories(path: path, remoteConnectionId: connectionId, append: append)) } func sortRuntimeFiles(_ sort: RuntimeFileSort) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSortFiles(sort: sort)) } func closeRuntimeFileEditor() { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentCloseFileEditor.shared) } @@ -439,12 +443,12 @@ final class MobileCoreAdapter { func writeRuntimeTerminal(_ data: String) { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentWriteTerminal(data: data)) } func closeRuntimeTerminal() { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentCloseTerminal.shared) } - func selectRemoteWorkspace(path: String, remoteConnectionId: String? = nil, remoteSshHost: String? = nil) { - remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectWorkspace(path: path, remoteConnectionId: remoteConnectionId, remoteSshHost: remoteSshHost)) + func selectRemoteWorkspace(path: String, remoteConnectionId: String? = nil, remoteSshHost: String? = nil, workspaceId: String? = nil) { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectWorkspace(path: path, remoteConnectionId: remoteConnectionId, remoteSshHost: remoteSshHost, inferSavedIdentity: workspaceId == nil, workspaceId: workspaceId)) } - func selectRemoteAssistant(path: String) { - remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectAssistant(path: path)) + func selectRemoteAssistant(path: String, workspaceId: String? = nil) { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectAssistant(path: path, workspaceId: workspaceId)) } func loadRemoteWorkspaces() { diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index 0596648649..17407159b5 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -349,10 +349,41 @@ struct MobilePendingDownload: Identifiable, Equatable { } } +/// A workspace reference as the phone holds it: the stable `workspaceId` when the +/// host assigned one, otherwise the legacy `(connection, ssh host, path)` triple +/// from pre-ID hosts and caches. The path is display text and an IO operand only. struct MobileWorkspaceScope: Equatable { let path: String let remoteConnectionId: String? let remoteSshHost: String? + var workspaceId: String? = nil + + private var normalizedWorkspaceId: String? { + let value = (workspaceId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + /// Stable key: `workspaceId ?: legacy triple`, matching `RemoteWorkspaceIdentity.key`. + var key: String { + if let id = normalizedWorkspaceId { return "workspace:\(id.utf8.count):\(id)" } + return [remoteConnectionId ?? "", remoteSshHost ?? "", Self.normalizedPath(path)] + .map { "\($0.utf8.count):\($0)" }.joined() + } + + /// ID-first equality: when both sides carry a workspace ID only the IDs are + /// compared; when either side predates IDs the legacy triple decides. + func refersTo(_ other: MobileWorkspaceScope) -> Bool { + if let own = normalizedWorkspaceId, let theirs = other.normalizedWorkspaceId { return own == theirs } + return Self.normalizedPath(path) == Self.normalizedPath(other.path) && + (remoteConnectionId ?? "") == (other.remoteConnectionId ?? "") && + (remoteSshHost ?? "") == (other.remoteSshHost ?? "") + } + + static func normalizedPath(_ path: String) -> String { + var value = path.trimmingCharacters(in: .whitespacesAndNewlines) + while value.count > 1 && (value.hasSuffix("/") || value.hasSuffix("\\")) { value.removeLast() } + return value + } } struct ChatSession: Identifiable, Equatable { @@ -385,9 +416,14 @@ struct PendingDirectoryRemoteDraft { let normalizedWorkspacePath: String var remoteConnectionId: String? = nil var remoteSshHost: String? = nil + var workspaceId: String? = nil var agentType: String = "code" let epoch: UInt64 var selectionRequested: Bool + + var scope: MobileWorkspaceScope { + MobileWorkspaceScope(path: workspacePath, remoteConnectionId: remoteConnectionId, remoteSshHost: remoteSshHost, workspaceId: workspaceId) + } } struct MobileAccountDevice: Identifiable, Equatable { @@ -410,7 +446,8 @@ struct MobileDeviceDirectoryEntry: Identifiable, Equatable { } struct MobileWorkspaceGroup: Identifiable, Equatable { - var id: String { [deviceKey ?? "", remoteConnectionId ?? "", remoteSshHost ?? "", path].map { "\($0.utf8.count):\($0)" }.joined() } + var workspaceId: String? = nil + var id: String { if let workspaceId { return [deviceKey ?? "", workspaceId].map { "\($0.utf8.count):\($0)" }.joined() }; return [deviceKey ?? "", remoteConnectionId ?? "", remoteSshHost ?? "", path].map { "\($0.utf8.count):\($0)" }.joined() } let path: String let name: String let selected: Bool @@ -420,6 +457,16 @@ struct MobileWorkspaceGroup: Identifiable, Equatable { var directoryStatus = "IDLE" var remoteConnectionId: String? = nil var remoteSshHost: String? = nil + + var scope: MobileWorkspaceScope { + MobileWorkspaceScope(path: path, remoteConnectionId: remoteConnectionId, remoteSshHost: remoteSshHost, workspaceId: workspaceId) + } + + /// ID-first identity comparison that ignores the device the row is filed under. + func refersTo(_ other: MobileWorkspaceGroup) -> Bool { scope.refersTo(other.scope) } + + /// Device-independent key for per-workspace UI state (create menu anchors, expansion). + var scopeKey: String { scope.key } } enum MobileSessionListSectionKind: Equatable { @@ -436,18 +483,36 @@ struct MobileSessionListSectionProjection: Identifiable { let path: String let name: String let sessions: [ChatSession] + /// Project sections only: the workspace the section stands for, ID-first. + var workspaceScope: MobileWorkspaceScope? = nil } struct MobileSessionWorkspaceOption: Identifiable { - var id: String { path } + /// `workspaceId ?: legacy triple`; two same-path workspaces are two options. + var id: String { key } let path: String let name: String + let workspaceId: String? + let remoteConnectionId: String? + let remoteSshHost: String? + let key: String + + init(path: String, name: String, workspaceId: String? = nil, remoteConnectionId: String? = nil, remoteSshHost: String? = nil, key: String? = nil) { + self.path = path + self.name = name + self.workspaceId = workspaceId + self.remoteConnectionId = remoteConnectionId + self.remoteSshHost = remoteSshHost + self.key = key ?? MobileWorkspaceScope(path: path, remoteConnectionId: remoteConnectionId, remoteSshHost: remoteSshHost, workspaceId: workspaceId).key + } } struct MobileAssistantOption: Identifiable, Equatable { - var id: String { path } + /// The workspace ID when the host assigned one; the path only for pre-ID hosts. + var id: String { workspaceId ?? path } let path: String let name: String + var workspaceId: String? = nil } struct ComposerAttachment: Identifiable, Equatable { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibility.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibility.kt new file mode 100644 index 0000000000..13b233f795 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibility.kt @@ -0,0 +1,92 @@ +package com.openbitfun.mobile.core.domain + +/** + * Outcome of matching one workspace reference against a host catalog. + * + * The cases are deliberately separate: an [UnknownId] is a reference the host no + * longer serves and must surface as an error, never retried through the path it + * happened to carry; [Ambiguous] and [Unresolved] only ever arise for pre-ID + * references and must not pick a row on the caller's behalf. + */ +public sealed interface WorkspaceReferenceResolution { + /** Exactly one catalog row owns the reference. */ + public data class Resolved(public val identity: RemoteWorkspaceIdentity) : WorkspaceReferenceResolution + + /** The reference carried a workspace ID the catalog does not contain. */ + public data class UnknownId(public val workspaceId: String) : WorkspaceReferenceResolution + + /** A pre-ID reference matched more than one catalog row. */ + public data class Ambiguous(public val candidates: List) : WorkspaceReferenceResolution + + /** A pre-ID reference matched no catalog row. */ + public data object Unresolved : WorkspaceReferenceResolution + + public val identityOrNull: RemoteWorkspaceIdentity? + get() = (this as? Resolved)?.identity + + /** True only for [UnknownId]: the caller knows the ID and must not downgrade to a path. */ + public val isUnknownId: Boolean + get() = this is UnknownId +} + +/** + * Upgrade-only conversion for pre-ID mobile caches and host projections. + * Paths are not workspace keys. New state and commands retain workspaceId. + * Remove when all supported caches have migrated and peers negotiate ID references. + * Unknown IDs never fall back to a path; ambiguous old roots stay unresolved. + * + * The legacy matching mirrors the host resolver + * (`src/crates/assembly/core/src/service/workspace/legacy_compat.rs`): blank + * connection and host values are absent, a saved connection id narrows the + * candidates, and an ssh host narrows them too except that a bare `localhost` + * host without a connection id is ignored because 1.0.0 stamped it on local + * workspaces. + */ +public object LegacyWorkspaceCompatibility { + /** Full outcome; callers that must distinguish unknown IDs from legacy misses use this. */ + public fun resolveReference( + reference: RemoteWorkspaceIdentity, + catalog: List, + ): WorkspaceReferenceResolution { + reference.workspaceId?.trim()?.takeIf { it.isNotEmpty() }?.let { id -> return resolveById(id, catalog) } + val root = normalizedRoot(reference.path) + val connectionId = reference.remoteConnectionId?.trim()?.takeIf { it.isNotEmpty() } + val sshHost = reference.remoteSshHost?.trim()?.takeIf { it.isNotEmpty() } + ?.takeIf { host -> host != LOCALHOST || connectionId != null } + val candidates = catalog.filter { candidate -> + normalizedRoot(candidate.path) == root && + (connectionId == null || candidate.remoteConnectionId?.trim() == connectionId) && + (sshHost == null || candidate.remoteSshHost?.trim() == sshHost) + } + return when (candidates.size) { + 0 -> WorkspaceReferenceResolution.Unresolved + 1 -> WorkspaceReferenceResolution.Resolved(candidates.single()) + else -> WorkspaceReferenceResolution.Ambiguous(candidates) + } + } + + /** An explicit ID is authoritative: it resolves to its row or is an error, never a path lookup. */ + public fun resolveById(workspaceId: String, catalog: List): WorkspaceReferenceResolution { + val id = workspaceId.trim() + if (id.isEmpty()) return WorkspaceReferenceResolution.UnknownId(workspaceId) + val matches = catalog.filter { it.workspaceId == id } + return when (matches.size) { + 1 -> WorkspaceReferenceResolution.Resolved(matches.single()) + 0 -> WorkspaceReferenceResolution.UnknownId(id) + // Two catalog rows sharing an ID is a corrupt catalog; refusing is the only safe answer. + else -> WorkspaceReferenceResolution.Ambiguous(matches) + } + } + + /** + * Convenience for callers that only need the match. Unknown IDs, ambiguous + * roots, and misses all read as `null`; use [resolveReference] when the + * difference matters. + */ + public fun resolve(reference: RemoteWorkspaceIdentity, catalog: List): RemoteWorkspaceIdentity? = + resolveReference(reference, catalog).identityOrNull + + private const val LOCALHOST: String = "localhost" + + private fun normalizedRoot(path: String): String = path.trim().let { it.trimEnd('/').ifEmpty { it } } +} diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/RemoteWorkspaceIdentity.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/RemoteWorkspaceIdentity.kt index 293140062d..2afa96c597 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/RemoteWorkspaceIdentity.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/RemoteWorkspaceIdentity.kt @@ -5,26 +5,59 @@ public data class RemoteWorkspaceIdentity public constructor( public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) { - public val key: String get() = listOf(remoteConnectionId.orEmpty(), remoteSshHost.orEmpty(), normalizedPath(path)) + /** A pre-ID reference: caches and hosts that never assigned a workspace ID. */ + public constructor(path: String, remoteConnectionId: String?, remoteSshHost: String?) : this(path, remoteConnectionId, remoteSshHost, null) + + public val key: String get() = workspaceId?.let { "workspace:${it.length}:$it" } ?: legacyKey + + /** Upgrade-only key for caches and peers predating workspace IDs. */ + private val legacyKey: String get() = listOf(remoteConnectionId.orEmpty(), remoteSshHost.orEmpty(), normalizedPath(path)) .joinToString("") { "${it.length}:$it" } public fun matches(other: RemoteWorkspaceIdentity): Boolean = key == other.key + /** + * ID-first equality for UI selection state. When both sides carry a workspace + * ID only the IDs are compared; when either side predates IDs the legacy + * triple decides, so a pre-ID cache row still highlights its live workspace. + */ + public fun sameWorkspace(other: RemoteWorkspaceIdentity): Boolean { + val ownId = workspaceId?.trim()?.takeIf { it.isNotEmpty() } + val otherId = other.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (ownId != null && otherId != null) return ownId == otherId + return legacyKey == other.legacyKey + } + public companion object { private fun normalizedPath(path: String): String = path.trim().let { it.trimEnd('/').ifEmpty { it } } } } -public fun RecentWorkspace.identity(): RemoteWorkspaceIdentity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) +public fun RecentWorkspace.identity(): RemoteWorkspaceIdentity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) -/** Old cache rows have no provenance: only an unambiguous local root can own them. */ +public fun WorkspaceAssistant.identity(): RemoteWorkspaceIdentity = RemoteWorkspaceIdentity(path, null, null, workspaceId) + +public fun SelectedWorkspace.identity(): RemoteWorkspaceIdentity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) + +/** + * Whether this session is filed under [workspace]. + * + * When both sides carry a workspace ID the IDs decide alone. An owned identity + * without an ID goes through [LegacyWorkspaceCompatibility]; an owned ID the + * catalog does not know resolves to nothing rather than to a same-path row. + * Old cache rows have no provenance: only an unambiguous local root can own them. + */ public fun RemoteSession.belongsTo(workspace: RemoteWorkspaceIdentity, catalog: List): Boolean { - workspaceIdentity?.let { return it.matches(workspace) } - if (!workspace.remoteConnectionId.isNullOrEmpty() || !workspace.remoteSshHost.isNullOrEmpty()) return false - val local = RemoteWorkspaceIdentity(workspacePath.orEmpty(), null, null) - return local.matches(workspace) && catalog.none { - RemoteWorkspaceIdentity(it.path, null, null).matches(workspace) && - (!it.remoteConnectionId.isNullOrEmpty() || !it.remoteSshHost.isNullOrEmpty()) + workspaceIdentity?.let { owned -> + val ownedId = owned.workspaceId + val targetId = workspace.workspaceId + if (ownedId != null && targetId != null) return ownedId == targetId + val resolved = LegacyWorkspaceCompatibility.resolveReference(owned, catalog).identityOrNull ?: return false + return resolved.matches(workspace) } + if (!workspace.remoteConnectionId.isNullOrEmpty() || !workspace.remoteSshHost.isNullOrEmpty()) return false + val local = LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity(workspacePath.orEmpty(), null, null), catalog) + return local?.matches(workspace) == true } diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt index 6bc0aa6db6..b1a923a224 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt @@ -7,7 +7,10 @@ public data class RecentWorkspace public constructor( public val kind: String, public val remoteSshHost: String?, public val remoteConnectionId: String?, + public val workspaceId: String?, ) { + public constructor(path: String, name: String, lastOpened: String, kind: String, remoteSshHost: String?, remoteConnectionId: String?) : + this(path, name, lastOpened, kind, remoteSshHost, remoteConnectionId, null) public constructor(path: String, name: String, lastOpened: String, kind: String) : this(path, name, lastOpened, kind, null, null) public constructor(path: String, name: String, lastOpened: String, kind: String, remoteSshHost: String?) : @@ -20,7 +23,10 @@ public data class WorkspaceAssistant public constructor( public val path: String, public val name: String, public val assistantId: String?, -) + public val workspaceId: String?, +) { + public constructor(path: String, name: String, assistantId: String?) : this(path, name, assistantId, null) +} public data class SelectedWorkspace public constructor( public val path: String, @@ -30,7 +36,10 @@ public data class SelectedWorkspace public constructor( public val assistantId: String?, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) { + public constructor(path: String, name: String, gitBranch: String, kind: String, assistantId: String?, remoteConnectionId: String?, remoteSshHost: String?) : + this(path, name, gitBranch, kind, assistantId, remoteConnectionId, remoteSshHost, null) public constructor(path: String, name: String, gitBranch: String, kind: String, assistantId: String?) : this(path, name, gitBranch, kind, assistantId, null, null) } diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceReferencePolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceReferencePolicy.kt new file mode 100644 index 0000000000..93d03578e4 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceReferencePolicy.kt @@ -0,0 +1,19 @@ +package com.openbitfun.mobile.core.domain + +/** + * How a client decides what it may put in a workspace-bearing command. + * + * A reference that knows its workspace ID sends the ID and nothing else, so an + * ID-aware host can never quietly fall back to the path. That requires the host + * to advertise [WORKSPACE_ID_REFERENCES]; a host that does not is an explicit + * unsupported state for such a reference, not a reason to send its path. + * References that never had an ID (pre-ID caches, hand-typed paths) keep using + * the legacy `(path, connection, ssh host)` projection on any host. + */ +public object WorkspaceReferencePolicy { + /** Host capability under which `workspace_id` is honoured on every workspace command. */ + public const val WORKSPACE_ID_REFERENCES: String = "workspace_id_references_v1" + + public fun supportsWorkspaceIdReferences(capabilities: Collection): Boolean = + WORKSPACE_ID_REFERENCES in capabilities +} diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibilityTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibilityTest.kt new file mode 100644 index 0000000000..c23dc2e1ca --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/LegacyWorkspaceCompatibilityTest.kt @@ -0,0 +1,115 @@ +package com.openbitfun.mobile.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LegacyWorkspaceCompatibilityTest { + private val local = RemoteWorkspaceIdentity("/repo", null, null, "local-id") + private val ssh = RemoteWorkspaceIdentity("/repo", "saved", "host", "ssh-id") + private val other = RemoteWorkspaceIdentity("/other", null, null, "other-id") + private val catalog = listOf(local, ssh, other) + + @Test + fun knownIdResolvesToItsRowRegardlessOfTheCarriedPath() { + val stale = RemoteWorkspaceIdentity("/moved", "wrong", "wrong-host", "ssh-id") + assertEquals(WorkspaceReferenceResolution.Resolved(ssh), LegacyWorkspaceCompatibility.resolveReference(stale, catalog)) + assertEquals(ssh, LegacyWorkspaceCompatibility.resolve(stale, catalog)) + } + + @Test + fun unknownIdIsAnErrorStateAndNeverFallsBackToThePath() { + val gone = RemoteWorkspaceIdentity("/repo", null, null, "deleted-id") + val resolution = LegacyWorkspaceCompatibility.resolveReference(gone, listOf(local, other)) + assertEquals(WorkspaceReferenceResolution.UnknownId("deleted-id"), resolution) + assertTrue(resolution.isUnknownId) + assertNull(resolution.identityOrNull) + assertNull(LegacyWorkspaceCompatibility.resolve(gone, listOf(local, other))) + assertEquals(WorkspaceReferenceResolution.UnknownId("deleted-id"), LegacyWorkspaceCompatibility.resolveById("deleted-id", catalog)) + assertIs(LegacyWorkspaceCompatibility.resolveById(" ", catalog)) + } + + @Test + fun legacyMissAndAmbiguityAreDistinctFromUnknownId() { + val miss = LegacyWorkspaceCompatibility.resolveReference(RemoteWorkspaceIdentity("/missing", null, null), catalog) + assertEquals(WorkspaceReferenceResolution.Unresolved, miss) + assertFalse(miss.isUnknownId) + val ambiguous = LegacyWorkspaceCompatibility.resolveReference(RemoteWorkspaceIdentity("/repo/", null, null), catalog) + assertEquals(WorkspaceReferenceResolution.Ambiguous(listOf(local, ssh)), ambiguous) + assertNull(LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", null, null), catalog)) + } + + @Test + fun savedConnectionAndHostNarrowLegacyCandidates() { + assertEquals(ssh, LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", "saved", null), catalog)) + assertEquals(ssh, LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", null, "host"), catalog)) + assertEquals(ssh, LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", " saved ", " host "), catalog)) + assertNull(LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", "unsaved", null), catalog)) + assertNull(LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", "saved", "elsewhere"), catalog)) + // Blank values are absent, as on the host. + assertEquals(WorkspaceReferenceResolution.Ambiguous(listOf(local, ssh)), + LegacyWorkspaceCompatibility.resolveReference(RemoteWorkspaceIdentity("/repo", " ", ""), catalog)) + } + + @Test + fun bareLocalhostHostIsIgnoredOnlyWithoutAConnectionId() { + // 1.0.0 stamped `localhost` on local workspaces: without a connection it says nothing. + assertEquals(local, LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", null, "localhost"), listOf(local, other))) + val localhostSsh = RemoteWorkspaceIdentity("/repo", "loop", "localhost", "loop-id") + val mixed = listOf(local, localhostSsh) + assertEquals(WorkspaceReferenceResolution.Ambiguous(mixed), + LegacyWorkspaceCompatibility.resolveReference(RemoteWorkspaceIdentity("/repo", null, "localhost"), mixed)) + // With a connection id, localhost is a real ssh host and must match. + assertEquals(localhostSsh, LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", "loop", "localhost"), mixed)) + assertNull(LegacyWorkspaceCompatibility.resolve(RemoteWorkspaceIdentity("/repo", "loop", "localhost"), listOf(local, ssh))) + } + + @Test + fun sessionOwnershipComparesIdsOnlyWhenBothSidesHaveThem() { + val session = RemoteSession("s", "Session", "code", "idle", "", "", 0, "/repo", null, + workspaceIdentity = RemoteWorkspaceIdentity("/renamed", null, null, "ssh-id")) + assertTrue(session.belongsTo(ssh, catalog)) + assertFalse(session.belongsTo(local, catalog)) + // The ID decides even when the catalog no longer lists the row. + assertTrue(session.belongsTo(ssh, emptyList())) + // An unknown owned ID never adopts the same-path row. + val orphan = session.copy(workspaceIdentity = RemoteWorkspaceIdentity("/repo", null, null, "deleted-id")) + assertFalse(orphan.belongsTo(RemoteWorkspaceIdentity("/repo", null, null), listOf(RemoteWorkspaceIdentity("/repo", null, null)))) + assertFalse(orphan.belongsTo(local, catalog)) + } + + @Test + fun ownedLegacyIdentityResolvesThroughTheCatalogToAnIdRow() { + val session = RemoteSession("s", "Session", "code", "idle", "", "", 0, "/repo", null, + workspaceIdentity = RemoteWorkspaceIdentity("/repo", "saved", "host")) + assertTrue(session.belongsTo(ssh, catalog)) + assertFalse(session.belongsTo(local, catalog)) + val unscoped = session.copy(workspaceIdentity = RemoteWorkspaceIdentity("/repo", null, null)) + assertFalse(unscoped.belongsTo(local, catalog), "ambiguous legacy roots stay unresolved") + assertTrue(unscoped.belongsTo(local, listOf(local, other))) + } + + @Test + fun sameWorkspaceComparesIdsFirstAndTheTripleOnlyWhenBothLackAnId() { + val byId = RemoteWorkspaceIdentity("/repo", null, null, "ws-1") + assertTrue(byId.sameWorkspace(RemoteWorkspaceIdentity("/moved", "other", "other-host", "ws-1"))) + assertFalse(byId.sameWorkspace(RemoteWorkspaceIdentity("/repo", null, null, "ws-2")), "same path, different ID") + val legacy = RemoteWorkspaceIdentity("/repo", "saved", "host") + // A pre-ID cache row against a live ID-bearing workspace: only the legacy triple is available. + assertFalse(byId.sameWorkspace(legacy), "different triple, so no match") + assertTrue(RemoteWorkspaceIdentity("/repo", "saved", "host", "ws-1").sameWorkspace(legacy)) + assertTrue(legacy.sameWorkspace(RemoteWorkspaceIdentity("/repo/", "saved", "host"))) + assertFalse(legacy.sameWorkspace(RemoteWorkspaceIdentity("/repo", "saved", "elsewhere"))) + assertFalse(legacy.sameWorkspace(RemoteWorkspaceIdentity("/repo", null, null))) + } + + @Test + fun capabilityGateReadsTheHostList() { + assertTrue(WorkspaceReferencePolicy.supportsWorkspaceIdReferences(listOf("dialog_steer_v1", "workspace_id_references_v1"))) + assertFalse(WorkspaceReferencePolicy.supportsWorkspaceIdReferences(listOf("dialog_steer_v1"))) + assertFalse(WorkspaceReferencePolicy.supportsWorkspaceIdReferences(emptyList())) + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt index 6779214731..b86ff1b83c 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt @@ -1,6 +1,7 @@ package com.openbitfun.mobile.core.feature.directory import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.identity import com.openbitfun.mobile.core.domain.belongsTo @@ -47,10 +48,10 @@ public class DeviceDirectoryStore internal constructor( is DeviceDirectoryIntent.Retry -> retry(intent.deviceId) is DeviceDirectoryIntent.SetWorkspaceExpanded -> setWorkspaceExpanded( intent.deviceId, - RemoteWorkspaceIdentity(intent.path, intent.remoteConnectionId, intent.remoteSshHost), + RemoteWorkspaceIdentity(intent.path, intent.remoteConnectionId, intent.remoteSshHost, intent.workspaceId), intent.expanded, ) - is DeviceDirectoryIntent.RetryWorkspace -> retryWorkspace(intent.deviceId, RemoteWorkspaceIdentity(intent.path, intent.remoteConnectionId, intent.remoteSshHost)) + is DeviceDirectoryIntent.RetryWorkspace -> retryWorkspace(intent.deviceId, RemoteWorkspaceIdentity(intent.path, intent.remoteConnectionId, intent.remoteSshHost, intent.workspaceId)) DeviceDirectoryIntent.Stop -> stop() } } @@ -201,18 +202,22 @@ public class DeviceDirectoryStore internal constructor( startLoad(id, slot, entry) } - private fun setWorkspaceExpanded(deviceId: String, identity: RemoteWorkspaceIdentity, expanded: Boolean) { + private fun setWorkspaceExpanded(deviceId: String, reference: RemoteWorkspaceIdentity, expanded: Boolean) { + val identity = LegacyWorkspaceCompatibility.resolve(reference, devices[deviceId]?.workspaces.orEmpty().map { it.identity() }) + ?: run { setEntry(deviceId) { it.copy(status = DeviceDirectoryStatus.FAILED, error = DeviceDirectoryFailure.LOAD_FAILED) }; return } val id = deviceId.trim() val normalizedPath = normalizeWorkspacePath(identity.path) val entry = devices[id] ?: return if (normalizedPath.isEmpty()) return updateWorkspaceState(id, identity) { it.copy(expanded = expanded) } if (!expanded || !entry.online) return - val state = devices[id]?.workspace(identity.path, identity.remoteConnectionId, identity.remoteSshHost) + val state = devices[id]?.workspace(identity) if (state?.status != WorkspaceDirectoryStatus.READY) loadWorkspaceSessions(id, identity, false) } - private fun retryWorkspace(deviceId: String, identity: RemoteWorkspaceIdentity) { + private fun retryWorkspace(deviceId: String, reference: RemoteWorkspaceIdentity) { + val identity = LegacyWorkspaceCompatibility.resolve(reference, devices[deviceId]?.workspaces.orEmpty().map { it.identity() }) + ?: run { setEntry(deviceId) { it.copy(status = DeviceDirectoryStatus.FAILED, error = DeviceDirectoryFailure.LOAD_FAILED) }; return } val id = deviceId.trim() val normalizedPath = normalizeWorkspacePath(identity.path) val entry = devices[id] ?: return @@ -226,7 +231,7 @@ public class DeviceDirectoryStore internal constructor( if (workspaceLoads[key]?.isActive == true) return val entry = devices[deviceId] ?: return if (!entry.online) return - val existing = entry.workspace(identity.path, identity.remoteConnectionId, identity.remoteSshHost) + val existing = entry.workspace(identity) if (!force && existing?.status == WorkspaceDirectoryStatus.READY) return val slot = slotFor(deviceId) ?: run { updateWorkspaceState(deviceId, identity) { it.copy(status = WorkspaceDirectoryStatus.FAILED) } @@ -236,7 +241,7 @@ public class DeviceDirectoryStore internal constructor( updateWorkspaceState(deviceId, identity) { it.copy(status = WorkspaceDirectoryStatus.LOADING) } val job = scope.launch { try { - val loaded = slot.sessionStore.sessionsForWorkspace(identity.path, identity.remoteConnectionId, identity.remoteSshHost) + val loaded = slot.sessionStore.sessionsForWorkspace(identity) if (!isCurrentWorkspace(key, generation) || devices[deviceId]?.online != true) return@launch val current = devices[deviceId] ?: return@launch val merged = replaceWorkspaceSessions(current.sessions, identity, loaded) @@ -383,7 +388,7 @@ public class DeviceDirectoryStore internal constructor( current: List, workspaces: List, ): List = workspaces.map { workspace -> current.firstOrNull { it.identity.matches(workspace.identity()) }?.copy(path = workspace.path) - ?: WorkspaceDirectoryEntry(workspace.path, false, WorkspaceDirectoryStatus.IDLE, workspace.remoteConnectionId, workspace.remoteSshHost) + ?: WorkspaceDirectoryEntry(workspace.path, false, WorkspaceDirectoryStatus.IDLE, workspace.remoteConnectionId, workspace.remoteSshHost, workspace.workspaceId) } private fun updateWorkspaceList( @@ -394,7 +399,7 @@ public class DeviceDirectoryStore internal constructor( val updated = current.map { entry -> if (entry.identity.matches(identity)) { found = true; transform(entry) } else entry }.toMutableList() - if (!found) updated += transform(WorkspaceDirectoryEntry(identity.path, false, WorkspaceDirectoryStatus.IDLE, identity.remoteConnectionId, identity.remoteSshHost)) + if (!found) updated += transform(WorkspaceDirectoryEntry(identity.path, false, WorkspaceDirectoryStatus.IDLE, identity.remoteConnectionId, identity.remoteSshHost, identity.workspaceId)) return updated } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt index f3e0500d4e..f600064140 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt @@ -1,5 +1,6 @@ package com.openbitfun.mobile.core.feature.directory +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.identity import com.openbitfun.mobile.core.domain.belongsTo import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity @@ -41,9 +42,11 @@ public data class WorkspaceDirectoryEntry public constructor( public val status: WorkspaceDirectoryStatus, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) { + public constructor(path: String, expanded: Boolean, status: WorkspaceDirectoryStatus, remoteConnectionId: String?, remoteSshHost: String?) : this(path, expanded, status, remoteConnectionId, remoteSshHost, null) public constructor(path: String, expanded: Boolean, status: WorkspaceDirectoryStatus) : this(path, expanded, status, null, null) - public val identity: RemoteWorkspaceIdentity get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) + public val identity: RemoteWorkspaceIdentity get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) } /** Why a device's directory content cannot be shown. */ @@ -80,18 +83,41 @@ public data class DeviceDirectoryEntry public constructor( public val catalogSource: WorkspaceCatalogSource?, public val recentWorkspaces: List, ) { - public fun sessionsForWorkspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): List { - val identity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) - return sessions.filter { it.belongsTo(identity, workspaces.map { row -> row.identity() }) } + /** + * Sessions filed under [workspace]. An ID resolves by ID alone (an unknown ID + * owns nothing); a pre-ID reference resolves through [LegacyWorkspaceCompatibility] + * and an ambiguous root owns nothing rather than one arbitrary row. + */ + public fun sessionsForWorkspace(workspace: RemoteWorkspaceIdentity): List { + val catalog = workspaces.map { it.identity() } + val identity = LegacyWorkspaceCompatibility.resolve(workspace, catalog) ?: return emptyList() + return sessions.filter { it.belongsTo(identity, catalog) } } - public fun workspace(path: String): WorkspaceDirectoryEntry? = workspace(path, null, null) + /** ID-first lookup; use when the caller holds a workspace ID. */ + public fun sessionsForWorkspaceId(workspaceId: String): List = + sessionsForWorkspace(RemoteWorkspaceIdentity("", null, null, workspaceId)) + + /** Pre-ID lookup for callers that only hold the legacy triple; delegates through the compatibility resolver. */ + public fun sessionsForWorkspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): List = + sessionsForWorkspace(RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost)) - public fun workspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): WorkspaceDirectoryEntry? { - val identity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) + /** Directory entry for [workspace], resolved the same way as [sessionsForWorkspace]. */ + public fun workspace(workspace: RemoteWorkspaceIdentity): WorkspaceDirectoryEntry? { + val identity = LegacyWorkspaceCompatibility.resolve(workspace, workspaces.map { it.identity() }) ?: return null return workspaceDirectory.firstOrNull { it.identity.matches(identity) } } + /** ID-first lookup; use when the caller holds a workspace ID. */ + public fun workspaceById(workspaceId: String): WorkspaceDirectoryEntry? = + workspace(RemoteWorkspaceIdentity("", null, null, workspaceId)) + + public fun workspace(path: String): WorkspaceDirectoryEntry? = workspace(path, null, null) + + /** Pre-ID lookup for callers that only hold the legacy triple; delegates through the compatibility resolver. */ + public fun workspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): WorkspaceDirectoryEntry? = + workspace(RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost)) + public companion object { public fun empty(deviceId: String, deviceName: String, online: Boolean): DeviceDirectoryEntry = DeviceDirectoryEntry( @@ -150,7 +176,9 @@ public sealed interface DeviceDirectoryIntent { public val expanded: Boolean, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) : DeviceDirectoryIntent { + public constructor(deviceId: String, path: String, expanded: Boolean, remoteConnectionId: String?, remoteSshHost: String?) : this(deviceId, path, expanded, remoteConnectionId, remoteSshHost, null) public constructor(deviceId: String, path: String, expanded: Boolean) : this(deviceId, path, expanded, null, null) } @@ -159,7 +187,9 @@ public sealed interface DeviceDirectoryIntent { public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) : DeviceDirectoryIntent { + public constructor(deviceId: String, path: String, remoteConnectionId: String?, remoteSshHost: String?) : this(deviceId, path, remoteConnectionId, remoteSshHost, null) public constructor(deviceId: String, path: String) : this(deviceId, path, null, null) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index ac21906ac9..5a5aaeaf0f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -2,8 +2,12 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.transport.RelayTransportException import com.openbitfun.mobile.core.transport.RelayFailure +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity +import com.openbitfun.mobile.core.domain.WorkspaceReferencePolicy +import com.openbitfun.mobile.core.domain.WorkspaceReferenceResolution import com.openbitfun.mobile.core.domain.belongsTo +import com.openbitfun.mobile.core.domain.identity import com.openbitfun.mobile.core.persistence.PersistedWorkspaceIdentity import com.openbitfun.mobile.core.feature.relay.HostCatalogNotice @@ -162,10 +166,17 @@ public class RemoteSessionStore internal constructor( * missing, so it is resolved before either is sent rather than passed down * from the UI — the same shape as `RemoteSessionManager.workspace`. */ + private var workspaceId: String? = null private var workspaceConnectionId: String? = null private var workspaceSshHost: String? = null private var workspacePath: String = "" private var hostCapabilities: List = emptyList() + /** True once a live `get_workspace_info` answered; capabilities are never assumed from cache. */ + private var hostCapabilitiesKnown: Boolean = false + + /** Whether the connected host honours ID-only workspace commands; null until asked. */ + public val supportsWorkspaceIdReferences: Boolean? + get() = if (hostCapabilitiesKnown) WorkspaceReferencePolicy.supportsWorkspaceIdReferences(hostCapabilities) else null public fun dispatch(intent: RemoteSessionIntent) { val current = _state.value as? RemoteSessionUiState.Ready @@ -178,8 +189,8 @@ public class RemoteSessionStore internal constructor( load(current?.query.orEmpty(), current?.agentFilter ?: SessionAgentFilter.ALL) RemoteSessionIntent.LoadMore -> loadMore() RemoteSessionIntent.LoadOlderMessages -> loadOlderMessages() - is RemoteSessionIntent.LoadWorkspaceSessions -> loadWorkspaceSessions(intent.path, false, intent.remoteConnectionId, intent.remoteSshHost) - is RemoteSessionIntent.RetryWorkspaceSessions -> loadWorkspaceSessions(intent.path, true, intent.remoteConnectionId, intent.remoteSshHost) + is RemoteSessionIntent.LoadWorkspaceSessions -> loadWorkspaceSessions(intent.path, false, intent.remoteConnectionId, intent.remoteSshHost, intent.workspaceId) + is RemoteSessionIntent.RetryWorkspaceSessions -> loadWorkspaceSessions(intent.path, true, intent.remoteConnectionId, intent.remoteSshHost, intent.workspaceId) is RemoteSessionIntent.Search -> load(intent.query, current?.agentFilter ?: SessionAgentFilter.ALL) is RemoteSessionIntent.SetAgentFilter -> load(current?.query.orEmpty(), intent.filter) @@ -187,7 +198,7 @@ public class RemoteSessionStore internal constructor( is RemoteSessionIntent.CreateSession -> createSession(intent, nextRequestId()) is RemoteSessionIntent.CreateSessionOperation -> createSession( RemoteSessionIntent.CreateSession( - intent.agentType, intent.title, intent.instruction, intent.modelId, intent.workspacePath, intent.remoteConnectionId, intent.remoteSshHost, + intent.agentType, intent.title, intent.instruction, intent.modelId, intent.workspacePath, intent.remoteConnectionId, intent.remoteSshHost, intent.workspaceId, ), intent.requestId.trim().ifEmpty { nextRequestId() }, ) @@ -267,16 +278,38 @@ public class RemoteSessionStore internal constructor( title: String, instruction: String, modelId: String?, + ) { + createAssistantSession(workspaceStore, requestId, assistantPath, title, instruction, modelId, null) + } + + /** + * Validates the assistant on this target and creates in its explicit workspace. + * Session creation does not change the runtime's current workspace. + * + * With [assistantWorkspaceId] the assistant is matched by ID alone and the + * create carries only that ID. Without one, [assistantPath] is a pre-ID + * reference resolved through [LegacyWorkspaceCompatibility]; an ambiguous + * path fails rather than picking an assistant. + */ + public fun createAssistantSession( + workspaceStore: RemoteWorkspaceStore, + requestId: String, + assistantPath: String, + title: String, + instruction: String, + modelId: String?, + assistantWorkspaceId: String?, ) { val normalizedRequestId = requestId.trim().ifEmpty { nextRequestId() } val normalizedPath = assistantPath.trim() + val normalizedWorkspaceId = assistantWorkspaceId?.trim()?.takeIf { it.isNotEmpty() } if (workspaceStore.deviceKey == null || workspaceStore.deviceKey != deviceKey) { _createOperation.value = CreateSessionOperationState.Failed( normalizedRequestId, CreateSessionOperationFailure.DEVICE_MISMATCH, false, false, ) return } - if (normalizedPath.isEmpty()) { + if (normalizedPath.isEmpty() && normalizedWorkspaceId == null) { _createOperation.value = CreateSessionOperationState.Failed( normalizedRequestId, CreateSessionOperationFailure.WORKSPACE, true, false, ) @@ -315,9 +348,18 @@ public class RemoteSessionStore internal constructor( failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) return@launch } - if (beforeSelection.assistants.none { it.path == normalizedPath }) { - failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, false, false) - return@launch + val assistantCatalog = beforeSelection.assistants.map { it.identity() } + val reference = RemoteWorkspaceIdentity(normalizedPath, null, null, normalizedWorkspaceId) + val assistant = when (val resolution = LegacyWorkspaceCompatibility.resolveReference(reference, assistantCatalog)) { + is WorkspaceReferenceResolution.Resolved -> resolution.identity + is WorkspaceReferenceResolution.UnknownId -> { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE_ID_UNKNOWN, false, false) + return@launch + } + is WorkspaceReferenceResolution.Ambiguous, WorkspaceReferenceResolution.Unresolved -> { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, false, false) + return@launch + } } work = null createSession( @@ -326,7 +368,10 @@ public class RemoteSessionStore internal constructor( title = title, instruction = instruction, modelId = modelId, - workspacePath = normalizedPath, + workspacePath = assistant.path, + remoteConnectionId = null, + remoteSshHost = null, + workspaceId = assistant.workspaceId, ), normalizedRequestId, ) @@ -360,16 +405,16 @@ public class RemoteSessionStore internal constructor( } /** Loads one disclosed workspace without changing the desktop's active workspace. */ - internal suspend fun sessionsForWorkspace(path: String, remoteConnectionId: String? = null, remoteSshHost: String? = null): List { - val identity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) - val normalizedPath = path.trim() + internal suspend fun sessionsForWorkspace(identity: RemoteWorkspaceIdentity): List { + val normalizedPath = identity.path.trim() if (normalizedPath.isEmpty()) return emptyList() val response = transport.send( RemoteCommand( cmd = "list_sessions", - workspacePath = normalizedPath, - remoteConnectionId = remoteConnectionId, - remoteSshHost = remoteSshHost, + workspaceId = identity.workspaceId, + workspacePath = normalizedPath.takeIf { identity.workspaceId == null }, + remoteConnectionId = identity.remoteConnectionId.takeIf { identity.workspaceId == null }, + remoteSshHost = identity.remoteSshHost.takeIf { identity.workspaceId == null }, limit = DIRECTORY_WORKSPACE_PAGE_SIZE, offset = 0, ), @@ -378,7 +423,7 @@ public class RemoteSessionStore internal constructor( .map(RemoteResponseMapper::session) .filter { SessionListVisibility.isMobileVisible(it) } .map { session -> - session.copy(workspacePath = session.workspacePath?.takeIf { it.isNotBlank() } ?: normalizedPath, workspaceIdentity = identity) + session.copy(workspacePath = session.workspacePath?.takeIf { it.isNotBlank() } ?: normalizedPath, workspaceIdentity = session.workspaceIdentity ?: identity) } val serverIds = server.mapTo(mutableSetOf()) { it.id } serverIds.forEach(locallyCreatedSessions::remove) @@ -393,13 +438,16 @@ public class RemoteSessionStore internal constructor( savePersistedSessions(sessions, false) } - private fun loadWorkspaceSessions(path: String, force: Boolean, remoteConnectionId: String?, remoteSshHost: String?) { + private fun loadWorkspaceSessions(path: String, force: Boolean, remoteConnectionId: String?, remoteSshHost: String?, workspaceId: String?) { val normalizedPath = normalizeWorkspacePath(path) - if (normalizedPath.isEmpty()) return - val identity = RemoteWorkspaceIdentity(normalizedPath, remoteConnectionId, remoteSshHost) + val normalizedId = workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (normalizedPath.isEmpty() && normalizedId == null) return + // With an ID the legacy fields are display facts only; sessionsForWorkspace + // keeps them off the wire. + val identity = RemoteWorkspaceIdentity(normalizedPath, remoteConnectionId, remoteSshHost, normalizedId) val key = identity.key if (workspaceDirectoryJobs[key]?.isActive == true) return - val existing = _workspaceDirectory.value.workspace(normalizedPath, remoteConnectionId, remoteSshHost) + val existing = _workspaceDirectory.value.workspace(identity) if (!force && existing?.status == WorkspaceSessionDirectoryStatus.READY) return val generation = (workspaceDirectoryGenerations[key] ?: 0L) + 1L workspaceDirectoryGenerations[key] = generation @@ -408,7 +456,15 @@ public class RemoteSessionStore internal constructor( } val job = scope.launch { try { - val loaded = sessionsForWorkspace(normalizedPath, remoteConnectionId, remoteSshHost) + if (normalizedId != null && !ensureWorkspaceIdReferences()) { + if (workspaceDirectoryGenerations[key] == generation) { + updateWorkspaceDirectory(identity) { + it.copy(status = WorkspaceSessionDirectoryStatus.UNSUPPORTED) + } + } + return@launch + } + val loaded = sessionsForWorkspace(identity) if (workspaceDirectoryGenerations[key] != generation) return@launch if (persistenceEnabled) { val cached = cachedSessions() @@ -452,7 +508,7 @@ public class RemoteSessionStore internal constructor( }.toMutableList() if (!found) { entries += transform( - WorkspaceSessionDirectoryEntry(identity.path, WorkspaceSessionDirectoryStatus.IDLE, emptyList(), identity.remoteConnectionId, identity.remoteSshHost), + WorkspaceSessionDirectoryEntry(identity.path, WorkspaceSessionDirectoryStatus.IDLE, emptyList(), identity.remoteConnectionId, identity.remoteSshHost, identity.workspaceId), ) } _workspaceDirectory.value = WorkspaceSessionDirectoryUiState(entries) @@ -566,6 +622,8 @@ public class RemoteSessionStore internal constructor( }, ) sessionUpdates?.cancel() + // The next connection may reach a different build; ask it again. + hostCapabilitiesKnown = false transcriptWrite?.cancel() _connectionPhase.value = ConnectionPhase.DISCONNECTED } @@ -601,6 +659,12 @@ public class RemoteSessionStore internal constructor( failKnown(RemoteSessionFailureReason.NO_WORKSPACE, current) return@launch } + if (workspaceId != null && supportsWorkspaceIdReferences != true) { + // The host named its workspace by ID but will not accept the ID + // back; listing by path could answer for a same-path workspace. + failKnown(RemoteSessionFailureReason.WORKSPACE_ID_UNSUPPORTED, current) + return@launch + } // Catalog enrichment must not gate navigation or transcript loading. // Keep it a child of this load so replacing/stopping the load cancels // it, but publish the authoritative session page before awaiting it. @@ -688,15 +752,37 @@ public class RemoteSessionStore internal constructor( val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) if (!isCurrentWork(operationToken)) return false workspacePath = (info.path ?: info.workspacePath).orEmpty().trim() + workspaceId = info.workspaceId workspaceConnectionId = info.remoteConnectionId workspaceSshHost = info.remoteSshHost - hostCapabilities = info.capabilities + recordHostCapabilities(info.capabilities) return workspacePath.isNotEmpty() && workspacePath != "/" } + private fun recordHostCapabilities(capabilities: List) { + hostCapabilities = capabilities + hostCapabilitiesKnown = true + } + + /** + * Whether an ID-bearing command may be sent to this host. + * + * The answer comes from the host's live capability list, fetched once per + * connection when nothing has read it yet. A host without + * `workspace_id_references_v1` gets no command at all for such a reference: + * sending the path instead would let it pick a same-path workspace. + */ + private suspend fun ensureWorkspaceIdReferences(): Boolean { + if (!hostCapabilitiesKnown) { + val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) + recordHostCapabilities(info.capabilities) + } + return WorkspaceReferencePolicy.supportsWorkspaceIdReferences(hostCapabilities) + } + private suspend fun listSessions(offset: Int, query: String, filter: SessionAgentFilter, count: Int = PAGE_SIZE): SessionPage { val trimmedQuery = query.trim() - val identity = RemoteWorkspaceIdentity(workspacePath, workspaceConnectionId, workspaceSshHost) + val identity = RemoteWorkspaceIdentity(workspacePath, workspaceConnectionId, workspaceSshHost, workspaceId) // `list_sessions` cannot apply either the mobile ACP visibility rule or // the agent tab. Pull from the start until there are enough visible rows // so an invisible server row never creates a short page or a dishonest @@ -708,7 +794,7 @@ public class RemoteSessionStore internal constructor( val pageSize = if (filter == SessionAgentFilter.ALL) PAGE_SIZE else FILTER_PAGE_SIZE while (hasMore && filtered.size < targetCount) { val response = sendListSessions(pageSize, pageOffset, trimmedQuery, identity) - val sessions = response.sessions.map(RemoteResponseMapper::session).map { it.copy(workspaceIdentity = identity) } + val sessions = response.sessions.map(RemoteResponseMapper::session).map { it.copy(workspaceIdentity = it.workspaceIdentity ?: identity) } sessions.filterTo(filtered) { SessionListVisibility.isMobileVisible(it) && filter.matches(it.agentType) } @@ -784,18 +870,22 @@ public class RemoteSessionStore internal constructor( val failure: ModelCatalogFailure?, ) - private suspend fun sendListSessions(limit: Int, offset: Int, query: String, identity: RemoteWorkspaceIdentity): SessionListResponse = - transport.send( + /** With an ID the command carries only the ID; the legacy projection goes only when there is none. */ + private suspend fun sendListSessions(limit: Int, offset: Int, query: String, identity: RemoteWorkspaceIdentity): SessionListResponse { + val legacy = identity.workspaceId == null + return transport.send( RemoteCommand( cmd = "list_sessions", - workspacePath = identity.path, - remoteConnectionId = identity.remoteConnectionId, - remoteSshHost = identity.remoteSshHost, + workspaceId = identity.workspaceId, + workspacePath = identity.path.takeIf { legacy }, + remoteConnectionId = identity.remoteConnectionId.takeIf { legacy }, + remoteSshHost = identity.remoteSshHost.takeIf { legacy }, limit = limit, offset = offset.toLong(), query = query.takeIf(String::isNotEmpty), ), ) + } private fun open(sessionId: String) { val normalized = sessionId.trim() @@ -1129,18 +1219,34 @@ public class RemoteSessionStore internal constructor( work = scope.launch { try { val requestedWorkspacePath = intent.workspacePath?.trim().orEmpty() - // An explicit path is the cross-workspace sidebar flow: creating - // there must not change the desktop's active workspace. The - // ordinary create flow still re-reads the active path so a - // recent workspace selection cannot race a cached value. + val requestedWorkspaceId = intent.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + // An explicit workspace (by ID, or by path for pre-ID rows) is the + // cross-workspace sidebar flow: creating there must not change the + // desktop's active workspace. The ordinary create flow still + // re-reads the active workspace so a recent selection cannot race + // a cached value. val assistantCreate = intent.agentType.equals("Claw", ignoreCase = true) - val workspaceResolved = assistantCreate || requestedWorkspacePath.isNotEmpty() || resolveWorkspacePath(operationToken) + val explicitWorkspace = requestedWorkspaceId != null || requestedWorkspacePath.isNotEmpty() + val workspaceResolved = assistantCreate || explicitWorkspace || resolveWorkspacePath(operationToken) if (!isCurrentWork(operationToken)) return@launch if (!workspaceResolved) { failCreate(requestId, generation, CreateSessionOperationFailure.WORKSPACE, retryable = true, unsupported = false) failKnown(RemoteSessionFailureReason.NO_WORKSPACE, current) return@launch } + // The ID that will be sent: the caller's, or the active workspace's + // when the host named it by ID. A known ID is never downgraded to + // its path, so a host that cannot take IDs ends the create here. + val targetWorkspaceId = requestedWorkspaceId + ?: workspaceId.takeIf { !assistantCreate && !explicitWorkspace } + if (targetWorkspaceId != null && !ensureWorkspaceIdReferences()) { + if (!isCurrentWork(operationToken)) return@launch + failCreate(requestId, generation, CreateSessionOperationFailure.WORKSPACE_ID_UNSUPPORTED, retryable = false, unsupported = true) + failKnown(RemoteSessionFailureReason.WORKSPACE_ID_UNSUPPORTED, current) + return@launch + } + if (!isCurrentWork(operationToken)) return@launch + val legacyWorkspace = targetWorkspaceId == null val targetWorkspacePath = requestedWorkspacePath.ifEmpty { if (assistantCreate) "" else workspacePath }.takeIf { it.isNotEmpty() } val targetConnectionId = if (assistantCreate && requestedWorkspacePath.isEmpty()) null else (if (requestedWorkspacePath.isNotEmpty()) intent.remoteConnectionId else workspaceConnectionId).orEmpty() @@ -1151,9 +1257,10 @@ public class RemoteSessionStore internal constructor( cmd = "create_session", agentType = intent.agentType, sessionName = SessionNaming.wireSessionName(intent.agentType, intent.title), - workspacePath = targetWorkspacePath, - remoteConnectionId = targetConnectionId, - remoteSshHost = targetSshHost, + workspaceId = targetWorkspaceId, + workspacePath = targetWorkspacePath.takeIf { legacyWorkspace }, + remoteConnectionId = targetConnectionId.takeIf { legacyWorkspace }, + remoteSshHost = targetSshHost.takeIf { legacyWorkspace }, ), ) val sessionId = created.resolvedSessionId?.trim().orEmpty() @@ -1167,11 +1274,16 @@ public class RemoteSessionStore internal constructor( // cannot turn an already-created remote session into a failed create. val now = Clock.System.now().toString() val confirmedPath = created.workspacePath ?: targetWorkspacePath - val confirmedIdentity = confirmedPath?.takeIf(String::isNotBlank)?.let { path -> - RemoteWorkspaceIdentity(path, + // The host's answer is authoritative for the created session's + // workspace: its ID when it gives one, else the ID that was asked + // for, else the legacy projection that was sent. + val confirmedWorkspaceId = created.workspaceId?.trim()?.takeIf { it.isNotEmpty() } ?: targetWorkspaceId + val confirmedIdentity = if (confirmedPath?.isNotBlank() == true || confirmedWorkspaceId != null) { + RemoteWorkspaceIdentity(confirmedPath.orEmpty(), if (created.workspacePath != null) created.remoteConnectionId else targetConnectionId, - if (created.workspacePath != null) created.remoteSshHost else targetSshHost) - } + if (created.workspacePath != null) created.remoteSshHost else targetSshHost, + confirmedWorkspaceId) + } else null val confirmedSession = RemoteSession( id = sessionId, title = created.title?.takeIf(String::isNotBlank) @@ -1289,6 +1401,8 @@ public class RemoteSessionStore internal constructor( val reason = when (remoteSessionFailure(error).reason) { RemoteSessionFailureReason.PROTOCOL_MISMATCH -> CreateSessionOperationFailure.UNSUPPORTED RemoteSessionFailureReason.NO_WORKSPACE -> CreateSessionOperationFailure.WORKSPACE + RemoteSessionFailureReason.WORKSPACE_ID_UNSUPPORTED -> CreateSessionOperationFailure.WORKSPACE_ID_UNSUPPORTED + RemoteSessionFailureReason.WORKSPACE_ID_UNKNOWN -> CreateSessionOperationFailure.WORKSPACE_ID_UNKNOWN RemoteSessionFailureReason.NETWORK, RemoteSessionFailureReason.TIMEOUT, RemoteSessionFailureReason.TRANSPORT, RemoteSessionFailureReason.RATE_LIMITED, RemoteSessionFailureReason.REMOTE_REJECTED, RemoteSessionFailureReason.SESSION_NOT_FOUND -> @@ -1819,7 +1933,7 @@ public class RemoteSessionStore internal constructor( id = s.sessionId, title = s.title, agentType = s.agentType, status = s.status, updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, workspacePath = s.workspacePath, workspaceName = s.workspaceName, - workspaceIdentity = s.workspaceIdentity?.let { RemoteWorkspaceIdentity(it.path, it.remoteConnectionId, it.remoteSshHost) }, + workspaceIdentity = s.workspaceIdentity?.let { RemoteWorkspaceIdentity(it.path, it.remoteConnectionId, it.remoteSshHost, it.workspaceId) }, ) private fun toPersistedSession(s: RemoteSession): PersistedRemoteSession = PersistedRemoteSession( @@ -1827,7 +1941,7 @@ public class RemoteSessionStore internal constructor( updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, lastMessageId = "", workspacePath = s.workspacePath, workspaceName = s.workspaceName, pendingConfirmed = s.id in locallyCreatedSessions, - workspaceIdentity = s.workspaceIdentity?.let { PersistedWorkspaceIdentity(it.path, it.remoteConnectionId, it.remoteSshHost) }, + workspaceIdentity = s.workspaceIdentity?.let { PersistedWorkspaceIdentity(it.path, it.remoteConnectionId, it.remoteSshHost, it.workspaceId) }, ) @@ -1981,8 +2095,9 @@ internal object RemoteResponseMapper { messageCount = item.messageCount ?: 0, workspacePath = item.workspacePath, workspaceName = item.workspaceName, - // Callers attach the identity of the workspace they listed under. - workspaceIdentity = null, + // A host that names the owning record binds the session by ID; older + // hosts leave it unset and callers attach the identity they listed under. + workspaceIdentity = item.workspaceId?.let { RemoteWorkspaceIdentity(item.workspacePath.orEmpty(), null, null, it) }, parentSessionId = item.parentSessionId, relationshipKind = item.relationshipKind, ) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt index 02623b59d0..c86e13ba7f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -1,6 +1,7 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.domain.ChatTimelineState +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.RemoteSession import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity import com.openbitfun.mobile.core.domain.SessionAgentTypes @@ -99,6 +100,16 @@ public enum class CreateSessionOperationFailure { DEVICE_MISMATCH, PROTOCOL, CANCELLED, + + /** + * The request named a workspace by ID but the connected host does not + * advertise `workspace_id_references_v1`. The ID is kept and nothing is sent: + * downgrading to the path would let the host pick a same-path workspace. + */ + WORKSPACE_ID_UNSUPPORTED, + + /** The request named a workspace ID this host does not serve. */ + WORKSPACE_ID_UNKNOWN, } /** Independent backward-page request state; never blocks live generation. */ @@ -151,6 +162,16 @@ public enum class RemoteSessionFailureReason { /** The relay is throttling this client. */ RATE_LIMITED, + + /** + * The command named a workspace by ID but the connected host does not + * advertise `workspace_id_references_v1`. Nothing was sent; the path is + * never substituted for an ID the client knows. + */ + WORKSPACE_ID_UNSUPPORTED, + + /** The host does not serve the workspace ID the command named. */ + WORKSPACE_ID_UNKNOWN, } public enum class WorkspaceSessionDirectoryStatus { @@ -158,6 +179,13 @@ public enum class WorkspaceSessionDirectoryStatus { LOADING, READY, FAILED, + + /** + * The branch is keyed by a workspace ID the connected host cannot address + * (it lacks `workspace_id_references_v1` or no longer serves the ID). The + * cached sessions stay visible; loading by path is not attempted. + */ + UNSUPPORTED, } /** One independently loaded workspace branch in a live remote session store. */ @@ -167,11 +195,15 @@ public data class WorkspaceSessionDirectoryEntry public constructor( public val sessions: List, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) { + public constructor(path: String, status: WorkspaceSessionDirectoryStatus, sessions: List, remoteConnectionId: String?, remoteSshHost: String?) : + this(path, status, sessions, remoteConnectionId, remoteSshHost, null) + public constructor(path: String, status: WorkspaceSessionDirectoryStatus, sessions: List) : this(path, status, sessions, null, null) - public val identity: RemoteWorkspaceIdentity get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) + public val identity: RemoteWorkspaceIdentity get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) } public data class WorkspaceSessionDirectoryUiState public constructor( @@ -179,10 +211,23 @@ public data class WorkspaceSessionDirectoryUiState public constructor( ) { public fun workspace(path: String): WorkspaceSessionDirectoryEntry? = workspace(path, null, null) - public fun workspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): WorkspaceSessionDirectoryEntry? { - val identity = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost) - return workspaces.firstOrNull { it.identity.matches(identity) } + /** + * Legacy lookup: the reference has no ID, so it is matched through + * [LegacyWorkspaceCompatibility] against the loaded branches. Ambiguous + * paths resolve to nothing rather than to the first branch. + */ + public fun workspace(path: String, remoteConnectionId: String?, remoteSshHost: String?): WorkspaceSessionDirectoryEntry? = + workspace(RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost)) + + /** ID-first lookup: an identity with a workspace ID matches on the ID alone. */ + public fun workspace(identity: RemoteWorkspaceIdentity): WorkspaceSessionDirectoryEntry? { + identity.workspaceId?.let { id -> return workspaces.firstOrNull { it.workspaceId == id } } + val resolved = LegacyWorkspaceCompatibility.resolve(identity, workspaces.map { it.identity }) ?: return null + return workspaces.firstOrNull { it.identity.matches(resolved) } } + + public fun workspaceById(workspaceId: String): WorkspaceSessionDirectoryEntry? = + workspaces.firstOrNull { it.workspaceId == workspaceId } } @@ -362,11 +407,18 @@ public sealed interface RemoteSessionIntent { public data object LoadOlderMessages : RemoteSessionIntent /** Load one sidebar workspace branch without changing the desktop's active workspace. */ + /** + * Load one workspace's sessions for the directory tree. With a [workspaceId] + * only the ID is sent; the legacy fields are used only for rows that never + * had an ID. + */ public data class LoadWorkspaceSessions public constructor( public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) : RemoteSessionIntent { + public constructor(path: String, remoteConnectionId: String?, remoteSshHost: String?) : this(path, remoteConnectionId, remoteSshHost, null) public constructor(path: String) : this(path, null, null) } @@ -374,7 +426,9 @@ public sealed interface RemoteSessionIntent { public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) : RemoteSessionIntent { + public constructor(path: String, remoteConnectionId: String?, remoteSshHost: String?) : this(path, remoteConnectionId, remoteSshHost, null) public constructor(path: String) : this(path, null, null) } @@ -405,7 +459,14 @@ public sealed interface RemoteSessionIntent { public val workspacePath: String?, public val remoteConnectionId: String?, public val remoteSshHost: String?, + /** + * Target workspace by ID. When set, `create_session` carries only the ID; + * [workspacePath] then only labels the in-flight operation. Null keeps + * the legacy path projection for rows that never had an ID. + */ + public val workspaceId: String?, ) : RemoteSessionIntent { + public constructor(agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?, remoteConnectionId: String?, remoteSshHost: String?) : this(agentType, title, instruction, modelId, workspacePath, remoteConnectionId, remoteSshHost, null) public constructor(agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?, remoteConnectionId: String?) : this(agentType, title, instruction, modelId, workspacePath, remoteConnectionId, null) public constructor(agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?) : this(agentType, title, instruction, modelId, workspacePath, null) public constructor( @@ -428,7 +489,9 @@ public sealed interface RemoteSessionIntent { public val workspacePath: String?, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, ) : RemoteSessionIntent { + public constructor(requestId: String, agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?, remoteConnectionId: String?, remoteSshHost: String?) : this(requestId, agentType, title, instruction, modelId, workspacePath, remoteConnectionId, remoteSshHost, null) public constructor(requestId: String, agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?, remoteConnectionId: String?) : this(requestId, agentType, title, instruction, modelId, workspacePath, remoteConnectionId, null) public constructor(requestId: String, agentType: String, title: String, instruction: String, modelId: String?, workspacePath: String?) : this(requestId, agentType, title, instruction, modelId, workspacePath, null) public constructor( diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentation.kt index e2f9e43813..b9840c311c 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentation.kt @@ -1,9 +1,12 @@ package com.openbitfun.mobile.core.feature.session +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.RecentWorkspace import com.openbitfun.mobile.core.domain.RemoteSession +import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity import com.openbitfun.mobile.core.domain.SessionAgentTypes import com.openbitfun.mobile.core.domain.SessionWorkspacePaths +import com.openbitfun.mobile.core.domain.identity import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant @@ -32,11 +35,28 @@ public enum class SessionAgentGroup { CHAT, CODE, COWORK } */ public enum class SessionStatusLabel { RUNNING, READY, ARCHIVED, RAW } -/** A workspace the list can be filtered down to. */ +/** + * A workspace the list can be filtered down to. + * + * [key] is the value a filter stores: the workspace ID when the host gave one, + * otherwise the legacy `(connection, ssh host, path)` triple. [path] is display + * text and the upgrade fallback for filters persisted before IDs existed. + */ public data class SessionWorkspaceOption public constructor( public val path: String, public val name: String, -) + public val workspaceId: String?, + public val remoteConnectionId: String?, + public val remoteSshHost: String?, +) { + public constructor(path: String, name: String) : this(path, name, null, null, null) + + public val identity: RemoteWorkspaceIdentity + get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) + + /** Stable filter and section key: `workspaceId ?: legacy triple`. */ + public val key: String get() = identity.key +} /** * Where the list is being shown from. @@ -44,14 +64,30 @@ public data class SessionWorkspaceOption public constructor( * The selected workspace is not just another entry: it is the fallback for * sessions the desktop sent no `workspacePath` for, so a session created before * the desktop started reporting paths still lands in the project the user is - * looking at. + * looking at. Its identity fields let sessions that carry a workspace ID be + * matched by ID alone; pre-ID hosts leave them null. */ public data class SessionWorkspaceContext public constructor( public val selectedPath: String, public val selectedName: String, public val selectedKind: String, public val recent: List, -) + public val selectedWorkspaceId: String?, + public val selectedRemoteConnectionId: String?, + public val selectedRemoteSshHost: String?, +) { + public constructor( + selectedPath: String, + selectedName: String, + selectedKind: String, + recent: List, + ) : this(selectedPath, selectedName, selectedKind, recent, null, null, null) + + /** The selected workspace as a reference, or null when nothing is selected. */ + public val selectedIdentity: RemoteWorkspaceIdentity? + get() = if (selectedPath.isEmpty() && selectedWorkspaceId.isNullOrEmpty()) null + else RemoteWorkspaceIdentity(selectedPath, selectedRemoteConnectionId, selectedRemoteSshHost, selectedWorkspaceId) +} /** * The view settings, as one value. @@ -76,11 +112,27 @@ public sealed interface SessionListSection { override val sessions: List, ) : SessionListSection + /** + * One project heading. [key] is `workspaceId ?: legacy triple` and is what + * the platforms should key collapse state and create menus by; [path] is + * display text, since two open workspaces may share a root. + */ public data class Project public constructor( public val path: String, public val name: String, override val sessions: List, - ) : SessionListSection + public val workspaceId: String?, + public val remoteConnectionId: String?, + public val remoteSshHost: String?, + ) : SessionListSection { + public constructor(path: String, name: String, sessions: List) : + this(path, name, sessions, null, null, null) + + public val identity: RemoteWorkspaceIdentity + get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId) + + public val key: String get() = identity.key + } public data class Today public constructor( override val sessions: List, @@ -127,11 +179,13 @@ public data class SessionListBatch public constructor( * * Two deliberate deviations from the source: * - * 1. Workspace identity is compared with [SessionWorkspacePaths.equal] - * everywhere, including when deciding whether a workspace is an assistant - * one. The ArkTS version compares those two raw, which means a desktop that - * reports the same assistant workspace with a trailing slash in one command - * and without it in another groups its sessions under the wrong heading. + * 1. Workspace identity is `workspaceId ?: (connection, ssh host, path)`, never + * the path alone. Sections, options, filters, and the assistant decision all + * resolve a reference through [LegacyWorkspaceCompatibility]: IDs compare by + * ID, pre-ID references by normalised root plus saved-connection identity, and + * an ambiguous root is filed nowhere. The ArkTS version compares raw paths, + * which both mis-groups a trailing slash and merges two workspaces that share + * a root. Filters persisted as bare paths keep matching by path. * 2. Sections are returned whole and [batch] applies the incremental disclosure * separately. Group identity and filtering stay stable while each platform * owns the transient count of how many batches the user has opened. @@ -165,18 +219,27 @@ public object SessionListPresentation { sessions: List, workspace: SessionWorkspaceContext, ): List { + val catalog = WorkspaceCatalog(workspace) val result = mutableListOf() - fun add(path: String, name: String) { - if (path.isEmpty()) return - if (result.any { SessionWorkspacePaths.equal(it.path, path) }) return + fun add(identity: RemoteWorkspaceIdentity, name: String) { + if (identity.path.isEmpty()) return + if (result.any { it.key == identity.key }) return result += SessionWorkspaceOption( - path = path, - name = name.ifBlank { SessionWorkspacePaths.basename(path) }, + path = identity.path, + name = name.ifBlank { SessionWorkspacePaths.basename(identity.path) }, + workspaceId = identity.workspaceId, + remoteConnectionId = identity.remoteConnectionId, + remoteSshHost = identity.remoteSshHost, ) } - add(workspace.selectedPath, workspace.selectedName) - workspace.recent.forEach { add(it.path, it.name) } - sessions.forEach { add(it.workspacePath.orEmpty(), it.workspaceName.orEmpty()) } + catalog.rows.forEach { add(it.identity, it.name) } + sessions.forEach { session -> + // A workspace only a session knows about is still offered; one the + // catalog already lists must not appear twice under a pre-ID reference. + val own = ownIdentity(session) ?: return@forEach + val known = catalog.resolve(own) + if (known != null) add(known.identity, known.name) else add(own, session.workspaceName.orEmpty()) + } return result } @@ -251,7 +314,7 @@ public object SessionListPresentation { val assistant = isAssistant(session, workspace) if (options.workspaceFilter.isNotEmpty() && - !SessionWorkspacePaths.equal(sessionPath(session, workspace, assistant), options.workspaceFilter) + !matchesWorkspaceFilter(session, workspace, assistant, options.workspaceFilter) ) { return false } @@ -264,32 +327,59 @@ public object SessionListPresentation { return options.statusFilter.isEmpty() || status == options.statusFilter } + /** + * A filter stores a [SessionWorkspaceOption.key]. Settings persisted before + * workspace IDs hold a bare path; those keep matching by path so an upgrade + * does not silently clear the user's filter. + */ + private fun matchesWorkspaceFilter( + session: RemoteSession, + workspace: SessionWorkspaceContext, + assistant: Boolean, + filter: String, + ): Boolean { + val catalog = WorkspaceCatalog(workspace) + val identity = sessionIdentity(session, workspace, assistant) + val key = identity?.let { catalog.resolve(it)?.identity?.key ?: it.key } + if (key == filter) return true + return SessionWorkspacePaths.equal(identity?.path.orEmpty(), filter) + } + + /** The reference the session itself carries: its identity, else its bare path. */ + private fun ownIdentity(session: RemoteSession): RemoteWorkspaceIdentity? { + session.workspaceIdentity?.takeIf { it.path.isNotEmpty() || !it.workspaceId.isNullOrEmpty() }?.let { return it } + val path = session.workspacePath.orEmpty() + return if (path.isEmpty()) null else RemoteWorkspaceIdentity(path, null, null) + } + /** * A chat session has no project of its own, so it must not inherit the * selected workspace the way a code session does. */ - private fun sessionPath( + private fun sessionIdentity( session: RemoteSession, workspace: SessionWorkspaceContext, assistant: Boolean, - ): String { - val own = session.workspacePath.orEmpty() - if (own.isNotEmpty()) return own - return if (assistant) "" else workspace.selectedPath + ): RemoteWorkspaceIdentity? { + ownIdentity(session)?.let { return it } + return if (assistant) null else workspace.selectedIdentity } private fun isAssistant(session: RemoteSession, workspace: SessionWorkspaceContext): Boolean = SessionAgentTypes.isAssistant(session.agentType) || - isAssistantWorkspace(session.workspacePath.orEmpty(), workspace) + isAssistantWorkspace(session, workspace) - private fun isAssistantWorkspace(path: String, workspace: SessionWorkspaceContext): Boolean { + /** + * Whether the session's workspace is an assistant one, decided by the + * catalog row's `workspaceKind` after resolving the reference by ID first + * and by the legacy triple only for pre-ID references. Paths alone never + * decide: an assistant and a project may share a root. + */ + private fun isAssistantWorkspace(session: RemoteSession, workspace: SessionWorkspaceContext): Boolean { val selectedIsAssistant = workspace.selectedKind.lowercase() == ASSISTANT - // No path at all means "wherever we are", which is the selected one. - if (path.isEmpty()) return selectedIsAssistant - if (selectedIsAssistant && SessionWorkspacePaths.equal(path, workspace.selectedPath)) return true - return workspace.recent.any { - SessionWorkspacePaths.equal(it.path, path) && it.kind.lowercase() == ASSISTANT - } + // No reference at all means "wherever we are", which is the selected one. + val own = ownIdentity(session) ?: return selectedIsAssistant + return WorkspaceCatalog(workspace).resolve(own)?.kind?.lowercase() == ASSISTANT } private fun chatSections(chat: List): List = @@ -300,39 +390,63 @@ public object SessionListPresentation { workspace: SessionWorkspaceContext, activeFilter: Boolean, ): List { - val entries = projectEntries(workspace) + val catalog = WorkspaceCatalog(workspace) + val entries = catalog.rows.filterNot { it.kind.lowercase() == ASSISTANT } + // Each session is filed once, under the row its reference resolves to. + // Ambiguous pre-ID paths resolve to nothing and stay out of every heading. + val filed = project.groupBy { session -> + val identity = sessionIdentity(session, workspace, assistant = false) + identity?.let { catalog.resolve(it)?.identity?.key } + } return entries.mapNotNull { entry -> - val rows = project.filter { session -> - SessionWorkspacePaths.equal( - session.workspacePath.orEmpty().ifEmpty { workspace.selectedPath }, - entry.path, - ) - } + val rows = filed[entry.identity.key].orEmpty() // With no filter on, an empty project still gets a heading: it is // how the user creates the first session in it. With one on, an // empty heading is just noise the filter was meant to remove. if (activeFilter && rows.isEmpty()) { null } else { - SessionListSection.Project(path = entry.path, name = entry.name, sessions = rows) + SessionListSection.Project( + path = entry.identity.path, + name = entry.name, + sessions = rows, + workspaceId = entry.identity.workspaceId, + remoteConnectionId = entry.identity.remoteConnectionId, + remoteSshHost = entry.identity.remoteSshHost, + ) } } } - private fun projectEntries(workspace: SessionWorkspaceContext): List { - val result = mutableListOf() - fun add(path: String, name: String, kind: String) { - if (kind.lowercase() == ASSISTANT) return - if (path.isEmpty() && name.isEmpty()) return - if (result.any { SessionWorkspacePaths.equal(it.path, path) }) return - result += SessionWorkspaceOption( - path = path, - name = name.ifBlank { SessionWorkspacePaths.basename(path) }, - ) + private class CatalogRow(val identity: RemoteWorkspaceIdentity, val name: String, val kind: String) + + /** + * The selected workspace and the desktop's recents as one catalog keyed by + * `workspaceId ?: legacy triple`, with the single resolver every grouping + * decision goes through. + */ + private class WorkspaceCatalog(workspace: SessionWorkspaceContext) { + val rows: List + private val identities: List + + init { + val result = mutableListOf() + fun add(identity: RemoteWorkspaceIdentity, name: String, kind: String) { + if (identity.path.isEmpty() && name.isEmpty()) return + if (result.any { it.identity.key == identity.key }) return + result += CatalogRow(identity, name.ifBlank { SessionWorkspacePaths.basename(identity.path) }, kind) + } + workspace.selectedIdentity?.let { add(it, workspace.selectedName, workspace.selectedKind) } + workspace.recent.forEach { if (it.path.isNotEmpty()) add(it.identity(), it.name, it.kind) } + rows = result + identities = result.map { it.identity } + } + + /** ID first; an unknown ID is nothing, never a same-path row. */ + fun resolve(reference: RemoteWorkspaceIdentity): CatalogRow? { + val resolved = LegacyWorkspaceCompatibility.resolve(reference, identities) ?: return null + return rows.firstOrNull { it.identity.key == resolved.key } } - add(workspace.selectedPath, workspace.selectedName, workspace.selectedKind) - workspace.recent.forEach { if (it.path.isNotEmpty()) add(it.path, it.name, it.kind) } - return result } private fun timeSections(sessions: List, nowMs: Long): List { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/shell/RemoteSidebarPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/shell/RemoteSidebarPresentation.kt index 00bda5ff8c..a8bd0cdcc0 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/shell/RemoteSidebarPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/shell/RemoteSidebarPresentation.kt @@ -40,9 +40,11 @@ public data class RemoteSidebarWorkspaceRow public constructor( public val sessions: List, public val remoteConnectionId: String?, public val remoteSshHost: String?, + public val workspaceId: String?, public val load: RemoteSidebarWorkspaceLoad = RemoteSidebarWorkspaceLoad.READY, ) { - public val key: String get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost).key + public val key: String get() = RemoteWorkspaceIdentity(path, remoteConnectionId, remoteSshHost, workspaceId).key + public constructor(path: String, name: String, selected: Boolean, sessions: List, remoteConnectionId: String?, remoteSshHost: String?) : this(path, name, selected, sessions, remoteConnectionId, remoteSshHost, null) public constructor(path: String, name: String, selected: Boolean, sessions: List, remoteConnectionId: String?) : this(path, name, selected, sessions, remoteConnectionId, null) public constructor(path: String, name: String, selected: Boolean, sessions: List) : this(path, name, selected, sessions, null) } @@ -95,10 +97,10 @@ public object RemoteSidebarPresentation { val catalogIdentities = workspaceRows.map { row -> row.identity() } return workspaceRows.map { workspace -> val identity = workspace.identity() - val isSelected = selected != null && identity.matches( - RemoteWorkspaceIdentity(selected.path, selected.remoteConnectionId, selected.remoteSshHost)) + // ID-first: IDs decide when both sides carry one; a pre-ID row falls back to the legacy triple. + val isSelected = selected != null && identity.sameWorkspace(selected.identity()) val bucketed = sessions.filter { it.belongsTo(identity, catalogIdentities) } - val branch = directory?.workspace(workspace.path, workspace.remoteConnectionId, workspace.remoteSshHost) + val branch = directory?.workspace(identity) // The selected workspace is the one `list_sessions` was asked about, // so its bucket is authoritative even before any branch was loaded. val load = when { @@ -122,6 +124,7 @@ public object RemoteSidebarPresentation { selected = isSelected, remoteConnectionId = workspace.remoteConnectionId, remoteSshHost = workspace.remoteSshHost, + workspaceId = workspace.workspaceId, load = load, sessions = owned.map { session -> RemoteSidebarSessionRow( diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt index bc42689bb1..4d35d07790 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt @@ -8,9 +8,14 @@ import com.openbitfun.mobile.core.domain.FilePreviewTarget import com.openbitfun.mobile.core.domain.FilePreviewTargetContext import com.openbitfun.mobile.core.domain.FileReferenceKind import com.openbitfun.mobile.core.domain.FileTargetResolver +import com.openbitfun.mobile.core.domain.LegacyWorkspaceCompatibility import com.openbitfun.mobile.core.domain.RecentWorkspace +import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity import com.openbitfun.mobile.core.domain.SelectedWorkspace import com.openbitfun.mobile.core.domain.WorkspaceAssistant +import com.openbitfun.mobile.core.domain.WorkspaceReferencePolicy +import com.openbitfun.mobile.core.domain.WorkspaceReferenceResolution +import com.openbitfun.mobile.core.domain.identity import com.openbitfun.mobile.core.feature.relay.HostCatalogNotice import com.openbitfun.mobile.core.persistence.TemporaryDownload import com.openbitfun.mobile.core.persistence.RelayStreamStore @@ -84,9 +89,9 @@ public class RemoteWorkspaceStore internal constructor( a.await() to b.await() } updateReady { it.copy(workspaces = recent.workspaces.map { item -> - RecentWorkspace(item.path.orEmpty(), item.name ?: basename(item.path.orEmpty()), item.lastOpened, item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId) - }, assistants = assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId) }, - catalog = recent.sidebarCatalog(assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId) }), loadFailure = false) } + RecentWorkspace(item.path.orEmpty(), item.name ?: basename(item.path.orEmpty()), item.lastOpened, item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId, item.workspaceId) + }, assistants = assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) }, + catalog = recent.sidebarCatalog(assistants.assistants.map { item -> WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) }), loadFailure = false) } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { updateReady { it.copy(loadFailure = true) } } } @@ -116,14 +121,52 @@ public class RemoteWorkspaceStore internal constructor( private var activePreviewRequestId: String? = null private var deviceToolAction: Job? = null private var deviceToolGeneration = 0L - private var fileWorkspace: Pair? = null + /** + * Location the file browser is bound to. `path` is the browsed directory (an IO operand), + * `remoteConnectionId` the provider, and `workspaceId` the owning workspace when the tools + * were opened for one; the identity key, not the path, scopes the cache. + */ + private var fileWorkspace: RemoteWorkspaceIdentity? = null private val directoryPicker = RuntimeFilesStore(scope, transport) private var directoryPickerObserver: Job? = null private val files = RuntimeFilesStore(scope, transport) private var filesObserver: Job? = null - private val workspaceTerminals = mutableMapOf, RuntimeTerminalStore>() + /** Device-tool terminals keyed by [RemoteWorkspaceIdentity.key] (workspace ID first, legacy triple otherwise). */ + private val workspaceTerminals = mutableMapOf() private var terminal = RuntimeTerminalStore(scope, transport, relayStreams) private var terminalObserver: Job? = null + /** False until a live `get_workspace_info` reported the host capability list for this connection. */ + private var hostCapabilitiesKnown = false + + /** + * Whether the connected host honours ID-only workspace commands, or null when no live + * capability list has been received yet (cached catalogs never answer this). + */ + public val supportsWorkspaceIdReferences: Boolean? + get() = if (!hostCapabilitiesKnown) null else (_state.value as? RemoteWorkspaceUiState.Ready)?.supportsWorkspaceIdReferences + + private fun recordHostCapabilities(capabilities: List) { + hostCapabilitiesKnown = true + updateReady { it.copy(hostCapabilities = capabilities) } + } + + /** + * Resolves whether an ID-bearing command may be sent. Fetches the live capability list + * when it is not known yet so a cached catalog never decides the answer. + */ + private suspend fun ensureWorkspaceIdReferences(): Boolean { + if (!hostCapabilitiesKnown) { + val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) + recordHostCapabilities(info.capabilities) + } + return WorkspaceReferencePolicy.supportsWorkspaceIdReferences( + (_state.value as? RemoteWorkspaceUiState.Ready)?.hostCapabilities.orEmpty(), + ) + } + + private fun failWorkspaceReference(failure: WorkspaceReferenceFailure) { + updateReady { it.copy(busy = false, workspaceReferenceFailure = failure) } + } private fun nextPreviewIdentity(target: FilePreviewTarget, requestedId: String = ""): PreviewRequestIdentity { @@ -165,7 +208,7 @@ public class RemoteWorkspaceStore internal constructor( } is RemoteWorkspaceIntent.SortFiles -> files.sort(intent.sort) RemoteWorkspaceIntent.CloseFileEditor -> files.closeFile() - is RemoteWorkspaceIntent.OpenDeviceTools -> openDeviceTools(intent.path, intent.connectionId) + is RemoteWorkspaceIntent.OpenDeviceTools -> openDeviceTools(intent.path, intent.connectionId, intent.workspaceId) is RemoteWorkspaceIntent.SelectDeviceToolsPanel -> updateReady { it.copy(deviceTools = it.deviceTools.copy(panel = intent.panel)) } RemoteWorkspaceIntent.CloseDeviceTools -> { deviceToolGeneration++; deviceToolAction?.cancel() @@ -175,14 +218,14 @@ public class RemoteWorkspaceStore internal constructor( val tools = (_state.value as? RemoteWorkspaceUiState.Ready)?.deviceTools if (tools != null && tools.visible && !tools.busy && !tools.failed && tools.path.isNotEmpty()) terminal.open(tools.path, tools.connectionId) } - is RemoteWorkspaceIntent.OpenDeviceFiles -> openDeviceTool(intent.path, intent.remoteConnectionId, false) - is RemoteWorkspaceIntent.OpenDeviceTerminal -> openDeviceTool(intent.path, intent.remoteConnectionId, true) + is RemoteWorkspaceIntent.OpenDeviceFiles -> openDeviceTool(intent.path, intent.remoteConnectionId, false, workspaceId = intent.workspaceId) + is RemoteWorkspaceIntent.OpenDeviceTerminal -> openDeviceTool(intent.path, intent.remoteConnectionId, true, workspaceId = intent.workspaceId) is RemoteWorkspaceIntent.BrowseFiles -> { if (filesObserver == null) filesObserver = scope.launch { files.state.collect { value -> updateReady { it.copy(files = value) } } } val selected = (_state.value as? RemoteWorkspaceUiState.Ready)?.selected - val binding = fileWorkspace ?: selected?.let { it.path to it.remoteConnectionId } + val binding = fileWorkspace ?: selected?.identity() if (binding != null) { - files.browse(intent.path, intent.path, binding.second, intent.append) + files.browse(intent.path, intent.path, binding.remoteConnectionId, intent.append) } else failRetainingCache() } @@ -208,7 +251,7 @@ public class RemoteWorkspaceStore internal constructor( RemoteWorkspaceIntent.ReopenTerminal -> terminal.reopen() is RemoteWorkspaceIntent.WriteTerminal -> terminal.write(intent.data) is RemoteWorkspaceIntent.SelectWorkspace -> selectWorkspace(intent) - is RemoteWorkspaceIntent.SelectAssistant -> selectAssistant(intent.path) + is RemoteWorkspaceIntent.SelectAssistant -> selectAssistant(intent) is RemoteWorkspaceIntent.OpenFile -> resolveAndOpenFile(intent) is RemoteWorkspaceIntent.DownloadFile -> resolveAndDownloadFile(intent) RemoteWorkspaceIntent.RetryDownload -> retryDownload() @@ -236,6 +279,7 @@ public class RemoteWorkspaceStore internal constructor( terminalObserver?.cancel() _stopVersion.value += 1 loadGeneration += 1 + hostCapabilitiesKnown = false invalidatePreview() cancelDownload() work?.cancel() @@ -269,13 +313,14 @@ public class RemoteWorkspaceStore internal constructor( path = item.path.orEmpty(), name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), lastOpened = item.lastOpened, + workspaceId = item.workspaceId, kind = item.workspaceKind.orEmpty(), - remoteSshHost = item.remoteSshHost, - remoteConnectionId = item.remoteConnectionId, + remoteSshHost = item.remoteSshHost.takeUnless { item.workspaceKind == "normal" || item.workspaceKind == "assistant" }, + remoteConnectionId = item.remoteConnectionId.takeUnless { item.workspaceKind == "normal" || item.workspaceKind == "assistant" }, ) }.filter { it.path.isNotEmpty() } val loadedAssistants = assistants.assistants.map { item -> - WorkspaceAssistant(item.path, item.name, item.assistantId) + WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) } if (persistenceEnabled) { try { @@ -328,13 +373,14 @@ public class RemoteWorkspaceStore internal constructor( path = item.path.orEmpty(), name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), lastOpened = item.lastOpened, - kind = item.workspaceKind.orEmpty(), - remoteSshHost = item.remoteSshHost, - remoteConnectionId = item.remoteConnectionId, + workspaceId = item.workspaceId, + kind = item.workspaceKind.orEmpty(), + remoteSshHost = item.remoteSshHost.takeUnless { item.workspaceKind == "normal" || item.workspaceKind == "assistant" }, + remoteConnectionId = item.remoteConnectionId.takeUnless { item.workspaceKind == "normal" || item.workspaceKind == "assistant" }, ) }.filter { it.path.isNotEmpty() } val loadedAssistants = assistants.assistants.map { item -> - WorkspaceAssistant(item.path, item.name, item.assistantId) + WorkspaceAssistant(item.path, item.name, item.assistantId, item.workspaceId) } if (persistenceEnabled) { try { @@ -347,6 +393,7 @@ public class RemoteWorkspaceStore internal constructor( } } downloadStaging?.delete(); downloadStaging = null + hostCapabilitiesKnown = true _state.value = RemoteWorkspaceUiState.Ready( workspaces = loadedWorkspaces, assistants = loadedAssistants, @@ -393,26 +440,72 @@ public class RemoteWorkspaceStore internal constructor( } } + /** + * Sends `set_workspace`. A known workspace ID is sent alone so an ID-aware host can + * never fall back to the path. A path without an ID is first resolved against the + * live catalog through [LegacyWorkspaceCompatibility]: a unique row that carries an + * ID upgrades the reference to that ID, a unique pre-ID row lends its saved + * connection identity, an ambiguous path is refused, and only a path the catalog + * does not know at all (a hand-typed location) is sent as the legacy projection. + */ private fun selectWorkspace(intent: RemoteWorkspaceIntent.SelectWorkspace) { + val ready = _state.value as? RemoteWorkspaceUiState.Ready ?: return + val explicitId = intent.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (explicitId != null) { + runSelection(RemoteCommand(cmd = "set_workspace", workspaceId = explicitId), assistant = false) + return + } val normalized = intent.path.trim() if (normalized.isEmpty()) return - val candidates = if (intent.inferSavedIdentity) { - (_state.value as? RemoteWorkspaceUiState.Ready)?.workspaces.orEmpty().filter { it.path == normalized } - } else emptyList() - if (intent.remoteConnectionId == null && intent.remoteSshHost == null && candidates.size > 1) { - failRetainingCache() + val reference = RemoteWorkspaceIdentity(normalized, intent.remoteConnectionId, intent.remoteSshHost) + val catalog = ready.workspaces.map { it.identity() } + val known: RemoteWorkspaceIdentity? = if (intent.inferSavedIdentity) { + when (val resolution = LegacyWorkspaceCompatibility.resolveReference(reference, catalog)) { + is WorkspaceReferenceResolution.Resolved -> resolution.identity + is WorkspaceReferenceResolution.Ambiguous -> { + failWorkspaceReference(WorkspaceReferenceFailure.AMBIGUOUS_PATH) + return + } + is WorkspaceReferenceResolution.UnknownId, WorkspaceReferenceResolution.Unresolved -> null + } + } else { + // An explicit picker already named the provider: only an exact legacy triple may lend its ID. + catalog.singleOrNull { it.workspaceId != null && it.matches(reference) } + } + val knownId = known?.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (knownId != null) { + runSelection(RemoteCommand(cmd = "set_workspace", workspaceId = knownId), assistant = false) return } - val known = candidates.singleOrNull() runSelection(RemoteCommand(cmd = "set_workspace", path = normalized, remoteConnectionId = intent.remoteConnectionId ?: known?.remoteConnectionId, - remoteSshHost = intent.remoteSshHost ?: known?.remoteSshHost), false) + remoteSshHost = intent.remoteSshHost ?: known?.remoteSshHost), assistant = false) } - private fun selectAssistant(path: String) { - val normalized = path.trim() + /** + * Sends `set_assistant`. With an ID only the ID is sent. Without one the path must + * resolve to exactly one pre-ID assistant row; assistants are never matched by path + * once the catalog carries IDs, and an unknown path is refused rather than guessed. + */ + private fun selectAssistant(intent: RemoteWorkspaceIntent.SelectAssistant) { + val ready = _state.value as? RemoteWorkspaceUiState.Ready ?: return + val explicitId = intent.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (explicitId != null) { + runSelection(RemoteCommand(cmd = "set_assistant", workspaceId = explicitId), assistant = true) + return + } + val normalized = intent.path.trim() if (normalized.isEmpty()) return - runSelection(RemoteCommand(cmd = "set_assistant", path = normalized), true) + val catalog = ready.assistants.map { it.identity() } + when (val resolution = LegacyWorkspaceCompatibility.resolveReference(RemoteWorkspaceIdentity(normalized, null, null), catalog)) { + is WorkspaceReferenceResolution.Resolved -> { + val resolvedId = resolution.identity.workspaceId?.trim()?.takeIf { it.isNotEmpty() } + if (resolvedId != null) runSelection(RemoteCommand(cmd = "set_assistant", workspaceId = resolvedId), assistant = true) + else runSelection(RemoteCommand(cmd = "set_assistant", path = resolution.identity.path), assistant = true) + } + is WorkspaceReferenceResolution.Ambiguous -> failWorkspaceReference(WorkspaceReferenceFailure.AMBIGUOUS_PATH) + is WorkspaceReferenceResolution.UnknownId, WorkspaceReferenceResolution.Unresolved -> failRetainingCache() + } } private fun runSelection(command: RemoteCommand, assistant: Boolean) { @@ -421,18 +514,34 @@ public class RemoteWorkspaceStore internal constructor( invalidatePreview() cancelDownload() work?.cancel() - _state.value = ((_state.value as? RemoteWorkspaceUiState.Ready) ?: current).copy(busy = true) + _state.value = ((_state.value as? RemoteWorkspaceUiState.Ready) ?: current).copy(busy = true, workspaceReferenceFailure = null) work = scope.launch { try { - if (assistant) { - check(transport.send(command).success == true) { "Assistant selection failed" } + if (command.workspaceId != null && !ensureWorkspaceIdReferences()) { + // Keep the ID and refuse: downgrading a known ID to its path would let the + // host pick a same-path workspace the user never chose. + if (generation == loadGeneration) failWorkspaceReference(WorkspaceReferenceFailure.ID_REFERENCES_UNSUPPORTED) + return@launch + } + val accepted = if (assistant) { + transport.send(command).success == true } else { - check(transport.send(command).success == true) { "Workspace selection failed" } + transport.send(command).success == true + } + if (!accepted) { + if (generation != loadGeneration) return@launch + // The wire carries no error code. An ID the live catalog does not list is + // reported as unknown; anything else is an ordinary failed request. + val rejectedId = command.workspaceId + if (rejectedId != null && isUnknownId(rejectedId, assistant)) failWorkspaceReference(WorkspaceReferenceFailure.UNKNOWN_ID) + else failRetainingCache() + return@launch } val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) if (generation != loadGeneration) return@launch + hostCapabilitiesKnown = true if (fileWorkspace == null) files.reset() - updateReady { it.copy(selected = info.asSelectedWorkspace(), hostCapabilities = info.capabilities, busy = false, loadFailure = false) } + updateReady { it.copy(selected = info.asSelectedWorkspace(), hostCapabilities = info.capabilities, busy = false, loadFailure = false, workspaceReferenceFailure = null) } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { @@ -441,6 +550,12 @@ public class RemoteWorkspaceStore internal constructor( } } + private fun isUnknownId(workspaceId: String, assistant: Boolean): Boolean { + val ready = _state.value as? RemoteWorkspaceUiState.Ready ?: return false + val catalog = if (assistant) ready.assistants.map { it.identity() } else ready.workspaces.map { it.identity() } + return LegacyWorkspaceCompatibility.resolveById(workspaceId, catalog).isUnknownId + } + private fun openFile(target: FilePreviewTarget, requestedId: String) { val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return val connectionId = current.selected?.remoteConnectionId.takeIf { target.sessionId.isEmpty() } @@ -530,7 +645,7 @@ public class RemoteWorkspaceStore internal constructor( label = intent.label, context = FilePreviewTargetContext( sessionId = intent.sessionId, - workspacePath = (fileWorkspace?.first.takeIf { intent.sessionId.isEmpty() } ?: ready.selected?.path).orEmpty(), + workspacePath = (fileWorkspace?.path.takeIf { intent.sessionId.isEmpty() } ?: ready.selected?.path).orEmpty(), controlTargetEpoch = targetEpoch, ), ) @@ -541,7 +656,7 @@ public class RemoteWorkspaceStore internal constructor( intent.reference, intent.label, intent.sessionId, - (fileWorkspace?.first.takeIf { intent.sessionId.isEmpty() } ?: ready.selected?.path).orEmpty(), + (fileWorkspace?.path.takeIf { intent.sessionId.isEmpty() } ?: ready.selected?.path).orEmpty(), targetEpoch, 0, 0, @@ -560,23 +675,25 @@ public class RemoteWorkspaceStore internal constructor( downloadFile(target) } - private fun bindTerminal(location: String, connectionId: String?) { + /** Binds the active terminal to the store cached for [scope]'s identity key (workspace ID first, legacy triple otherwise). */ + private fun bindTerminal(scope: RemoteWorkspaceIdentity) { terminalObserver?.cancel() - terminal = workspaceTerminals.getOrPut(location to connectionId) { RuntimeTerminalStore(scope, transport, relayStreams) } + terminal = workspaceTerminals.getOrPut(scope.key) { RuntimeTerminalStore(this.scope, transport, relayStreams) } updateReady { it.copy(terminal = terminal.state.value) } - terminalObserver = scope.launch { terminal.state.collect { value -> updateReady { it.copy(terminal = value) } } } + terminalObserver = this.scope.launch { terminal.state.collect { value -> updateReady { it.copy(terminal = value) } } } } - private fun openDeviceTools(path: String, connectionId: String?) { + private fun openDeviceTools(path: String, connectionId: String?, workspaceId: String?) { val ready = _state.value as? RemoteWorkspaceUiState.Ready ?: return // The editor must be closed through its discard/save flow before changing providers. if (ready.deviceTools.visible && ready.files.file != null) return val panel = if (ready.deviceTools.visible) ready.deviceTools.panel else DeviceToolsPanel.FILES updateReady { it.copy(deviceTools = DeviceToolsUiState(true, panel, "", connectionId, true, false)) } - openDeviceTool(path, connectionId, false, unifiedTools = true) + openDeviceTool(path, connectionId, false, unifiedTools = true, workspaceId = workspaceId) } - private fun openDeviceTool(path: String, connectionId: String?, terminalTool: Boolean, unifiedTools: Boolean = false) { + private fun openDeviceTool(path: String, connectionId: String?, terminalTool: Boolean, unifiedTools: Boolean = false, workspaceId: String? = null) { + val scopedWorkspaceId = workspaceId?.trim()?.takeIf { it.isNotEmpty() } val ready = _state.value as? RemoteWorkspaceUiState.Ready ?: return deviceToolAction?.cancel() val generation = ++deviceToolGeneration @@ -601,19 +718,21 @@ public class RemoteWorkspaceStore internal constructor( response.value.jsonObject["homeDir"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: error("Runtime home directory unavailable") } if (generation != deviceToolGeneration) return@launch + val toolScope = RemoteWorkspaceIdentity(location, connectionId, null, scopedWorkspaceId) if (terminalTool) { - bindTerminal(location, connectionId) + bindTerminal(toolScope) terminal.open(location, connectionId) } else { if (unifiedTools) { - bindTerminal(location, connectionId) + bindTerminal(toolScope) updateReady { it.copy(deviceTools = it.deviceTools.copy(path = location, busy = false, failed = false)) } } if (filesObserver == null) filesObserver = scope.launch { files.state.collect { value -> - if (value.directory.isNotEmpty() && !value.failed) fileWorkspace = value.directory to connectionId + // Browsing deeper moves the IO operand only; the owning workspace identity is kept. + if (value.directory.isNotEmpty() && !value.failed) fileWorkspace = (fileWorkspace ?: toolScope).copy(path = value.directory, remoteConnectionId = connectionId) updateReady { it.copy(files = value) } } } - fileWorkspace = location to connectionId + fileWorkspace = toolScope files.browse(location, location, connectionId, false) } } catch (cancelled: CancellationException) { throw cancelled } @@ -642,8 +761,8 @@ public class RemoteWorkspaceStore internal constructor( } val binding = previousBinding ?: DownloadBinding( target, - (fileWorkspace?.first ?: current.selected?.path).takeIf { target.sessionId.isEmpty() }, - (fileWorkspace?.let { it.second } ?: current.selected?.remoteConnectionId.takeIf { fileWorkspace == null }).takeIf { target.sessionId.isEmpty() }, + (fileWorkspace?.path ?: current.selected?.path).takeIf { target.sessionId.isEmpty() }, + (fileWorkspace?.remoteConnectionId ?: current.selected?.remoteConnectionId.takeIf { fileWorkspace == null }).takeIf { target.sessionId.isEmpty() }, loadGeneration, _stopVersion.value, ) @@ -890,10 +1009,11 @@ public class RemoteWorkspaceStore internal constructor( path = path, name = resolvedName?.takeIf(String::isNotBlank) ?: basename(path), gitBranch = gitBranch.orEmpty(), + workspaceId = workspaceId, kind = workspaceKind.orEmpty(), assistantId = assistantId, - remoteConnectionId = remoteConnectionId, - remoteSshHost = remoteSshHost, + remoteConnectionId = remoteConnectionId.takeUnless { workspaceKind == "normal" || workspaceKind == "assistant" }, + remoteSshHost = remoteSshHost.takeUnless { workspaceKind == "normal" || workspaceKind == "assistant" }, ) } @@ -919,10 +1039,10 @@ public class RemoteWorkspaceStore internal constructor( private fun cachedReady(rows: List): RemoteWorkspaceUiState.Ready { val assistants = rows.filter { it.workspaceKind == ASSISTANT_KIND }.map { row -> - WorkspaceAssistant(row.path, row.name.ifEmpty { basename(row.path) }, null) + WorkspaceAssistant(row.path, row.name.ifEmpty { basename(row.path) }, null, row.workspaceId) } val workspaces = rows.filterNot { it.workspaceKind == ASSISTANT_KIND }.map { row -> - RecentWorkspace(row.path, row.name.ifEmpty { basename(row.path) }, row.lastOpened, row.workspaceKind, row.remoteSshHost, row.remoteConnectionId) + RecentWorkspace(row.path, row.name.ifEmpty { basename(row.path) }, row.lastOpened, row.workspaceKind, row.remoteSshHost, row.remoteConnectionId, row.workspaceId) } return RemoteWorkspaceUiState.Ready( workspaces = workspaces, @@ -940,12 +1060,13 @@ public class RemoteWorkspaceStore internal constructor( assistants: List, ): List { val rows = workspaces.map { workspace -> - PersistedRemoteWorkspace(workspace.path, workspace.name, workspace.lastOpened, workspace.kind, workspace.remoteSshHost, workspace.remoteConnectionId) + PersistedRemoteWorkspace(workspace.path, workspace.name, workspace.lastOpened, workspace.kind, workspace.remoteSshHost, workspace.remoteConnectionId, workspace.workspaceId) }.toMutableList() + // Dedupe by workspace identity (ID first, legacy triple otherwise), never by path: + // an assistant and a project may share a root and must both survive the cache. assistants.forEach { assistant -> - if (rows.none { it.path == assistant.path }) { - rows += PersistedRemoteWorkspace(assistant.path, assistant.name, "", ASSISTANT_KIND) - } + val row = PersistedRemoteWorkspace(assistant.path, assistant.name, "", ASSISTANT_KIND, null, null, assistant.workspaceId) + if (rows.none { it.key == row.key }) rows += row } return rows } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt index 622557f340..0a8365337b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt @@ -6,6 +6,8 @@ import com.openbitfun.mobile.core.domain.FilePreviewTarget import com.openbitfun.mobile.core.domain.RecentWorkspace import com.openbitfun.mobile.core.domain.SelectedWorkspace import com.openbitfun.mobile.core.domain.WorkspaceAssistant +import com.openbitfun.mobile.core.domain.WorkspaceReferencePolicy +import com.openbitfun.mobile.core.domain.identity public data class PreviewRequestIdentity public constructor( public val requestId: String, @@ -161,6 +163,25 @@ public data class DeviceToolsUiState public constructor( public constructor() : this(false, DeviceToolsPanel.FILES, "", null, false, false) } +/** + * Why the last workspace or assistant selection was not sent, or was refused, + * as a workspace reference. Distinct from [RemoteWorkspaceUiState.Ready.loadFailure]: + * nothing is wrong with the connection, the reference itself cannot be honoured. + */ +public enum class WorkspaceReferenceFailure { + /** + * The reference has a workspace ID but the host does not advertise + * `workspace_id_references_v1`. The ID was kept and no path was sent. + */ + ID_REFERENCES_UNSUPPORTED, + + /** The reference's ID is not in this host's catalog. */ + UNKNOWN_ID, + + /** A pre-ID path matched several catalog rows; nothing was selected. */ + AMBIGUOUS_PATH, +} + public sealed interface RemoteWorkspaceUiState { public data object Idle : RemoteWorkspaceUiState public data object Loading : RemoteWorkspaceUiState @@ -182,7 +203,22 @@ public sealed interface RemoteWorkspaceUiState { public val directoryPicker: RuntimeFilesUiState, public val catalog: WorkspaceCatalogUiState?, public val deviceTools: DeviceToolsUiState, + /** Set when the last selection named a workspace the host cannot address; cleared by the next successful one. */ + public val workspaceReferenceFailure: WorkspaceReferenceFailure?, ) : RemoteWorkspaceUiState { + /** Whether the live host honours ID-only workspace commands. False for a cached catalog. */ + public val supportsWorkspaceIdReferences: Boolean + get() = WorkspaceReferencePolicy.supportsWorkspaceIdReferences(hostCapabilities) + + /** ID-first: whether [workspace] is the live selection. IDs decide when both carry one; the legacy triple otherwise. */ + public fun isSelected(workspace: RecentWorkspace): Boolean = + selected?.identity()?.sameWorkspace(workspace.identity()) == true + + /** ID-first: whether [assistant] is the live selection. */ + public fun isSelected(assistant: WorkspaceAssistant): Boolean = + selected?.identity()?.sameWorkspace(assistant.identity()) == true + + public constructor(workspaces: List, assistants: List, selected: SelectedWorkspace?, preview: RemoteFilePreviewUiState, busy: Boolean, download: RemoteFileDownloadUiState, loadFailure: Boolean, hostCapabilities: List, savedConnections: List, savedConnectionsFailure: Boolean, terminal: RuntimeTerminalUiState, files: RuntimeFilesUiState, directoryPicker: RuntimeFilesUiState, catalog: WorkspaceCatalogUiState?, deviceTools: DeviceToolsUiState) : this(workspaces, assistants, selected, preview, busy, download, loadFailure, hostCapabilities, savedConnections, savedConnectionsFailure, terminal, files, directoryPicker, catalog, deviceTools, null) public constructor(workspaces: List, assistants: List, selected: SelectedWorkspace?, preview: RemoteFilePreviewUiState, busy: Boolean, download: RemoteFileDownloadUiState, loadFailure: Boolean, hostCapabilities: List, savedConnections: List, savedConnectionsFailure: Boolean, terminal: RuntimeTerminalUiState, files: RuntimeFilesUiState, directoryPicker: RuntimeFilesUiState, catalog: WorkspaceCatalogUiState?) : this(workspaces, assistants, selected, preview, busy, download, loadFailure, hostCapabilities, savedConnections, savedConnectionsFailure, terminal, files, directoryPicker, catalog, DeviceToolsUiState()) public constructor(workspaces: List, assistants: List, selected: SelectedWorkspace?, preview: RemoteFilePreviewUiState, busy: Boolean, download: RemoteFileDownloadUiState, loadFailure: Boolean, hostCapabilities: List, savedConnections: List, savedConnectionsFailure: Boolean, terminal: RuntimeTerminalUiState, files: RuntimeFilesUiState, directoryPicker: RuntimeFilesUiState) : this(workspaces, assistants, selected, preview, busy, download, loadFailure, hostCapabilities, savedConnections, savedConnectionsFailure, terminal, files, directoryPicker, null) public constructor(workspaces: List, assistants: List, selected: SelectedWorkspace?, preview: RemoteFilePreviewUiState, busy: Boolean, download: RemoteFileDownloadUiState, loadFailure: Boolean, hostCapabilities: List, savedConnections: List, savedConnectionsFailure: Boolean, terminal: RuntimeTerminalUiState, files: RuntimeFilesUiState) : this(workspaces, assistants, selected, preview, busy, download, loadFailure, hostCapabilities, savedConnections, savedConnectionsFailure, terminal, files, RuntimeFilesUiState("", emptyList(), false, null, "", false, false)) @@ -213,15 +249,25 @@ public sealed interface RemoteWorkspaceIntent { public data class ResizeTerminal(public val cols: Int, public val rows: Int) : RemoteWorkspaceIntent public data class UploadFile(public val path: String, public val source: RuntimeUploadSource) : RemoteWorkspaceIntent public data object Load : RemoteWorkspaceIntent - public data class OpenDeviceTools(public val path: String, public val connectionId: String?) : RemoteWorkspaceIntent { + /** + * Device tools browse a location on a provider. [workspaceId] is optional: when the + * location is an open workspace, it keys the terminal/file caches by workspace identity + * instead of by path so two workspaces sharing a path never share a terminal. + */ + public data class OpenDeviceTools public constructor(public val path: String, public val connectionId: String?, public val workspaceId: String?) : RemoteWorkspaceIntent { + public constructor(path: String, connectionId: String?) : this(path, connectionId, null) public constructor() : this("", null) public constructor(connectionId: String?) : this("", connectionId) } public data class SelectDeviceToolsPanel(public val panel: DeviceToolsPanel) : RemoteWorkspaceIntent public data object CloseDeviceTools : RemoteWorkspaceIntent public data object StartDeviceToolsTerminal : RemoteWorkspaceIntent - public data class OpenDeviceFiles(public val path: String, public val remoteConnectionId: String?) : RemoteWorkspaceIntent - public data class OpenDeviceTerminal(public val path: String, public val remoteConnectionId: String?) : RemoteWorkspaceIntent + public data class OpenDeviceFiles public constructor(public val path: String, public val remoteConnectionId: String?, public val workspaceId: String?) : RemoteWorkspaceIntent { + public constructor(path: String, remoteConnectionId: String?) : this(path, remoteConnectionId, null) + } + public data class OpenDeviceTerminal public constructor(public val path: String, public val remoteConnectionId: String?, public val workspaceId: String?) : RemoteWorkspaceIntent { + public constructor(path: String, remoteConnectionId: String?) : this(path, remoteConnectionId, null) + } public data class BrowseFiles(public val path: String, public val append: Boolean) : RemoteWorkspaceIntent public data class ReadFile(public val path: String) : RemoteWorkspaceIntent public data class SaveFile(public val content: String) : RemoteWorkspaceIntent @@ -240,12 +286,20 @@ public sealed interface RemoteWorkspaceIntent { public data object ReopenTerminal : RemoteWorkspaceIntent public data object CloseTerminal : RemoteWorkspaceIntent public data class WriteTerminal(public val data: String) : RemoteWorkspaceIntent - /** Explicit location pickers disable inference: null then means the controlled host itself. */ - public data class SelectWorkspace public constructor(public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, public val inferSavedIdentity: Boolean) : RemoteWorkspaceIntent { + /** + * Selects a workspace. With [workspaceId] the host is addressed by ID only and the + * path/connection fields are display context. Without it, explicit location pickers + * disable inference: null then means the controlled host itself. + */ + public data class SelectWorkspace public constructor(public val path: String, public val remoteConnectionId: String?, public val remoteSshHost: String?, public val inferSavedIdentity: Boolean, public val workspaceId: String?) : RemoteWorkspaceIntent { + public constructor(path: String, remoteConnectionId: String?, remoteSshHost: String?, inferSavedIdentity: Boolean) : this(path, remoteConnectionId, remoteSshHost, inferSavedIdentity, null) public constructor(path: String, remoteConnectionId: String?, remoteSshHost: String?) : this(path, remoteConnectionId, remoteSshHost, true) public constructor(path: String) : this(path, null, null) } - public data class SelectAssistant public constructor(public val path: String) : RemoteWorkspaceIntent + /** Selects an assistant workspace by ID when known; the path is only used for pre-ID catalog rows. */ + public data class SelectAssistant public constructor(public val path: String, public val workspaceId: String?) : RemoteWorkspaceIntent { + public constructor(path: String) : this(path, null) + } public data class OpenFile public constructor( public val reference: String, public val label: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogUiState.kt index f12e225dbb..d44d803afc 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogUiState.kt @@ -20,12 +20,12 @@ internal fun RecentWorkspaceListResponse.sidebarCatalog( ): WorkspaceCatalogUiState { val rows = (openedWorkspaces ?: workspaces).map { item -> RecentWorkspace(item.path.orEmpty(), item.name.orEmpty(), item.lastOpened, - item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId) + item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId, item.workspaceId) } return projectWorkspaceCatalog(rows, assistants, openedWorkspaces != null).copy(recentWorkspaces = workspaces.map { item -> RecentWorkspace(item.path.orEmpty(), item.name.orEmpty(), item.lastOpened, - item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId) - }.filter { it.path.isNotBlank() }) + item.workspaceKind.orEmpty(), item.remoteSshHost, item.remoteConnectionId, item.workspaceId) + }.map { it.normalizedRouting() }.filter { it.path.isNotBlank() }) } internal fun projectWorkspaceCatalog( @@ -34,19 +34,22 @@ internal fun projectWorkspaceCatalog( opened: Boolean = false, ): WorkspaceCatalogUiState { val candidates = if (opened) rows else assistants.map { - RecentWorkspace(it.path, it.name, "", "assistant") + RecentWorkspace(it.path, it.name, "", "assistant", null, null, it.workspaceId) } + rows return WorkspaceCatalogUiState( - candidates.filter { it.path.isNotBlank() }.map { row -> + candidates.filter { it.path.isNotBlank() }.map { it.normalizedRouting() }.map { row -> val assistant = if (row.remoteConnectionId.isNullOrEmpty() && row.remoteSshHost.isNullOrEmpty()) { - assistants.find { it.path == row.path && it.name.isNotBlank() } + assistants.find { (if (row.workspaceId != null) it.workspaceId == row.workspaceId else it.workspaceId == null && it.path == row.path) && it.name.isNotBlank() } } else null when { assistant != null -> row.copy(name = assistant.name, kind = "assistant") row.name.isBlank() -> row.copy(name = row.path.trimEnd('/').substringAfterLast('/')) else -> row } - }.distinctBy { Triple(it.remoteConnectionId, it.remoteSshHost, it.path) }, + }.distinctBy { it.workspaceId ?: Triple(it.remoteConnectionId, it.remoteSshHost, it.path) }, if (opened) WorkspaceCatalogSource.OPENED else WorkspaceCatalogSource.RECENT, ) } + +internal fun RecentWorkspace.normalizedRouting(): RecentWorkspace = + if (kind == "normal" || kind == "assistant") copy(remoteSshHost = null, remoteConnectionId = null) else this diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt index 1f386eacff..a151e9a5ca 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt @@ -355,6 +355,7 @@ class DeviceDirectoryStoreTest { @Test fun workspaceFailureKeepsSiblingSessionsAndCanRetry() = runTest { val transport = FakeDeviceTransport("a") + transport.extraWorkspaces = """,{"path":"/other","name":"Other","workspace_kind":"normal"}""" val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) store.dispatch(DeviceDirectoryIntent.Load("a")) @@ -396,6 +397,7 @@ class DeviceDirectoryStoreTest { "a" to FakeDeviceTransport("a"), "b" to FakeDeviceTransport("b"), ) + transports.getValue("a").extraWorkspaces = """,{"path":"/assistant-not-current","name":"Assistant","workspace_kind":"normal"}""" val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true), DeviceDirectoryDevice("b", true)))) store.dispatch(DeviceDirectoryIntent.Load("a")) @@ -588,6 +590,7 @@ private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandT """[{"id":"s-$deviceId","title":"Session $deviceId","agent_type":"code"}]""" var assistantJson: String = "[]" var openedJson: String = "null" + var extraWorkspaces: String = "" override suspend fun send( deserializer: DeserializationStrategy, @@ -599,7 +602,7 @@ private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandT "list_recent_workspaces" -> { workspaceFailure?.let { throw RelayTransportException(it) } workspaceGate?.await() - """{"resp":"ok","workspaces":[{"path":"/repo-$deviceId","name":"Repo $deviceId","last_opened":"2026-08-09","workspace_kind":"local"}],"opened_workspaces":$openedJson}""" + """{"resp":"ok","workspaces":[{"path":"/repo-$deviceId","name":"Repo $deviceId","last_opened":"2026-08-09","workspace_kind":"local"}$extraWorkspaces],"opened_workspaces":$openedJson}""" } "list_assistants" -> """{"resp":"ok","assistants":$assistantJson}""" "get_workspace_info" -> diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentationTest.kt index 63e309a18a..2365de7528 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/SessionListPresentationTest.kt @@ -2,6 +2,7 @@ package com.openbitfun.mobile.core.feature.session import com.openbitfun.mobile.core.domain.RecentWorkspace import com.openbitfun.mobile.core.domain.RemoteSession +import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -229,6 +230,81 @@ class SessionListPresentationTest { assertEquals(0, complete.nextCount) } + private val identityWorkspace = SessionWorkspaceContext( + selectedPath = "/repo", + selectedName = "Local", + selectedKind = "normal", + recent = listOf( + RecentWorkspace("/repo", "Local", "", "normal", null, null, "ws-local"), + RecentWorkspace("/repo", "SSH", "", "remote", "host", "saved-1", "ws-ssh"), + RecentWorkspace("/repo", "Helper", "", "assistant", null, null, "ws-helper"), + ), + selectedWorkspaceId = "ws-local", + selectedRemoteConnectionId = null, + selectedRemoteSshHost = null, + ) + + @Test + fun workspacesSharingAPathAreSeparateProjectsKeyedByWorkspaceId() { + val sessions = listOf( + session("local", path = "/repo", workspaceId = "ws-local"), + session("ssh", path = "/repo", workspaceId = "ws-ssh"), + session("gone", path = "/repo", workspaceId = "ws-gone"), + ) + val view = SessionListPresentation.view(sessions, identityWorkspace, defaults, now) + val projects = view.sections.filterIsInstance() + assertEquals(listOf("ws-local", "ws-ssh"), projects.map { it.workspaceId }) + assertEquals(listOf("/repo", "/repo"), projects.map { it.path }, "the path is display text only") + assertTrue(projects[0].key != projects[1].key) + assertEquals(listOf("local"), projects[0].sessions.map { it.id }) + assertEquals(listOf("ssh"), projects[1].sessions.map { it.id }) + // An ID the catalog does not know is filed nowhere; it must not land on a same-path row. + assertTrue(view.sections.flatMap { it.sessions }.none { it.id == "gone" }) + } + + @Test + fun aPreIdSessionResolvesThroughTheLegacyTripleAndAnAmbiguousRootIsFiledNowhere() { + val sessions = listOf( + session("by-connection", path = "/repo", remoteConnectionId = "saved-1"), + session("bare-path", path = "/repo"), + ) + val view = SessionListPresentation.view(sessions, identityWorkspace, defaults, now) + val projects = view.sections.filterIsInstance() + assertEquals(listOf("by-connection"), projects.first { it.workspaceId == "ws-ssh" }.sessions.map { it.id }) + // "/repo" alone matches the local and the SSH row (the assistant row has kind assistant but + // still shares the root), so nothing may be picked on the user's behalf. + assertTrue(view.sections.flatMap { it.sessions }.none { it.id == "bare-path" }) + } + + @Test + fun assistantDetectionUsesTheCatalogRowKindByIdNotThePath() { + val helper = session("helper", agentType = "code", path = "/repo", workspaceId = "ws-helper") + val project = session("project", agentType = "code", path = "/repo", workspaceId = "ws-local") + assertEquals(SessionAgentGroup.CHAT, SessionListPresentation.agentGroup(helper, identityWorkspace)) + assertEquals(SessionAgentGroup.CODE, SessionListPresentation.agentGroup(project, identityWorkspace)) + val view = SessionListPresentation.view(listOf(helper, project), identityWorkspace, defaults, now) + assertEquals(listOf("helper"), view.sections.filterIsInstance().single().sessions.map { it.id }) + assertTrue(view.sections.none { it is SessionListSection.Project && it.workspaceId == "ws-helper" }) + } + + @Test + fun workspaceOptionsCarryIdsAndFiltersMatchByKeyWithLegacyPathFallback() { + val sessions = listOf( + session("local", path = "/repo", workspaceId = "ws-local"), + session("ssh", path = "/repo", workspaceId = "ws-ssh"), + session("stray", path = "/elsewhere"), + ) + val options = SessionListPresentation.workspaceOptions(sessions, identityWorkspace) + assertEquals(listOf("ws-local", "ws-ssh", "ws-helper", null), options.map { it.workspaceId }) + assertEquals(4, options.map { it.key }.toSet().size, "same-path rows are distinct options") + fun ids(filter: String) = SessionListPresentation.view(sessions, identityWorkspace, defaults.copy(workspaceFilter = filter), now) + .sections.flatMap { it.sessions }.map { it.id }.sorted() + assertEquals(listOf("ssh"), ids(options.first { it.workspaceId == "ws-ssh" }.key)) + assertEquals(listOf("local"), ids(options.first { it.workspaceId == "ws-local" }.key)) + // A filter persisted as a bare path before IDs existed keeps matching by path. + assertEquals(listOf("local", "ssh"), ids("/repo")) + } + private fun session( id: String, agentType: String = "code", @@ -236,7 +312,19 @@ class SessionListPresentationTest { updatedAt: String = "1754726400000", createdAt: String = "1754726400000", path: String? = "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/work/openbitfun", - ) = RemoteSession( + workspaceId: String? = null, + remoteConnectionId: String? = null, + ) = if (workspaceId == null && remoteConnectionId == null) RemoteSession( + id = id, + title = id, + agentType = agentType, + status = status, + updatedAt = updatedAt, + createdAt = createdAt, + messageCount = 0, + workspacePath = path, + workspaceName = null, + ) else RemoteSession( id = id, title = id, agentType = agentType, @@ -246,5 +334,6 @@ class SessionListPresentationTest { messageCount = 0, workspacePath = path, workspaceName = null, + workspaceIdentity = RemoteWorkspaceIdentity(path.orEmpty(), remoteConnectionId, null, workspaceId), ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt index 220d4e4577..2d4273d506 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt @@ -900,6 +900,180 @@ private class DelayedPreviewTransport : RemoteCommandTransport { } } +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteWorkspaceStoreIdentityTest { + private val idHost = "workspace_id_references_v1" + + /** Catalog rows carry IDs; the connected host advertises [capabilities]. */ + private fun idAwareTransport( + base: FakeWorkspaceTransport, + capabilities: List = listOf(idHost), + workspaces: String = """[{"path":"/repo","name":"Local","workspace_id":"ws-local","workspace_kind":"normal"},{"path":"/repo","name":"SSH","workspace_id":"ws-ssh","workspace_kind":"remote","remote_connection_id":"saved-1","remote_ssh_host":"host"}]""", + assistants: String = """[{"path":"/assistant","name":"Assistant","assistant_id":"a1","workspace_id":"ws-assistant"}]""", + ) = object : RemoteCommandTransport { + override suspend fun send(deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long): T { + val wire = when (command.cmd) { + "list_recent_workspaces" -> """{"resp":"ok","workspaces":$workspaces}""" + "list_assistants" -> """{"resp":"ok","assistants":$assistants}""" + "get_workspace_info" -> { + base.commands += command + """{"resp":"ok","has_workspace":true,"path":"/repo","project_name":"Local","git_branch":"main","workspace_kind":"normal","workspace_id":"ws-local","capabilities":${capabilities.joinToString(",", "[", "]") { "\"$it\"" }}}""" + } + else -> null + } + return if (wire == null) base.send(deserializer, command, timeoutMs) else RelayJson.decodeFromString(deserializer, wire) + } + } + + @Test + fun aPathThatResolvesToACatalogRowWithAnIdIsSelectedByIdOnly() = runTest { + val base = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, idAwareTransport(base), StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + assertEquals(true, store.supportsWorkspaceIdReferences) + // The saved connection narrows two same-path rows to the SSH one; its ID is what gets sent. + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo", "saved-1", null)); advanceUntilIdle() + val byId = base.commands.last { it.cmd == "set_workspace" } + assertEquals("ws-ssh", byId.workspaceId) + assertEquals(null, byId.path) + assertEquals(null, byId.remoteConnectionId) + assertEquals(null, byId.remoteSshHost) + // An explicit picker row with an ID is addressed by that ID. + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo", null, null, false, "ws-local")); advanceUntilIdle() + assertEquals("ws-local", base.commands.last { it.cmd == "set_workspace" }.workspaceId) + // A hand-typed location the catalog does not know has no ID and sends the legacy projection only. + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/elsewhere", "saved-1", null)); advanceUntilIdle() + val legacy = base.commands.last { it.cmd == "set_workspace" } + assertEquals(null, legacy.workspaceId) + assertEquals("/elsewhere", legacy.path) + assertEquals("saved-1", legacy.remoteConnectionId) + assertEquals(null, assertIs(store.state.value).workspaceReferenceFailure) + store.stop() + } + + @Test + fun anAmbiguousPreIdPathIsRefusedInsteadOfPickingARow() = runTest { + val base = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, idAwareTransport(base), StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + base.commands.clear() + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo")); advanceUntilIdle() + assertTrue(base.commands.none { it.cmd == "set_workspace" }) + val ready = assertIs(store.state.value) + assertEquals(WorkspaceReferenceFailure.AMBIGUOUS_PATH, ready.workspaceReferenceFailure) + assertFalse(ready.loadFailure) + assertEquals(2, ready.workspaces.size, "the catalog is retained") + store.stop() + } + + @Test + fun anIdOnAHostWithoutIdReferencesIsRefusedNotDowngradedToAPath() = runTest { + val base = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, idAwareTransport(base, capabilities = emptyList()), StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + assertEquals(false, store.supportsWorkspaceIdReferences) + base.commands.clear() + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo", null, null, false, "ws-local")); advanceUntilIdle() + assertTrue(base.commands.none { it.cmd == "set_workspace" }, "no path fallback is sent for a known ID") + val ready = assertIs(store.state.value) + assertEquals(WorkspaceReferenceFailure.ID_REFERENCES_UNSUPPORTED, ready.workspaceReferenceFailure) + assertFalse(ready.busy) + assertFalse(ready.loadFailure) + store.dispatch(RemoteWorkspaceIntent.SelectAssistant("/assistant", "ws-assistant")); advanceUntilIdle() + assertTrue(base.commands.none { it.cmd == "set_assistant" }) + assertEquals(WorkspaceReferenceFailure.ID_REFERENCES_UNSUPPORTED, assertIs(store.state.value).workspaceReferenceFailure) + store.stop() + } + + @Test + fun anIdTheHostRejectsAndTheCatalogLacksIsReportedAsUnknown() = runTest { + val base = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, idAwareTransport(base), StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + base.selectionAccepted = false + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/gone", null, null, false, "ws-gone")); advanceUntilIdle() + assertEquals("ws-gone", base.commands.last { it.cmd == "set_workspace" }.workspaceId) + val ready = assertIs(store.state.value) + assertEquals(WorkspaceReferenceFailure.UNKNOWN_ID, ready.workspaceReferenceFailure) + assertEquals("/repo", ready.selected?.path, "the previous selection and catalog stay") + // A rejected ID the catalog does list is an ordinary failed request, not an unknown ID. + store.dispatch(RemoteWorkspaceIntent.SelectWorkspace("/repo", null, null, false, "ws-local")); advanceUntilIdle() + val retained = assertIs(store.state.value) + assertEquals(null, retained.workspaceReferenceFailure) + assertTrue(retained.loadFailure) + store.stop() + } + + @Test + fun assistantsAreSelectedByIdAndNeverMatchedByPathOnceIdsExist() = runTest { + val base = FakeWorkspaceTransport() + val store = RemoteWorkspaceStore.create(this, idAwareTransport(base), StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.SelectAssistant("/assistant")); advanceUntilIdle() + val resolved = base.commands.last { it.cmd == "set_assistant" } + assertEquals("ws-assistant", resolved.workspaceId, "a pre-ID path is upgraded to the row's ID") + assertEquals(null, resolved.path) + base.commands.clear() + store.dispatch(RemoteWorkspaceIntent.SelectAssistant("/assistant/", "ws-assistant")); advanceUntilIdle() + assertEquals("ws-assistant", base.commands.last { it.cmd == "set_assistant" }.workspaceId) + base.commands.clear() + // A path the assistant catalog does not know is refused rather than guessed. + store.dispatch(RemoteWorkspaceIntent.SelectAssistant("/not-an-assistant")); advanceUntilIdle() + assertTrue(base.commands.none { it.cmd == "set_assistant" }) + assertTrue(assertIs(store.state.value).loadFailure) + store.stop() + } + + @Test + fun theCacheKeepsAnAssistantThatSharesItsPathWithAProjectAndWritesIds() = runTest { + val base = FakeWorkspaceTransport() + val persistence = MemoryWorkspaceListStore() + val transport = idAwareTransport( + base, + workspaces = """[{"path":"/shared","name":"Project","workspace_id":"ws-project","workspace_kind":"normal"}]""", + assistants = """[{"path":"/shared","name":"Helper","assistant_id":"a1","workspace_id":"ws-helper"},{"path":"/shared","name":"Twin","assistant_id":"a2","workspace_id":"ws-project"}]""", + ) + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a", persistence) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + val rows = persistence.rows.getValue("device-a") + assertEquals(listOf("ws-project", "ws-helper"), rows.map { it.workspaceId }, "dedupe is by ID, not by path") + assertEquals(listOf("normal", "assistant"), rows.map { it.workspaceKind }) + store.stop() + // The cached catalog restores both rows with their IDs. + val restored = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a", persistence) + assertEquals(setOf("ws-project", "ws-helper"), restored.cachedCatalog().map { it.workspaceId }.toSet()) + restored.stop() + } + + @Test + fun deviceToolTerminalsAreKeyedByWorkspaceIdentityNotPath() = runTest { + val base = FakeWorkspaceTransport() + var terminalCount = 0 + val transport = object : RemoteCommandTransport { + override suspend fun send(deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long): T { + val wire = when (command.command) { + "get_directory_children_paginated" -> """{"resp":"host_invoke_result","ok":true,"value":{"children":[],"hasMore":false}}""" + "terminal_create" -> { terminalCount++; """{"resp":"host_invoke_result","ok":true,"value":{"id":"terminal-$terminalCount"}}""" } + else -> null + } + return if (wire == null) base.send(deserializer, command, timeoutMs) else RelayJson.decodeFromString(deserializer, wire) + } + } + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler)) + store.dispatch(RemoteWorkspaceIntent.Load); advanceUntilIdle() + // Two workspaces sharing a path on the same provider get separate terminals when addressed by ID. + store.dispatch(RemoteWorkspaceIntent.OpenDeviceTerminal("/repo", null, "ws-a")); advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenDeviceTerminal("/repo", null, "ws-b")); advanceUntilIdle() + assertEquals(2, terminalCount) + store.dispatch(RemoteWorkspaceIntent.OpenDeviceTerminal("/repo", null, "ws-a")); advanceUntilIdle() + assertEquals(2, terminalCount, "returning to a workspace ID reuses its PTY") + // A location without an ID keys by the legacy triple and is its own scope. + store.dispatch(RemoteWorkspaceIntent.OpenDeviceTerminal("/repo", null)); advanceUntilIdle() + assertEquals(3, terminalCount) + store.stop() + } +} + private class FakeWorkspaceTransport( private val downloadChunks: Boolean = false, private val readFileUnsupported: Boolean = false, diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogTest.kt index 5eeaddf870..46ea6f22d3 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/WorkspaceCatalogTest.kt @@ -9,6 +9,19 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class WorkspaceCatalogTest { + @Test + fun legacyLocalMarkersFollowKindAndRealLocalhostSshIsPreserved() { + val response = decode("""{"workspaces":[ + {"path":"/local","workspace_kind":"normal","remote_ssh_host":"localhost"}, + {"path":"/remote","workspace_kind":"remote","remote_ssh_host":"localhost","remote_connection_id":"saved"} + ]}""") + val catalog = response.sidebarCatalog(emptyList()) + assertNull(catalog.workspaces[0].remoteSshHost) + assertNull(catalog.recentWorkspaces[0].remoteSshHost) + assertEquals("localhost", catalog.workspaces[1].remoteSshHost) + assertEquals("saved", catalog.workspaces[1].remoteConnectionId) + } + private val assistants = listOf(WorkspaceAssistant("/assistant", "My assistant", "a")) @Test @@ -48,5 +61,17 @@ class WorkspaceCatalogTest { assertEquals(listOf(null, "ssh-a", "ssh-b"), catalog.workspaces.map { it.remoteConnectionId }) } + @Test + fun idsSurviveWireRoundTripAndSamePathCatalogRows() { + val response = decode("""{"workspaces":[],"opened_workspaces":[ + {"workspace_id":"local-id","path":"/shared","name":"Local","workspace_kind":"normal","remote_ssh_host":"localhost"}, + {"workspace_id":"remote-id","path":"/shared","name":"Remote","workspace_kind":"remote","remote_ssh_host":"localhost","remote_connection_id":"ssh"} + ]}""") + val catalog = response.sidebarCatalog(emptyList()) + assertEquals(listOf("local-id", "remote-id"), catalog.workspaces.map { it.workspaceId }) + assertNull(catalog.workspaces[0].remoteSshHost) + assertEquals("localhost", catalog.workspaces[1].remoteSshHost) + } + private fun decode(wire: String): RecentWorkspaceListResponse = RelayJson.decodeFromString(wire) } diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt index 9fee409e3e..0225ff2928 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt @@ -144,8 +144,23 @@ public data class PersistedWorkspaceIdentity( public val path: String = "", public val remoteConnectionId: String? = null, public val remoteSshHost: String? = null, + public val workspaceId: String? = null, ) +/** + * Cache row identity: the workspace ID when the host gave one, else the legacy + * `(connection, ssh host, path)` triple. Length-prefixed so embedded separators + * cannot make two different identities collide. + */ +public fun persistedWorkspaceKey(path: String, remoteConnectionId: String?, remoteSshHost: String?, workspaceId: String?): String { + workspaceId?.trim()?.takeIf { it.isNotEmpty() }?.let { return "workspace:${it.length}:$it" } + val root = path.trim().let { it.trimEnd('/').ifEmpty { it } } + return listOf(remoteConnectionId.orEmpty(), remoteSshHost.orEmpty(), root).joinToString("") { "${it.length}:$it" } +} + +public val PersistedWorkspaceIdentity.key: String + get() = persistedWorkspaceKey(path, remoteConnectionId, remoteSshHost, workspaceId) + @Serializable public data class PersistedRemoteSession public constructor( public val sessionId: String = "", @@ -191,7 +206,11 @@ public data class PersistedRemoteWorkspace public constructor( public val workspaceKind: String = "", public val remoteSshHost: String? = null, public val remoteConnectionId: String? = null, -) + public val workspaceId: String? = null, +) { + /** `workspaceId` when present, otherwise the legacy triple; see [persistedWorkspaceKey]. */ + public val key: String get() = persistedWorkspaceKey(path, remoteConnectionId, remoteSshHost, workspaceId) +} public interface RemoteSessionListStore { public fun load(deviceKey: String): List @@ -221,9 +240,15 @@ public class SqlDelightRemoteSessionListStore public constructor( override fun load(deviceKey: String): List = queries.selectRemoteSessions(deviceKey).executeAsList().map { row -> + // Rows written before the identity columns existed have NULL in all of + // them and stay legacy: ownership is then attributed by the caller + // through LegacyWorkspaceCompatibility, never invented here. + val identity = row.workspace_identity_path?.let { path -> + PersistedWorkspaceIdentity(path, row.remote_connection_id, row.remote_ssh_host, row.workspace_id) + } PersistedRemoteSession(row.session_id, row.title, row.agent_type, row.status, row.updated_at, row.created_at, row.message_count.toInt(), row.last_message_id, - row.workspace_path, row.workspace_name, row.pending_confirmed == 1L) + row.workspace_path, row.workspace_name, row.pending_confirmed == 1L, identity) } override fun hasMore(deviceKey: String): Boolean = @@ -259,16 +284,30 @@ public class SqlDelightRemoteSessionListStore public constructor( append(session.workspaceName.orEmpty()) append('\u0001') append(session.pendingConfirmed) + session.workspaceIdentity?.let { identity -> + append('\u0001') + append(identity.path) + append('\u0001') + append(identity.workspaceId.orEmpty()) + append('\u0001') + append(identity.remoteConnectionId.orEmpty()) + append('\u0001') + append(identity.remoteSshHost.orEmpty()) + } } } if (signature == lastSignature) return queries.transaction { queries.deleteRemoteSessionsForDevice(deviceKey) - kept.forEach { session -> queries.upsertRemoteSession( - deviceKey, session.sessionId, session.title, session.agentType, session.status, - session.updatedAt, session.createdAt, session.messageCount.toLong(), session.lastMessageId, - session.workspacePath, session.workspaceName, if (hasMore) 1L else 0L, - if (session.pendingConfirmed) 1L else 0L) + kept.forEach { session -> + val identity = session.workspaceIdentity + queries.upsertRemoteSession( + deviceKey, session.sessionId, session.title, session.agentType, session.status, + session.updatedAt, session.createdAt, session.messageCount.toLong(), session.lastMessageId, + session.workspacePath, session.workspaceName, if (hasMore) 1L else 0L, + if (session.pendingConfirmed) 1L else 0L, + identity?.path, identity?.workspaceId, identity?.remoteConnectionId, identity?.remoteSshHost, + ) } } lastSignature = signature @@ -359,27 +398,41 @@ public class SqlDelightRemoteWorkspaceListStore public constructor( override fun load(deviceKey: String): List = queries.selectRemoteWorkspaces(deviceKey).executeAsList().map { row -> - PersistedRemoteWorkspace(row.path, row.name, row.last_opened, row.workspace_kind) + PersistedRemoteWorkspace( + path = row.path, + name = row.name, + lastOpened = row.last_opened, + workspaceKind = row.workspace_kind, + remoteSshHost = row.remote_ssh_host, + remoteConnectionId = row.remote_connection_id, + workspaceId = row.workspace_id, + ) } override fun save(deviceKey: String, workspaces: List) { if (deviceKey.isBlank()) return - val kept = workspaces.distinctBy { it.path }.take(60) + // Identity, not path, decides duplicates: a local folder and an SSH folder + // at the same path are two workspaces and both rows are kept. + val kept = workspaces.distinctBy { it.key }.take(60) val signature = "$deviceKey|${kept.joinToString("\u0002") { workspace -> - listOf(workspace.path, workspace.name, workspace.lastOpened, workspace.workspaceKind) + listOf(workspace.path, workspace.name, workspace.lastOpened, workspace.workspaceKind, + workspace.workspaceId.orEmpty(), workspace.remoteConnectionId.orEmpty(), workspace.remoteSshHost.orEmpty()) .joinToString("\u0001") }}" if (signature == lastSignature) return queries.transaction { queries.deleteRemoteWorkspacesForDevice(deviceKey) kept.forEachIndexed { index, workspace -> - queries.upsertRemoteWorkspace( + queries.insertRemoteWorkspace( deviceKey, workspace.path, workspace.name, workspace.lastOpened, workspace.workspaceKind, index.toLong(), + workspace.workspaceId, + workspace.remoteConnectionId, + workspace.remoteSshHost, ) } } diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/6.sqm b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/6.sqm new file mode 100644 index 0000000000..3860bfff69 --- /dev/null +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/6.sqm @@ -0,0 +1,33 @@ +-- v6 -> v7: workspace identity for cached sessions and workspaces. +-- +-- Cached sessions remember which workspace owns them. The identity path is a +-- separate nullable column so an explicit local identity (no ID, no connection) +-- can still be told apart from a pre-identity row; all four stay NULL for rows +-- written by older builds, which keep loading as legacy path-only records. +ALTER TABLE remote_session_list ADD COLUMN workspace_identity_path TEXT; +ALTER TABLE remote_session_list ADD COLUMN workspace_id TEXT; +ALTER TABLE remote_session_list ADD COLUMN remote_connection_id TEXT; +ALTER TABLE remote_session_list ADD COLUMN remote_ssh_host TEXT; + +-- The workspace catalog was keyed by (device_key, path). Two workspaces on one +-- host may share a path (a local folder and an SSH folder, two SSH hosts), so +-- the row identity becomes the workspace ID or, for rows without one, the +-- legacy (connection, ssh host, path) triple. SQLite cannot drop a primary key +-- in place, so the table is rebuilt; every existing row is carried over with +-- NULL identity columns and its original order. +CREATE TABLE remote_workspace_list_v7 ( + device_key TEXT NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + last_opened TEXT NOT NULL, + workspace_kind TEXT NOT NULL, + seq INTEGER NOT NULL, + workspace_id TEXT, + remote_connection_id TEXT, + remote_ssh_host TEXT +); +INSERT INTO remote_workspace_list_v7(device_key, path, name, last_opened, workspace_kind, seq) +SELECT device_key, path, name, last_opened, workspace_kind, seq FROM remote_workspace_list ORDER BY device_key, seq; +DROP TABLE remote_workspace_list; +ALTER TABLE remote_workspace_list_v7 RENAME TO remote_workspace_list; +CREATE INDEX remote_workspace_list_device_seq_index ON remote_workspace_list(device_key, seq); diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq index c654cce74a..921d789477 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq @@ -79,6 +79,8 @@ INSERT OR REPLACE INTO chat_draft(draft_id, text, updated_at) VALUES (?, ?, ?); deleteDraft: DELETE FROM chat_draft WHERE draft_id = ?; +-- Owning workspace columns: NULL in all four means a pre-identity row; a non-NULL +-- workspace_identity_path with NULL id/connection/host is an explicit local workspace. CREATE TABLE remote_session_list ( device_key TEXT NOT NULL, session_id TEXT NOT NULL, @@ -93,6 +95,10 @@ CREATE TABLE remote_session_list ( workspace_name TEXT, has_more INTEGER NOT NULL DEFAULT 0, pending_confirmed INTEGER NOT NULL DEFAULT 0, + workspace_identity_path TEXT, + workspace_id TEXT, + remote_connection_id TEXT, + remote_ssh_host TEXT, PRIMARY KEY (device_key, session_id) ); @@ -125,6 +131,10 @@ CREATE TABLE remote_session_cursor ( PRIMARY KEY (device_key, session_id) ); +-- One row per workspace the device serves. Rows are identified by workspace_id +-- when the host provided one and by (remote_connection_id, remote_ssh_host, path) +-- otherwise, so the same path may appear more than once per device. The writer +-- rewrites a device's rows as a whole in seq order. CREATE TABLE remote_workspace_list ( device_key TEXT NOT NULL, path TEXT NOT NULL, @@ -132,15 +142,19 @@ CREATE TABLE remote_workspace_list ( last_opened TEXT NOT NULL, workspace_kind TEXT NOT NULL, seq INTEGER NOT NULL, - PRIMARY KEY (device_key, path) + workspace_id TEXT, + remote_connection_id TEXT, + remote_ssh_host TEXT ); +CREATE INDEX remote_workspace_list_device_seq_index ON remote_workspace_list(device_key, seq); + selectRemoteSessions: SELECT * FROM remote_session_list WHERE device_key = ? ORDER BY updated_at DESC, session_id ASC; upsertRemoteSession: -INSERT OR REPLACE INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, message_count, last_message_id, workspace_path, workspace_name, has_more, pending_confirmed) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT OR REPLACE INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, message_count, last_message_id, workspace_path, workspace_name, has_more, pending_confirmed, workspace_identity_path, workspace_id, remote_connection_id, remote_ssh_host) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); deleteRemoteSessionsForDevice: DELETE FROM remote_session_list WHERE device_key = ?; @@ -171,9 +185,9 @@ DELETE FROM remote_session_cursor WHERE device_key = ? AND session_id = ?; selectRemoteWorkspaces: SELECT * FROM remote_workspace_list WHERE device_key = ? ORDER BY seq ASC; -upsertRemoteWorkspace: -INSERT OR REPLACE INTO remote_workspace_list(device_key, path, name, last_opened, workspace_kind, seq) -VALUES (?, ?, ?, ?, ?, ?); +insertRemoteWorkspace: +INSERT INTO remote_workspace_list(device_key, path, name, last_opened, workspace_kind, seq, workspace_id, remote_connection_id, remote_ssh_host) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); deleteRemoteWorkspacesForDevice: DELETE FROM remote_workspace_list WHERE device_key = ?; diff --git a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt index 4ebacd932a..c512812f71 100644 --- a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt +++ b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt @@ -90,7 +90,9 @@ class RemotePersistenceStoreTest { parameters = 0, ).await() - MobileDatabase.Schema.migrate(driver, 3, 4).await() + // The current store reads the v7 identity columns, so the v3 row has to be + // carried through every migration up to the current version. + MobileDatabase.Schema.migrate(driver, 3, MobileDatabase.Schema.version).await() val migratedPendingValue = driver.executeQuery( identifier = null, sql = "SELECT pending_confirmed FROM remote_session_list WHERE session_id = 'session-v3'", @@ -214,13 +216,100 @@ class RemotePersistenceStoreTest { @Test fun migratesV4DatabaseWithAnEmptyWorkspaceCache() = runTest { val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - MobileDatabase.Schema.migrate(driver, 4, 5).await() + // Build the v4 schema the way a device did (v2 created the remote session + // tables), then carry it through every later migration: the v5 workspace + // cache is created empty and the v7 identity columns the current store + // reads are added on top. + MobileDatabase.Schema.migrate(driver, 2, 4).await() + MobileDatabase.Schema.migrate(driver, 4, MobileDatabase.Schema.version).await() val workspaces = SqlDelightRemoteWorkspaceListStore(driver) assertTrue(workspaces.load("device-a").isEmpty()) workspaces.save("device-a", listOf(PersistedRemoteWorkspace("/repo", "Repo"))) assertEquals("/repo", workspaces.load("device-a").single().path) } + @Test + fun workspaceIdentityColumnsRoundTripAndSamePathRowsCoexist() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + MobileDatabase.Schema.create(driver).await() + val workspaces = SqlDelightRemoteWorkspaceListStore(driver) + val local = PersistedRemoteWorkspace("/repo", "Repo", "today", "normal", workspaceId = "local-id") + val ssh = PersistedRemoteWorkspace("/repo", "Repo", "today", "remote", "host", "saved", "ssh-id") + val legacyLocal = PersistedRemoteWorkspace("/repo", "Old", "", "normal") + val legacySsh = PersistedRemoteWorkspace("/repo", "Old SSH", "", "remote", "host", "saved") + workspaces.save("device-a", listOf(local, ssh, legacyLocal, legacySsh, local.copy(name = "Duplicate"))) + val loaded = workspaces.load("device-a") + assertEquals(listOf(local, ssh, legacyLocal, legacySsh), loaded, "IDs dedupe, the same path with a different identity does not") + assertEquals(listOf("local-id", "ssh-id", null, null), loaded.map { it.workspaceId }) + assertEquals(listOf(null, "saved", null, "saved"), loaded.map { it.remoteConnectionId }) + assertEquals(listOf(null, "host", null, "host"), loaded.map { it.remoteSshHost }) + assertTrue(local.key != ssh.key && legacyLocal.key != legacySsh.key && local.key != legacyLocal.key) + assertEquals(local.key, local.copy(path = "/elsewhere", name = "Renamed").key, "an ID row keeps its identity when its projection changes") + } + + @Test + fun sessionWorkspaceIdentityRoundTripsIncludingExplicitLocalOwnership() = runTest { + val (sessions, _) = stores() + val rows = listOf( + session("legacy", "2026-01-04"), + session("local", "2026-01-03").copy(workspacePath = "/repo", workspaceIdentity = PersistedWorkspaceIdentity("/repo")), + session("ssh", "2026-01-02").copy(workspacePath = "/repo", workspaceIdentity = PersistedWorkspaceIdentity("/repo", "saved", "host")), + session("id", "2026-01-01").copy(workspacePath = "/moved", workspaceIdentity = PersistedWorkspaceIdentity("/repo", null, null, "ws-1")), + ) + sessions.save("device-a", rows) + assertEquals(rows, sessions.load("device-a")) + // Identity-only edits must not be swallowed by the rewrite signature. + sessions.save("device-a", rows.map { if (it.sessionId == "legacy") it.copy(workspaceIdentity = PersistedWorkspaceIdentity("/repo", null, null, "ws-2")) else it }) + assertEquals("ws-2", sessions.load("device-a").first { it.sessionId == "legacy" }.workspaceIdentity?.workspaceId) + } + + @Test + fun migratesV6RowsWithoutIdentityAsLegacyAndKeepsSamePathWorkspaces() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + // The remote tables are entirely created by migrations 2..5, so replaying + // them yields the exact v6 shape an upgrading install carries. + MobileDatabase.Schema.migrate(driver, 2, 6).await() + driver.execute( + identifier = null, + sql = """ + INSERT INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, + message_count, last_message_id, workspace_path, workspace_name, has_more, pending_confirmed) + VALUES ('device-v6', 'session-v6', 'Old', 'code', 'idle', '2026-02-03', '2026-01-02', 1, 'm1', '/repo', 'Repo', 0, 0) + """.trimIndent(), + parameters = 0, + ).await() + driver.execute( + identifier = null, + sql = """ + INSERT INTO remote_workspace_list(device_key, path, name, last_opened, workspace_kind, seq) + VALUES ('device-v6', '/repo', 'Repo', 'yesterday', 'normal', 1), ('device-v6', '/assistant', 'Assistant', '', 'assistant', 0) + """.trimIndent(), + parameters = 0, + ).await() + + MobileDatabase.Schema.migrate(driver, 6, 7).await() + + val sessions = SqlDelightRemoteSessionListStore(driver) + val legacySession = sessions.load("device-v6").single() + assertEquals("session-v6", legacySession.sessionId) + assertEquals("/repo", legacySession.workspacePath) + assertEquals(null, legacySession.workspaceIdentity, "pre-identity rows stay legacy; ownership is resolved by the reader") + + val workspaces = SqlDelightRemoteWorkspaceListStore(driver) + val legacyRows = workspaces.load("device-v6") + assertEquals(listOf("/assistant", "/repo"), legacyRows.map { it.path }, "original order survives the table rebuild") + assertTrue(legacyRows.all { it.workspaceId == null && it.remoteConnectionId == null && it.remoteSshHost == null }) + + // The rebuilt table accepts what the old primary key forbade. + workspaces.save("device-v6", legacyRows + listOf( + PersistedRemoteWorkspace("/repo", "Repo", "today", "remote", "host", "saved", "ssh-id"), + PersistedRemoteWorkspace("/repo", "Repo", "today", "normal", workspaceId = "local-id"), + )) + assertEquals(listOf(null, null, "ssh-id", "local-id"), workspaces.load("device-v6").map { it.workspaceId }) + sessions.save("device-v6", listOf(legacySession.copy(workspaceIdentity = PersistedWorkspaceIdentity("/repo", null, null, "local-id")))) + assertEquals("local-id", sessions.load("device-v6").single().workspaceIdentity?.workspaceId) + } + private fun session(id: String, updated: String) = PersistedRemoteSession( sessionId = id, title = "Title $id", agentType = "remote", status = "ready", updatedAt = updated, createdAt = updated, messageCount = 1, lastMessageId = "m0", diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt index 443ebd4833..e80023025c 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommand.kt @@ -64,6 +64,7 @@ public data class RemoteCommand( @SerialName("display_content") val displayContent: String? = null, @SerialName("plan_file_path") val planFilePath: String? = null, @SerialName("plan_name") val planName: String? = null, + @SerialName("workspace_id") val workspaceId: String? = null, @SerialName("workspace_path") val workspacePath: String? = null, @SerialName("path") val path: String? = null, @SerialName("remote_connection_id") val remoteConnectionId: String? = null, diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/SessionDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/SessionDtos.kt index edf290b64f..380a647ae2 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/SessionDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/SessionDtos.kt @@ -39,6 +39,7 @@ public data class SessionItemResponse( val messageCount: Int? = null, val workspacePath: String? = null, val workspaceName: String? = null, + val workspaceId: String? = null, /** Set when the session hangs off another one (btw/review/miniapp/subagent). */ val parentSessionId: String? = null, val relationshipKind: String? = null, @@ -59,6 +60,7 @@ public object SessionItemResponseSerializer : KSerializer { messageCount = json.wireInt("message_count"), workspacePath = json.wireString("workspace_path"), workspaceName = json.wireString("workspace_name"), + workspaceId = json.wireString("workspace_id"), parentSessionId = json.wireString("parent_session_id"), relationshipKind = json.wireString("relationship_kind"), ) @@ -82,6 +84,7 @@ public object SessionItemResponseSerializer : KSerializer { value.messageCount?.let { put("message_count", it) } value.workspacePath?.let { put("workspace_path", it) } value.workspaceName?.let { put("workspace_name", it) } + value.workspaceId?.let { put("workspace_id", it) } value.parentSessionId?.let { put("parent_session_id", it) } value.relationshipKind?.let { put("relationship_kind", it) } }, @@ -99,6 +102,7 @@ public data class SessionListResponse( @Serializable public data class InitialSyncResponse( + @SerialName("workspace_id") val workspaceId: String? = null, @SerialName("resp") override val resp: String? = null, @SerialName("message") override val message: String? = null, @SerialName("has_workspace") val hasWorkspace: Boolean? = null, @@ -107,6 +111,10 @@ public data class InitialSyncResponse( @SerialName("git_branch") val gitBranch: String? = null, @SerialName("workspace_kind") val workspaceKind: String? = null, @SerialName("assistant_id") val assistantId: String? = null, + @SerialName("remote_connection_id") val remoteConnectionId: String? = null, + @SerialName("remote_ssh_host") val remoteSshHost: String? = null, + /** Host feature list; `workspace_id_references_v1` is what allows ID-only workspace commands. */ + @SerialName("capabilities") val capabilities: List = emptyList(), @SerialName("sessions") val sessions: List = emptyList(), @SerialName("has_more_sessions") val hasMoreSessions: Boolean = false, @SerialName("authenticated_user_id") val authenticatedUserId: String? = null, @@ -119,6 +127,8 @@ public data class CreateSessionResponse( @SerialName("session_id") val sessionId: String? = null, @SerialName("id") val id: String? = null, @SerialName("title") val title: String? = null, + /** Set by ID-aware hosts; the created session's workspace identity is this ID. */ + @SerialName("workspace_id") val workspaceId: String? = null, @SerialName("workspace_path") val workspacePath: String? = null, @SerialName("remote_connection_id") val remoteConnectionId: String? = null, @SerialName("remote_ssh_host") val remoteSshHost: String? = null, diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt index 8170dc5507..559e6c19ab 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.json.put @Serializable public data class WorkspaceInfoResponse( + @SerialName("workspace_id") val workspaceId: String? = null, @SerialName("resp") override val resp: String? = null, @SerialName("message") override val message: String? = null, @SerialName("has_workspace") val hasWorkspace: Boolean? = null, @@ -46,6 +47,7 @@ public data class RecentWorkspaceEntryResponse( val workspaceKind: String? = null, val remoteSshHost: String? = null, val remoteConnectionId: String? = null, + val workspaceId: String? = null, ) public object RecentWorkspaceEntryResponseSerializer : KSerializer { @@ -54,6 +56,7 @@ public object RecentWorkspaceEntryResponseSerializer : KSerializer, pub workspace_service: Arc, - pub workspace_path: Arc>>, + /// Workspace record this Host activated at startup (opened from + /// `--workspace` or restored). Identity is the record ID; `root_path` is + /// only an IO operand. + pub initial_workspace: Option, + pub workspace_id: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, pub agent_registry: Arc, @@ -132,16 +136,10 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result, + /// Canonical root of the owned workspace. Only used to upgrade legacy + /// `workspacePath` requests to the owned workspace ID; never an identity. + #[allow(dead_code)] external_workspace_root: Option, allowed_browser_origins: Arc>, dispatch_host: Option>, @@ -180,7 +184,24 @@ async fn main() -> Result<()> { if let Err(error) = ssh_manager.load_known_hosts().await { tracing::warn!(error = %error, "Failed to load SSH known hosts"); } + // The Server Host's project workspace is the record bootstrap activated; + // external-source requests are scoped to it by workspace ID. + let external_workspace_id = server_state + .initial_workspace + .as_ref() + .map(|workspace| workspace.id.clone()); + let external_workspace_root = server_state + .initial_workspace + .as_ref() + .map(|workspace| { + workspace + .root_path + .canonicalize() + .unwrap_or_else(|_| workspace.root_path.clone()) + }) + .or(external_workspace_root); let app_state = AppState { + external_workspace_id, external_workspace_root, allowed_browser_origins: Arc::new(allowed_browser_origins), dispatch_host: Some(Arc::new(DispatchHostState { diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 389b3b6027..a9a08072e3 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -59,7 +59,7 @@ pub(crate) async fn dispatch( let request = params .get("request") .ok_or_else(|| ExternalSourceOperationError::invalid_request("missing request"))?; - let workspace = external_workspace_root(state, request)?; + let workspace = external_workspace_id(state, request)?; let workspace = workspace.as_deref(); match method { "get_external_source_snapshot" => { @@ -92,11 +92,42 @@ pub(crate) async fn dispatch( }) } +/// Resolve the workspace an external-source request is scoped to. +/// +/// `workspaceId` is authoritative and must name the workspace this Server +/// Host owns. `workspacePath` is accepted only as an upgrade path for pre-ID +/// callers: it is matched against the owned workspace root and then replaced +/// by the owned workspace ID, so paths never act as identity downstream. #[allow(dead_code)] -fn external_workspace_root( +fn external_workspace_id( state: &AppState, request: &serde_json::Value, -) -> ExternalSourceOperationResult> { +) -> ExternalSourceOperationResult> { + let requested_id = match request.get("workspaceId") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(id)) if !id.trim().is_empty() => Some(id.trim()), + _ => { + return Err(ExternalSourceOperationError::invalid_request( + "workspaceId must be a non-empty string when provided", + )) + } + }; + if let Some(requested_id) = requested_id { + let owned = state.external_workspace_id.as_deref().ok_or_else(|| { + ExternalSourceOperationError::new( + openbitfun_core::external_sources::ExternalSourceOperationErrorCode::HostUnavailable, + "The Server Host has no project workspace", + false, + ) + })?; + if requested_id != owned { + return Err(ExternalSourceOperationError::invalid_request( + "External compatibility is limited to the Server Host workspace", + )); + } + return Ok(Some(owned.to_string())); + } + let workspace = match request.get("workspacePath") { None | Some(serde_json::Value::Null) => None, Some(serde_json::Value::String(path)) if !path.trim().is_empty() => { @@ -116,24 +147,28 @@ fn external_workspace_root( let Some(requested) = workspace else { return Ok(None); }; - let owned = state.external_workspace_root.as_ref().ok_or_else(|| { - ExternalSourceOperationError::new( - openbitfun_core::external_sources::ExternalSourceOperationErrorCode::HostUnavailable, - "The Server Host has no project workspace", - false, - ) - })?; + let (owned_id, owned_root) = state + .external_workspace_id + .as_deref() + .zip(state.external_workspace_root.as_ref()) + .ok_or_else(|| { + ExternalSourceOperationError::new( + openbitfun_core::external_sources::ExternalSourceOperationErrorCode::HostUnavailable, + "The Server Host has no project workspace", + false, + ) + })?; let requested = requested.canonicalize().map_err(|_| { ExternalSourceOperationError::invalid_request( "Workspace path is not available on this Host", ) })?; - if &requested != owned { + if &requested != owned_root { return Err(ExternalSourceOperationError::invalid_request( "External compatibility is limited to the Server Host workspace", )); } - Ok(Some(requested)) + Ok(Some(owned_id.to_string())) } #[allow(dead_code)] @@ -156,6 +191,9 @@ mod tests { fn app_state(external_workspace_root: Option) -> AppState { AppState { + external_workspace_id: external_workspace_root + .as_ref() + .map(|_| "server-workspace".to_string()), external_workspace_root, allowed_browser_origins: Default::default(), dispatch_host: None, @@ -176,7 +214,7 @@ mod tests { fn workspace_paths_must_be_absolute() { let state = app_state(None); let request = serde_json::json!({ "workspacePath": "relative/project" }); - let error = external_workspace_root(&state, &request).unwrap_err(); + let error = external_workspace_id(&state, &request).unwrap_err(); assert_eq!(error.code.as_str(), "invalid_request"); } @@ -185,7 +223,7 @@ mod tests { let state = app_state(None); let workspace = std::env::current_dir().expect("current directory is available"); let request = serde_json::json!({ "workspacePath": workspace }); - let error = external_workspace_root(&state, &request).unwrap_err(); + let error = external_workspace_id(&state, &request).unwrap_err(); assert_eq!(error.code.as_str(), "host_unavailable"); } @@ -198,9 +236,36 @@ mod tests { let state = app_state(Some(workspace.clone())); let request = serde_json::json!({ "workspacePath": workspace }); assert_eq!( - external_workspace_root(&state, &request).unwrap(), - Some(workspace) + external_workspace_id(&state, &request).unwrap(), + Some("server-workspace".to_string()) + ); + } + + #[test] + fn workspace_ids_are_authoritative_and_must_match_the_owned_workspace() { + let workspace = std::env::current_dir().expect("current directory is available"); + let state = app_state(Some(workspace)); + let request = serde_json::json!({ "workspaceId": "server-workspace" }); + assert_eq!( + external_workspace_id(&state, &request).unwrap(), + Some("server-workspace".to_string()) ); + + let request = serde_json::json!({ + "workspaceId": "other-workspace", + "workspacePath": std::env::current_dir().expect("current directory is available"), + }); + let error = external_workspace_id(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "invalid_request"); + + let request = serde_json::json!({ "workspaceId": "" }); + let error = external_workspace_id(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "invalid_request"); + + let state = app_state(None); + let request = serde_json::json!({ "workspaceId": "server-workspace" }); + let error = external_workspace_id(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "host_unavailable"); } #[test] @@ -211,7 +276,7 @@ mod tests { let state = app_state(None); let request = serde_json::json!({ "workspacePath": false }); - let error = external_workspace_root(&state, &request).unwrap_err(); + let error = external_workspace_id(&state, &request).unwrap_err(); assert_eq!(error.code.as_str(), "invalid_request"); } diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 38fb37a931..dde3a33bef 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -103,6 +103,7 @@ mod tests { fn state_with_allowed_origins(origins: &[&str]) -> AppState { AppState { + external_workspace_id: None, external_workspace_root: None, allowed_browser_origins: std::sync::Arc::new( origins.iter().map(|origin| (*origin).to_string()).collect(), diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs index 835f2ee41c..8a8affdce3 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/client.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/client.rs @@ -101,7 +101,9 @@ impl RuntimeIpcClient { if connect_timeout.is_zero() || request_timeout.is_zero() { return Err(RuntimeIpcClientError::InvalidTimeout); } - if discovery.protocol_version != PROTOCOL_VERSION { + if discovery.protocol_version != PROTOCOL_VERSION + && discovery.protocol_version != crate::protocol::LEGACY_WORKSPACE_PATH_PROTOCOL_VERSION + { return Err(RuntimeIpcClientError::IncompatibleProtocol { expected: PROTOCOL_VERSION, observed: discovery.protocol_version, @@ -123,7 +125,7 @@ impl RuntimeIpcClient { &RuntimeIpcFrame::Initialize { request_id, request: InitializeRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: discovery.protocol_version, instance_identity: discovery.instance_identity.as_str().to_string(), token: discovery.token.clone(), client_id: client_id.to_string(), @@ -143,7 +145,7 @@ impl RuntimeIpcClient { request_id: response_id, result, } if response_id == request_id - && result.protocol_version == PROTOCOL_VERSION + && result.protocol_version == discovery.protocol_version && result.instance_identity == discovery.instance_identity.as_str() && result.capabilities.health => { diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs index 233d3d5979..2dbfca7467 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -49,3 +49,5 @@ mod protocol_contract_tests; #[cfg(test)] #[path = "tests/shared_controller.rs"] mod shared_controller_tests; + +pub use operation::legacy_workspace_operation; diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 02629e93c9..823b2a164f 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -17,6 +17,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeSessionRestoreRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID wire field. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub session_id: String, } @@ -484,6 +488,7 @@ mod tests { fn restore_and_pending_permission_rules_preserve_existing_behavior() { let restore = RuntimeIpcOperation::RestoreSession { request: RuntimeSessionRestoreRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), session_id: "session-2".to_string(), }, @@ -514,6 +519,7 @@ mod tests { fn lineage_rules_keep_root_controller_and_allow_active_read_only_inspection() { let query = RuntimeIpcOperation::GetSessionLineage { request: AgentSessionLineageRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), anchor_session_id: "root-1".to_string(), remote_connection_id: None, @@ -522,6 +528,7 @@ mod tests { }; let inspect = RuntimeIpcOperation::InspectLineageSession { request: AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), root_session_id: "root-1".to_string(), session_id: "child-1".to_string(), @@ -532,6 +539,7 @@ mod tests { }; let cancel = RuntimeIpcOperation::CancelLineageSession { request: AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), root_session_id: "root-1".to_string(), session_id: "child-1".to_string(), @@ -566,3 +574,26 @@ mod tests { assert!(cancel_rules.side_effecting); } } + +/// Temporary serialization boundary for a pre-ID Shared Runtime (protocol 18). +/// Paths here are projections of an already selected host-owned workspace, never +/// lookup keys in current code. Remove when support for protocol 18 sunsets. +pub fn legacy_workspace_operation( + operation: RuntimeIpcOperation, + workspace_root: &str, +) -> RuntimeIpcOperation { + use RuntimeIpcOperation::{ListSessions, RestoreSession}; + match operation { + ListSessions { mut request } => { + request.workspace_id = None; + request.workspace_path = workspace_root.to_owned(); + ListSessions { request } + } + RestoreSession { mut request } => { + request.workspace_id = None; + request.workspace_path = workspace_root.to_owned(); + RestoreSession { request } + } + other => other, + } +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index cbb5428772..344ec14f97 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,9 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use openbitfun_events::AgenticEventEnvelope; use openbitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 18; +pub const PROTOCOL_VERSION: u32 = 19; +/// Upgrade-only predecessor accepted by current clients. +pub(crate) const LEGACY_WORKSPACE_PATH_PROTOCOL_VERSION: u32 = 18; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] @@ -110,6 +112,8 @@ pub struct InitializeResult { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RuntimeIpcCapabilities { + #[serde(default)] + pub workspace_id_references: bool, pub health: bool, #[serde(default)] pub interactive_tui: bool, diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 6106a584be..2ab9ca0f36 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -273,6 +273,7 @@ async fn handle_connection( instance_identity: config.instance_identity.clone(), server_version: config.server_version.clone(), capabilities: RuntimeIpcCapabilities { + workspace_id_references: true, health: true, interactive_tui, }, diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index aae0e2ae3a..2889f8ffec 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -19,8 +19,8 @@ use openbitfun_runtime_ports::{ use serde_json::{json, Map}; #[test] -fn shared_runtime_protocol_stays_at_version_18() { - assert_eq!(PROTOCOL_VERSION, 18); +fn shared_runtime_protocol_supports_workspace_ids_at_version_19() { + assert_eq!(PROTOCOL_VERSION, 19); } #[test] @@ -80,7 +80,7 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() { #[test] fn protocol_round_trips_read_only_main_agent_catalog() { - assert_eq!(PROTOCOL_VERSION, 18); + assert_eq!(PROTOCOL_VERSION, 19); let operation = RuntimeIpcOperation::ListAgentModes { session_id: Some("session-1".to_string()), }; @@ -136,7 +136,7 @@ fn protocol_round_trips_read_only_main_agent_catalog() { #[test] fn protocol_round_trips_exact_turn_steering_without_replacing_turn_admission() { - assert_eq!(PROTOCOL_VERSION, 18); + assert_eq!(PROTOCOL_VERSION, 19); let operation = RuntimeIpcOperation::SteerTurn { request: AgentDialogSteerRequest { session_id: "session-1".to_string(), @@ -212,6 +212,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { let operations = vec![ RuntimeIpcOperation::GetSessionLineage { request: AgentSessionLineageRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), anchor_session_id: "root-1".to_string(), remote_connection_id: None, @@ -220,6 +221,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { }, RuntimeIpcOperation::InspectLineageSession { request: AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), root_session_id: "root-1".to_string(), session_id: "child-1".to_string(), @@ -230,6 +232,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { }, RuntimeIpcOperation::CancelLineageSession { request: AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), root_session_id: "root-1".to_string(), session_id: "child-1".to_string(), @@ -254,7 +257,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { #[test] fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { - assert_eq!(PROTOCOL_VERSION, 18); + assert_eq!(PROTOCOL_VERSION, 19); let operation = RuntimeIpcOperation::WorkspaceDiff; let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); @@ -376,7 +379,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 18); + assert_eq!(PROTOCOL_VERSION, 19); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { @@ -481,6 +484,7 @@ fn protocol_round_trips_manual_compaction_as_an_idle_controller_turn() { fn protocol_round_trips_undo_as_an_active_controller_operation() { let operation = RuntimeIpcOperation::UndoSession { request: AgentSessionRevertRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), session_id: "session-1".to_string(), remote_connection_id: None, @@ -594,6 +598,7 @@ fn submit_turn_accepts_the_existing_64_kib_tui_paste_contract() { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some("D:/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), @@ -626,3 +631,30 @@ fn question_interaction_requires_current_session_controller() { RuntimeIpcSessionRequirement::CurrentController ); } + +#[test] +fn workspace_identity_capability_and_legacy_serializer_are_explicit() { + let legacy: crate::RuntimeIpcCapabilities = serde_json::from_value(json!({ + "health": true, "interactive_tui": true + })) + .unwrap(); + assert!(!legacy.workspace_id_references); + let current = RuntimeIpcOperation::RestoreSession { + request: crate::RuntimeSessionRestoreRequest { + workspace_id: Some("workspace-1".into()), + workspace_path: String::new(), + session_id: "session-1".into(), + }, + }; + let json = serde_json::to_value(¤t).unwrap(); + assert_eq!(json["request"]["workspaceId"], "workspace-1"); + assert!(json["request"].get("workspacePath").is_none()); + let projected = crate::legacy_workspace_operation(current, "/legacy-project"); + let json = serde_json::to_value(&projected).unwrap(); + assert!(json["request"].get("workspaceId").is_none()); + assert_eq!(json["request"]["workspacePath"], "/legacy-project"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + projected + ); +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index d5c0343075..8057716159 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -483,6 +483,7 @@ async fn cancellation_supersedes_a_slow_lineage_read_on_the_same_client() { inspect_client .request(RuntimeIpcOperation::InspectLineageSession { request: AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: inspect_workspace_path, root_session_id: "session-a".to_string(), session_id: "session-child".to_string(), @@ -504,6 +505,7 @@ async fn cancellation_supersedes_a_slow_lineage_read_on_the_same_client() { Duration::from_millis(300), client.request(RuntimeIpcOperation::CancelLineageSession { request: AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path, root_session_id: "session-a".to_string(), session_id: "session-child".to_string(), @@ -570,6 +572,7 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it() 2, RuntimeIpcOperation::RestoreSession { request: RuntimeSessionRestoreRequest { + workspace_id: None, workspace_path: "fixture-workspace".to_string(), session_id: "session-a".to_string(), }, @@ -636,6 +639,8 @@ fn restored(session_id: &str) -> RuntimeIpcOperationResult { fn workspace_binding() -> AgentSessionWorkspaceBinding { AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace-fixture".to_string()), workspace_path: "/workspace".to_string(), project_workspace_path: Some("/workspace".to_string()), @@ -659,6 +664,7 @@ fn test_identity(workspace: &Path) -> RuntimeInstanceIdentity { fn restore_operation(workspace: &Path, session_id: &str) -> RuntimeIpcOperation { RuntimeIpcOperation::RestoreSession { request: RuntimeSessionRestoreRequest { + workspace_id: None, workspace_path: workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), }, @@ -694,6 +700,7 @@ fn submit_operation(workspace: &Path, session_id: &str, turn_id: &str) -> Runtim execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some(workspace.to_string_lossy().to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), @@ -740,6 +747,7 @@ fn compact_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { fn undo_operation(workspace: &Path, session_id: &str) -> RuntimeIpcOperation { RuntimeIpcOperation::UndoSession { request: AgentSessionRevertRequest { + workspace_id: None, workspace_path: workspace.to_string_lossy().to_string(), session_id: session_id.to_string(), remote_connection_id: None, diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 839e3e4db4..68d57bbbdb 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -290,3 +290,22 @@ cargo test -p openbitfun-core --no-default-features --features remote-connect,to `tools-pages` selects only the Pages tool group. Account host wiring additionally requires `remote-connect`; CLI and Desktop select both explicitly. Pages does not select MiniApp runtime or market dependencies. + +Scheduled-job workspace identity and the temporary 1.0.0 target upgrade boundary: + +```bash +cargo test -p openbitfun-core --no-default-features --features agent-runtime,scheduled-jobs,git --lib service::cron::service::tests +cargo test -p openbitfun-core --no-default-features --features agent-runtime,scheduled-jobs,git --lib cron_100_target_upgrades_once +``` + +For workspace-ID fork ownership and pre-ID session-directory upgrade coverage: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib session_fork_ +``` + +For remote search ID binding without a live SSH connection: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,ssh-remote --lib service::search::remote::identity_tests +``` diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 2d32c81754..c1587f507a 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -195,6 +195,11 @@ product-full = [ "runtime-services", "tool-packs", ] +# Test-only constructors for downstream in-process tests, such as +# `WorkspaceService::new_isolated_for_tests`, which keep fixture workspace +# records out of the developer's real user data directory. Enable only from +# `[dev-dependencies]`; it is not part of any product assembly. +test-support = [] # Core compatibility facade and concrete product Agent Runtime assembly. This # owns the existing agent/session/tool lifecycle, not app presentation or # external ecosystem discovery. diff --git a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs index 564eb87065..96898b2e02 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs @@ -49,10 +49,25 @@ impl AgentRegistry { } /// Load user custom agents globally and project subagents for the given workspace. - pub async fn load_custom_agents(&self, workspace_root: Option<&Path>) { + /// + /// A remote workspace has no locally discoverable project agents; the + /// registry still reloads user-level custom agents and publishes an empty + /// project set under that workspace ID so callers do not retry the scan on + /// every query. Only an unknown workspace ID aborts the load. + pub async fn load_custom_agents(&self, workspace_id: Option<&str>) { + let root = match workspace_id { + Some(id) => match super::support::project_agent_discovery_root(id).await { + Ok(root) => root, + Err(error) => { + log::warn!("Cannot load workspace agents: {error}"); + return; + } + }, + None => None, + }; self.load_custom_agents_from_discovery_roots( - workspace_root, - &custom_agent_discovery_roots(workspace_root), + workspace_id, + &custom_agent_discovery_roots(root.as_deref()), ) .await; } @@ -60,28 +75,28 @@ impl AgentRegistry { #[cfg(test)] pub(crate) async fn load_custom_agents_from_test_roots( &self, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, roots: &CustomAgentDiscoveryRoots, ) { - self.load_custom_agents_from_discovery_roots(workspace_root, roots) + self.load_custom_agents_from_discovery_roots(workspace_id, roots) .await; } async fn load_custom_agents_from_discovery_roots( &self, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, roots: &CustomAgentDiscoveryRoots, ) { let mut state = self.custom_load_state.lock().await; state.prepare(roots).await; state.published = false; - self.scan_custom_agents(workspace_root, roots).await; + self.scan_custom_agents(workspace_id, roots).await; state.published = true; } async fn scan_custom_agents( &self, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, roots: &CustomAgentDiscoveryRoots, ) { let valid_tools = get_all_registered_tool_names().await; @@ -168,7 +183,7 @@ impl AgentRegistry { } } - if let Some(root) = workspace_root { + if let Some(root) = workspace_id { let map = self.read_agents(); let filtered_project_entries = project_entries .into_iter() @@ -185,15 +200,15 @@ impl AgentRegistry { .collect(); drop(map); self.write_project_subagents() - .insert(root.to_path_buf(), filtered_project_entries); + .insert(root.to_owned(), filtered_project_entries); } self.set_user_custom_agents_loaded(true); } /// Compatibility wrapper for existing project-subagent callers. - pub async fn load_custom_subagents(&self, workspace_root: &Path) { - self.load_custom_agents(Some(workspace_root)).await; + pub async fn load_custom_subagents(&self, workspace_id: &str) { + self.load_custom_agents(Some(workspace_id)).await; } async fn get_valid_model_ids() -> Vec { @@ -299,13 +314,13 @@ impl AgentRegistry { pub fn get_custom_agent_config( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option { if let Some(entry) = self.read_agents().get(agent_id) { return entry.custom_config.clone(); } - workspace_root + workspace_id .and_then(|root| self.read_project_subagents().get(root).cloned()) .and_then(|entries| entries.get(agent_id).cloned()) .and_then(|entry| entry.custom_config) @@ -314,9 +329,9 @@ impl AgentRegistry { pub fn get_custom_subagent_config( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option { - self.find_agent_entry(agent_id, workspace_root) + self.find_agent_entry(agent_id, workspace_id) .filter(|entry| entry.category == AgentCategory::SubAgent) .and_then(|entry| entry.custom_config) } @@ -336,7 +351,7 @@ impl AgentRegistry { agent_id: &str, model: Option, clear_model_override: bool, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult<()> { let mut map = self.write_agents(); if let Some(entry) = map.get_mut(agent_id) { @@ -344,17 +359,17 @@ impl AgentRegistry { } drop(map); - let workspace_root = workspace_root.ok_or_else(|| { + let workspace_id = workspace_id.ok_or_else(|| { OpenBitFunError::agent(format!( - "workspace_path is required to update project custom agent '{}'", + "workspace_id is required to update project custom agent '{}'", agent_id )) })?; let mut project_maps = self.write_project_subagents(); - let entries = project_maps.get_mut(workspace_root).ok_or_else(|| { + let entries = project_maps.get_mut(workspace_id).ok_or_else(|| { OpenBitFunError::agent(format!( "Project custom agents are not loaded for workspace: {}", - workspace_root.display() + workspace_id )) })?; let entry = entries @@ -369,13 +384,13 @@ impl AgentRegistry { agent_id: &str, model: Option, clear_model_override: bool, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult<()> { self.update_and_save_custom_agent_config( agent_id, model, clear_model_override, - workspace_root, + workspace_id, ) } @@ -434,23 +449,21 @@ impl AgentRegistry { pub async fn get_custom_agent_detail( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { self.ensure_user_custom_agents_loaded().await; - if let Some(root) = workspace_root { + if let Some(root) = workspace_id { self.load_custom_agents(Some(root)).await; } - self.get_custom_agent_detail_inner(agent_id, workspace_root) + self.get_custom_agent_detail_inner(agent_id, workspace_id) } pub async fn get_custom_subagent_detail( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { - let detail = self - .get_custom_agent_detail(agent_id, workspace_root) - .await?; + let detail = self.get_custom_agent_detail(agent_id, workspace_id).await?; if detail.kind != "subagent" { return Err(OpenBitFunError::agent(format!( "Agent '{}' is not a subagent", @@ -463,13 +476,13 @@ impl AgentRegistry { pub async fn get_custom_subagent_detail_by_key( &self, agent_key: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { self.ensure_user_custom_agents_loaded().await; - if let Some(root) = workspace_root { + if let Some(root) = workspace_id { self.load_custom_agents(Some(root)).await; } - let detail = self.get_custom_agent_detail_by_key_inner(agent_key, workspace_root)?; + let detail = self.get_custom_agent_detail_by_key_inner(agent_key, workspace_id)?; if detail.kind != "subagent" { return Err(OpenBitFunError::agent(format!( "Agent '{}' is not a subagent", @@ -482,10 +495,10 @@ impl AgentRegistry { fn get_custom_agent_detail_inner( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { let entry = self - .find_agent_entry(agent_id, workspace_root) + .find_agent_entry(agent_id, workspace_id) .ok_or_else(|| OpenBitFunError::agent(format!("Agent not found: {}", agent_id)))?; Self::custom_agent_detail_from_entry(agent_id, entry) } @@ -493,7 +506,7 @@ impl AgentRegistry { pub(super) fn get_custom_agent_detail_by_key_inner( &self, agent_key: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { let entry = { let agents = self.read_agents(); @@ -506,7 +519,7 @@ impl AgentRegistry { .cloned() } .or_else(|| { - let root = workspace_root?; + let root = workspace_id?; self.read_project_subagents() .get(root) .and_then(|entries| { @@ -606,7 +619,7 @@ impl AgentRegistry { pub async fn update_custom_agent_definition( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, name: String, description: String, prompt: String, @@ -617,11 +630,11 @@ impl AgentRegistry { model: Option, ) -> OpenBitFunResult<()> { self.ensure_user_custom_agents_loaded().await; - if let Some(root) = workspace_root { + if let Some(root) = workspace_id { self.load_custom_agents(Some(root)).await; } let entry = self - .find_agent_entry(agent_id, workspace_root) + .find_agent_entry(agent_id, workspace_id) .ok_or_else(|| OpenBitFunError::agent(format!("Agent not found: {}", agent_id)))?; if entry.source == AgentSource::Builtin { return Err(OpenBitFunError::agent( @@ -716,13 +729,13 @@ impl AgentRegistry { }; save_runtime_custom_agent(&replacement)?; - self.replace_custom_agent_entry(agent_id, workspace_root, replacement) + self.replace_custom_agent_entry(agent_id, workspace_id, replacement) } pub async fn update_custom_subagent_definition( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, description: String, prompt: String, tools: Option>, @@ -730,11 +743,11 @@ impl AgentRegistry { review: Option, ) -> OpenBitFunResult<()> { let detail = self - .get_custom_subagent_detail(agent_id, workspace_root) + .get_custom_subagent_detail(agent_id, workspace_id) .await?; self.update_custom_agent_definition( agent_id, - workspace_root, + workspace_id, detail.name, description, prompt, @@ -750,7 +763,7 @@ impl AgentRegistry { fn replace_custom_agent_entry( &self, agent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, new_agent: Arc, ) -> OpenBitFunResult<()> { let mut map = self.write_agents(); @@ -782,7 +795,7 @@ impl AgentRegistry { } drop(map); - let root = workspace_root.ok_or_else(|| { + let root = workspace_id.ok_or_else(|| { OpenBitFunError::agent( "Workspace path is required to update project subagent".to_string(), ) @@ -872,7 +885,7 @@ impl AgentRegistry { parent_agent_type: &str, agent_id: &str, enabled: bool, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult<()> { let parent_agent_type = parent_agent_type.trim(); if parent_agent_type.is_empty() { @@ -882,7 +895,7 @@ impl AgentRegistry { } let entry = self - .find_agent_entry(agent_id, workspace_root) + .find_agent_entry(agent_id, workspace_id) .ok_or_else(|| OpenBitFunError::agent(format!("Subagent not found: {}", agent_id)))?; if entry.category != AgentCategory::SubAgent { return Err(OpenBitFunError::agent(format!( @@ -904,14 +917,14 @@ impl AgentRegistry { match entry.subagent_source { Some(SubAgentSource::Project) => { - let workspace_root = workspace_root.ok_or_else(|| { + let workspace_id = workspace_id.ok_or_else(|| { OpenBitFunError::agent(format!( - "workspace_path is required to update project subagent availability for '{}'", + "workspace_id is required to update project subagent availability for '{}'", agent_id )) })?; let mut project_overrides = - load_project_subagent_overrides_local(workspace_root).await?; + load_project_subagent_overrides_local(workspace_id).await?; if enabled == default_enabled { prune_override_config(&mut project_overrides, parent_agent_type, &subagent_key); } else { @@ -922,7 +935,7 @@ impl AgentRegistry { state, ); } - save_project_subagent_overrides_local(workspace_root, &project_overrides).await?; + save_project_subagent_overrides_local(workspace_id, &project_overrides).await?; Ok(()) } Some(SubAgentSource::Builtin) | Some(SubAgentSource::User) => { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index 3d9abd1b15..0c192279a4 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -4,7 +4,6 @@ use crate::agentic::agents::{Agent, SubagentVisibilityPolicy}; use crate::agentic::deep_review_policy::{ CODE_REVIEW_AGENT_TYPE, DEEP_REVIEW_AGENT_TYPE, REVIEW_FIXER_AGENT_TYPE, }; -use crate::agentic::workspace::canonical_local_workspace_path; use log::{debug, warn}; use openbitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; use openbitfun_core_types::{ @@ -13,7 +12,6 @@ use openbitfun_core_types::{ use openbitfun_product_domains::external_sources::EcosystemId; use openbitfun_product_domains::external_subagents::ExternalSubagentMode; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; #[cfg(feature = "external-sources")] @@ -96,9 +94,9 @@ struct ExternalSubagentGenerationEntry { pub(super) struct ExternalSubagentRegistryState { generations: RwLock>, - workspace_routes: RwLock>>, + workspace_routes: RwLock>>, workspace_route_overlays: - RwLock>>>, + RwLock>>>, } impl ExternalSubagentRegistryState { @@ -128,7 +126,7 @@ impl ExternalSubagentRegistryState { fn read_routes( &self, - ) -> std::sync::RwLockReadGuard<'_, HashMap>> + ) -> std::sync::RwLockReadGuard<'_, HashMap>> { self.workspace_routes .read() @@ -137,7 +135,7 @@ impl ExternalSubagentRegistryState { fn write_routes( &self, - ) -> std::sync::RwLockWriteGuard<'_, HashMap>> + ) -> std::sync::RwLockWriteGuard<'_, HashMap>> { self.workspace_routes .write() @@ -148,7 +146,7 @@ impl ExternalSubagentRegistryState { &self, ) -> std::sync::RwLockReadGuard< '_, - HashMap>>, + HashMap>>, > { self.workspace_route_overlays .read() @@ -159,7 +157,7 @@ impl ExternalSubagentRegistryState { &self, ) -> std::sync::RwLockWriteGuard< '_, - HashMap>>, + HashMap>>, > { self.workspace_route_overlays .write() @@ -168,14 +166,14 @@ impl ExternalSubagentRegistryState { fn effective_routes_for_workspace( &self, - workspace_root: &Path, + workspace_id: &str, ) -> BTreeMap { let mut effective = self .read_routes() - .get(workspace_root) + .get(workspace_id) .cloned() .unwrap_or_default(); - if let Some(overlays) = self.read_route_overlays().get(workspace_root) { + if let Some(overlays) = self.read_route_overlays().get(workspace_id) { // Owner keys provide deterministic overlay precedence. The // OpenCode Config owner is currently the only overlay publisher; // unlike the base table, removing it reveals the latest route @@ -199,12 +197,12 @@ impl ExternalSubagentRegistryState { pub(super) fn find_external_route_entry( &self, logical_id: &str, - workspace_root: &Path, + workspace_id: &str, ) -> Option { - let workspace_root = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let logical_key = normalize_external_logical_id(logical_id); let runtime_key = match self - .effective_routes_for_workspace(&workspace_root) + .effective_routes_for_workspace(&workspace_id) .get(&logical_key)? { ExternalSubagentRoute::External(runtime_key) => runtime_key.clone(), @@ -374,20 +372,16 @@ impl AgentRegistry { pub(super) fn find_external_route_entry( &self, logical_id: &str, - workspace_root: &Path, + workspace_id: &str, ) -> Option { self.external_subagents - .find_external_route_entry(logical_id, workspace_root) + .find_external_route_entry(logical_id, workspace_id) } /// Returns whether the logical id is owned by an external route in the /// requested workspace. `Unavailable` remains externally owned so a /// withdrawn candidate cannot expose a same-name local mutation path. - pub fn is_external_subagent_route( - &self, - logical_id: &str, - workspace_root: Option<&Path>, - ) -> bool { + pub fn is_external_subagent_route(&self, logical_id: &str, workspace_id: Option<&str>) -> bool { let logical_key = normalize_external_logical_id(logical_id); let is_external = |route: &ExternalSubagentRoute| { matches!( @@ -395,8 +389,8 @@ impl AgentRegistry { ExternalSubagentRoute::External(_) | ExternalSubagentRoute::Unavailable ) }; - workspace_root.is_some_and(|workspace| { - let workspace = canonical_local_workspace_path(workspace); + workspace_id.is_some_and(|workspace| { + let workspace = workspace.to_owned(); self.external_subagents .effective_routes_for_workspace(&workspace) .get(&logical_key) @@ -406,12 +400,12 @@ impl AgentRegistry { pub fn install_external_subagent_routes( &self, - workspace_root: &Path, + workspace_id: &str, registrations: Vec, routes: BTreeMap, ) { self.install_external_subagent_routes_with_policy( - workspace_root, + workspace_id, registrations, routes, true, @@ -423,12 +417,12 @@ impl AgentRegistry { /// same-name local Agent becomes visible again. pub fn replace_external_subagent_routes( &self, - workspace_root: &Path, + workspace_id: &str, registrations: Vec, routes: BTreeMap, ) { self.install_external_subagent_routes_with_policy( - workspace_root, + workspace_id, registrations, routes, false, @@ -437,12 +431,12 @@ impl AgentRegistry { fn install_external_subagent_routes_with_policy( &self, - workspace_root: &Path, + workspace_id: &str, registrations: Vec, routes: BTreeMap, preserve_missing_external_routes: bool, ) { - let workspace_root = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); { let mut generations = self.external_subagents.write_generations(); for registration in registrations { @@ -475,7 +469,7 @@ impl AgentRegistry { let previous = self .external_subagents .read_routes() - .get(&workspace_root) + .get(&workspace_id) .cloned() .unwrap_or_default(); // Discovery and temporary-unavailable updates preserve missing @@ -494,15 +488,13 @@ impl AgentRegistry { } self.external_subagents .write_routes() - .insert(workspace_root, routes); + .insert(workspace_id, routes); self.external_subagents.prune_unrouted_generations(); } - pub fn release_external_subagent_workspace(&self, workspace_root: &Path) { - let workspace_root = canonical_local_workspace_path(workspace_root); - self.external_subagents - .write_routes() - .remove(&workspace_root); + pub fn release_external_subagent_workspace(&self, workspace_id: &str) { + let workspace_id = workspace_id.to_owned(); + self.external_subagents.write_routes().remove(&workspace_id); self.external_subagents.prune_unrouted_generations(); } @@ -511,12 +503,12 @@ impl AgentRegistry { /// routes for the workspace. pub fn replace_external_subagent_route_overlay( &self, - workspace_root: &Path, + workspace_id: &str, owner: &str, registrations: Vec, routes: BTreeMap, ) { - let workspace_root = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let routes = routes .into_iter() .map(|(logical_id, route)| (normalize_external_logical_id(&logical_id), route)) @@ -551,19 +543,19 @@ impl AgentRegistry { ); } } - let workspace_overlays = overlays.entry(workspace_root).or_default(); + let workspace_overlays = overlays.entry(workspace_id).or_default(); workspace_overlays.insert(owner.to_string(), routes); drop(overlays); self.external_subagents.prune_unrouted_generations(); } - pub fn release_external_subagent_route_overlay(&self, workspace_root: &Path, owner: &str) { - let workspace_root = canonical_local_workspace_path(workspace_root); + pub fn release_external_subagent_route_overlay(&self, workspace_id: &str, owner: &str) { + let workspace_id = workspace_id.to_owned(); let mut overlays = self.external_subagents.write_route_overlays(); - if let Some(workspace_overlays) = overlays.get_mut(&workspace_root) { + if let Some(workspace_overlays) = overlays.get_mut(&workspace_id) { workspace_overlays.remove(owner); if workspace_overlays.is_empty() { - overlays.remove(&workspace_root); + overlays.remove(&workspace_id); } } drop(overlays); @@ -573,13 +565,13 @@ impl AgentRegistry { pub fn resolve_subagent_for_fresh_invocation( &self, logical_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, external_sources_supported: bool, ) -> Option { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { - if let Some(workspace_root) = workspace_root { - let workspace_key = canonical_local_workspace_path(workspace_root); + if let Some(workspace_id) = workspace_id { + let workspace_key = workspace_id.to_owned(); if let Some(route) = self .external_subagents .effective_routes_for_workspace(&workspace_key) @@ -588,7 +580,7 @@ impl AgentRegistry { { return match route { ExternalSubagentRoute::Local => self - .find_agent_entry(logical_id, Some(workspace_root)) + .find_agent_entry(logical_id, Some(workspace_id)) .map(|entry| local_binding(logical_id, entry.agent.id())), ExternalSubagentRoute::External(runtime_key) => { self.external_subagents.acquire(&runtime_key) @@ -598,7 +590,7 @@ impl AgentRegistry { } } } - self.find_agent_entry(logical_id, workspace_root) + self.find_agent_entry(logical_id, workspace_id) .map(|entry| local_binding(logical_id, entry.agent.id())) } @@ -608,13 +600,13 @@ impl AgentRegistry { pub fn resolve_primary_agent_for_turn( &self, logical_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, external_sources_supported: bool, expected_owner: Option, ) -> Option { self.resolve_primary_agent_for_turn_with_route( logical_id, - workspace_root, + workspace_id, external_sources_supported, expected_owner, None, @@ -624,15 +616,15 @@ impl AgentRegistry { pub fn resolve_primary_agent_for_turn_with_route( &self, logical_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, external_sources_supported: bool, expected_owner: Option, expected_route_key: Option<&str>, ) -> Option { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { - if let Some(workspace_root) = workspace_root { - let workspace_key = canonical_local_workspace_path(workspace_root); + if let Some(workspace_id) = workspace_id { + let workspace_key = workspace_id.to_owned(); if let Some(route) = self .external_subagents .effective_routes_for_workspace(&workspace_key) @@ -641,7 +633,7 @@ impl AgentRegistry { { let binding = match route { ExternalSubagentRoute::Local => { - match self.find_agent_entry(logical_id, Some(workspace_root)) { + match self.find_agent_entry(logical_id, Some(workspace_id)) { Some(entry) if is_local_session_primary_entry(&entry) => { Some(local_primary_binding(&entry)) } @@ -674,7 +666,7 @@ impl AgentRegistry { if expected_owner == Some(SessionAgentRouteOwner::External) { return None; } - let binding = match self.find_agent_entry(logical_id, workspace_root) { + let binding = match self.find_agent_entry(logical_id, workspace_id) { Some(entry) if is_local_session_primary_entry(&entry) => { Some(local_primary_binding(&entry)) } @@ -709,10 +701,10 @@ impl AgentRegistry { &self, logical_id: &str, ecosystem_id: &EcosystemId, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option { - let workspace_root = workspace_root?; - let workspace_key = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id?; + let workspace_key = workspace_id.to_owned(); let logical_key = normalize_external_logical_id(logical_id); let route = self .external_subagents @@ -733,13 +725,13 @@ impl AgentRegistry { pub(super) fn apply_external_routes_to_query( &self, - workspace_root: &Path, + workspace_id: &str, mut local: Vec, ) -> Vec { - let workspace_root = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let routes = self .external_subagents - .effective_routes_for_workspace(&workspace_root); + .effective_routes_for_workspace(&workspace_id); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { @@ -767,13 +759,13 @@ impl AgentRegistry { pub(super) fn apply_external_routes_to_modes( &self, - workspace_root: &Path, + workspace_id: &str, mut local: Vec, ) -> Vec { - let workspace_root = canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let routes = self .external_subagents - .effective_routes_for_workspace(&workspace_root); + .effective_routes_for_workspace(&workspace_id); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 3cb81e2a19..820ff0daf3 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -19,7 +19,6 @@ use crate::agentic::deep_review_policy::canonical_review_worker_agent_type; use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::sync::{Arc, OnceLock}; @@ -56,8 +55,8 @@ pub type CustomSubagentDetail = CustomAgentDetail; pub struct AgentRegistry { /// id -> agent_entry agents: RwLock>, - /// workspace root -> (project subagent id -> agent_entry) - project_subagents: RwLock>>, + /// workspace ID -> (project subagent id -> agent_entry) + project_subagents: RwLock>>, user_custom_agents_loaded: RwLock, custom_load_state: tokio::sync::Mutex, external_subagents: Arc, @@ -92,7 +91,7 @@ impl AgentRegistry { fn read_project_subagents( &self, - ) -> std::sync::RwLockReadGuard<'_, HashMap>> { + ) -> std::sync::RwLockReadGuard<'_, HashMap>> { match self.project_subagents.read() { Ok(guard) => guard, Err(poisoned) => { @@ -104,7 +103,7 @@ impl AgentRegistry { fn write_project_subagents( &self, - ) -> std::sync::RwLockWriteGuard<'_, HashMap>> { + ) -> std::sync::RwLockWriteGuard<'_, HashMap>> { match self.project_subagents.write() { Ok(guard) => guard, Err(poisoned) => { @@ -114,11 +113,7 @@ impl AgentRegistry { } } - fn find_agent_entry( - &self, - agent_type: &str, - workspace_root: Option<&Path>, - ) -> Option { + fn find_agent_entry(&self, agent_type: &str, workspace_id: Option<&str>) -> Option { if let Some(entry) = self.external_subagents.find_generation_entry(agent_type) { return Some(entry); } @@ -126,7 +121,7 @@ impl AgentRegistry { return Some(entry); } - if let Some(root) = workspace_root { + if let Some(root) = workspace_id { let project_subagents = self.read_project_subagents(); if let Some(entry) = project_subagents .get(root) @@ -154,9 +149,9 @@ impl AgentRegistry { pub fn get_agent( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option> { - self.find_agent_entry(agent_type, workspace_root) + self.find_agent_entry(agent_type, workspace_id) .map(|entry| entry.agent) } @@ -166,7 +161,7 @@ impl AgentRegistry { pub(crate) fn get_local_agent( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option> { if let Some(entry) = self.read_agents().values().find(|entry| { entry.source != types::AgentSource::External @@ -174,7 +169,7 @@ impl AgentRegistry { }) { return Some(entry.agent.clone()); } - workspace_root + workspace_id .and_then(|root| { self.read_project_subagents() .get(root)? @@ -255,8 +250,8 @@ impl openbitfun_agent_runtime::sdk::RuntimeAgentRegistry for AgentRegistry { query: openbitfun_agent_runtime::sdk::RuntimeAgentRegistryQuery<'_>, ) -> Vec { let mut ids = self.read_agents().keys().cloned().collect::>(); - if let Some(workspace_root) = query.workspace_root { - if let Some(entries) = self.read_project_subagents().get(workspace_root) { + if let Some(workspace_id) = query.workspace_id { + if let Some(entries) = self.read_project_subagents().get(workspace_id) { ids.extend(entries.keys().cloned()); } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index b70e123e46..14e9f1f1a9 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -15,7 +15,6 @@ use crate::agentic::tools::get_all_registered_tool_names; use crate::service::config::mode_config_canonicalizer::resolve_effective_tools; use openbitfun_agent_runtime::agents::subagent_source_presentation_rank; use std::collections::HashSet; -use std::path::Path; /// Append the dynamically registered tool families a mode Agent may use. /// @@ -42,18 +41,18 @@ impl AgentRegistry { #[cfg(feature = "external-sources")] pub(crate) async fn get_local_agents_for_external_resolution( &self, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Vec { self.ensure_user_custom_agents_loaded().await; - if let Some(workspace_root) = workspace_root { - if !self.read_project_subagents().contains_key(workspace_root) { - self.load_custom_agents(Some(workspace_root)).await; + if let Some(workspace_id) = workspace_id { + if !self.read_project_subagents().contains_key(workspace_id) { + self.load_custom_agents(Some(workspace_id)).await; } } let user_overrides = get_subagent_overrides().await; - let project_overrides = match workspace_root { - Some(workspace_root) => load_project_subagent_overrides_local(workspace_root) + let project_overrides = match workspace_id { + Some(workspace_id) => load_project_subagent_overrides_local(workspace_id) .await .ok(), None => None, @@ -65,8 +64,8 @@ impl AgentRegistry { local_conflict_info(entry, None, project_overrides.as_ref(), &user_overrides) })); } - if let Some(workspace_root) = workspace_root { - if let Some(project_entries) = self.read_project_subagents().get(workspace_root) { + if let Some(workspace_id) = workspace_id { + if let Some(project_entries) = self.read_project_subagents().get(workspace_id) { result.extend(project_entries.values().filter_map(|entry| { local_conflict_info(entry, None, project_overrides.as_ref(), &user_overrides) })); @@ -94,9 +93,9 @@ impl AgentRegistry { pub async fn get_agent_tool_policy( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> AgentToolPolicy { - let entry = self.find_agent_entry(agent_type, workspace_root); + let entry = self.find_agent_entry(agent_type, workspace_id); let Some(entry) = entry else { return AgentToolPolicy { allowed_tools: Vec::new(), @@ -153,9 +152,9 @@ impl AgentRegistry { pub async fn get_agent_tools( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Vec { - self.get_agent_tool_policy(agent_type, workspace_root) + self.get_agent_tool_policy(agent_type, workspace_id) .await .allowed_tools } @@ -170,7 +169,7 @@ impl AgentRegistry { /// used by Task; remote/read-only hosts must keep this disabled. pub async fn get_modes_info_for_workspace( &self, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, external_sources_supported: bool, ) -> Vec { self.ensure_user_custom_agents_loaded().await; @@ -182,8 +181,8 @@ impl AgentRegistry { .collect(); drop(map); if external_sources_supported { - if let Some(workspace_root) = workspace_root { - result = self.apply_external_routes_to_modes(workspace_root, result); + if let Some(workspace_id) = workspace_id { + result = self.apply_external_routes_to_modes(workspace_id, result); } } result.sort_by(|a, b| { @@ -265,18 +264,17 @@ impl AgentRegistry { pub async fn get_subagent_is_review_for_workspace( &self, id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option { self.ensure_user_custom_agents_loaded().await; - if let Some(workspace_root) = workspace_root { - let is_project_cache_loaded = - self.read_project_subagents().contains_key(workspace_root); + if let Some(workspace_id) = workspace_id { + let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_id); if !is_project_cache_loaded { - self.load_custom_agents(Some(workspace_root)).await; + self.load_custom_agents(Some(workspace_id)).await; } } - self.find_agent_entry(id, workspace_root) + self.find_agent_entry(id, workspace_id) .filter(|entry| entry.category == AgentCategory::SubAgent) .map(|entry| is_review_agent_entry(&entry)) } @@ -323,10 +321,10 @@ impl AgentRegistry { } /// get all subagent information (including source and availability status, used for TaskTool and frontend subagent list etc.) - pub async fn get_subagents_info(&self, workspace_root: Option<&Path>) -> Vec { + pub async fn get_subagents_info(&self, workspace_id: Option<&str>) -> Vec { self.get_subagents_for_query(&SubagentQueryContext { parent_agent_type: None, - workspace_root, + workspace_id, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, external_sources_supported: true, @@ -339,17 +337,16 @@ impl AgentRegistry { query: &SubagentQueryContext<'_>, ) -> Vec { self.ensure_user_custom_agents_loaded().await; - if let Some(workspace_root) = query.workspace_root { - let is_project_cache_loaded = - self.read_project_subagents().contains_key(workspace_root); + if let Some(workspace_id) = query.workspace_id { + let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_id); if !is_project_cache_loaded { - self.load_custom_agents(Some(workspace_root)).await; + self.load_custom_agents(Some(workspace_id)).await; } } let user_overrides = get_subagent_overrides().await; - let project_overrides = match query.workspace_root { - Some(workspace_root) => load_project_subagent_overrides_local(workspace_root) + let project_overrides = match query.workspace_id { + Some(workspace_id) => load_project_subagent_overrides_local(workspace_id) .await .ok(), None => None, @@ -382,8 +379,8 @@ impl AgentRegistry { }) .collect(); drop(map); - if let Some(workspace_root) = query.workspace_root { - if let Some(project_entries) = self.read_project_subagents().get(workspace_root) { + if let Some(workspace_id) = query.workspace_id { + if let Some(project_entries) = self.read_project_subagents().get(workspace_id) { result.extend( project_entries .values() @@ -413,8 +410,8 @@ impl AgentRegistry { } } if query.external_sources_supported { - if let Some(workspace_root) = query.workspace_root { - result = self.apply_external_routes_to_query(workspace_root, result); + if let Some(workspace_id) = query.workspace_id { + result = self.apply_external_routes_to_query(workspace_id, result); } } Self::sort_subagents_for_presentation(result) @@ -423,33 +420,32 @@ impl AgentRegistry { pub async fn can_parent_access_subagent( &self, subagent_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, parent_agent_type: Option<&str>, ) -> bool { let query = SubagentQueryContext { parent_agent_type, - workspace_root, + workspace_id, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, }; let user_overrides = get_subagent_overrides().await; - let project_overrides = match query.workspace_root { - Some(workspace_root) => load_project_subagent_overrides_local(workspace_root) + let project_overrides = match query.workspace_id { + Some(workspace_id) => load_project_subagent_overrides_local(workspace_id) .await .ok(), None => None, }; - if let Some(workspace_root) = query.workspace_root { - let is_project_cache_loaded = - self.read_project_subagents().contains_key(workspace_root); + if let Some(workspace_id) = query.workspace_id { + let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_id); if !is_project_cache_loaded { - self.load_custom_agents(Some(workspace_root)).await; + self.load_custom_agents(Some(workspace_id)).await; } } - self.find_agent_entry(subagent_id, workspace_root) + self.find_agent_entry(subagent_id, workspace_id) .is_some_and(|entry| { Self::entry_is_visible_for_query( &entry, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs index 6dc2b64fe6..89af5de38b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs @@ -6,7 +6,6 @@ use crate::service::config::GlobalConfig; use crate::service::config::SubagentModelSelection; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use log::{debug, error, warn}; -use std::path::Path; impl AgentRegistry { /// Returns a source-neutral explicit model selection for a delegated @@ -14,10 +13,10 @@ impl AgentRegistry { pub fn get_explicit_subagent_model_selection( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option { let config = self - .find_agent_entry(agent_type, workspace_root)? + .find_agent_entry(agent_type, workspace_id)? .custom_config?; if !config.model_is_explicit { return None; @@ -40,15 +39,14 @@ impl AgentRegistry { pub async fn get_model_id_for_agent( &self, agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { - let externally_owned = workspace_root + let externally_owned = workspace_id .is_some_and(|workspace| self.is_external_subagent_route(agent_type, Some(workspace))); let entry = if externally_owned { - workspace_root - .and_then(|workspace| self.find_external_route_entry(agent_type, workspace)) + workspace_id.and_then(|workspace| self.find_external_route_entry(agent_type, workspace)) } else { - self.find_agent_entry(agent_type, workspace_root) + self.find_agent_entry(agent_type, workspace_id) }; let entry = entry.ok_or_else(|| { error!("[AgentRegistry] Agent not found: {}", agent_type); diff --git a/src/crates/assembly/core/src/agentic/agents/registry/support.rs b/src/crates/assembly/core/src/agentic/agents/registry/support.rs index cfb2d041ca..70719c1004 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/support.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/support.rs @@ -6,7 +6,6 @@ use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{AgentProfileConfig, AgentSubagentOverrideConfig}; use crate::util::errors::OpenBitFunResult; use std::collections::HashMap; -use std::path::Path; /// Tool-name prefixes of the tool families that are registered dynamically at /// runtime and therefore cannot appear in an Agent's static tool manifest. @@ -44,9 +43,10 @@ pub(super) async fn get_subagent_overrides() -> AgentSubagentOverrideConfig { } pub(super) async fn load_project_subagent_overrides_local( - workspace_root: &Path, + workspace_id: &str, ) -> OpenBitFunResult { - let document = load_project_agent_profiles_document_local(workspace_root).await?; + let record = require_local_workspace(workspace_id).await?; + let document = load_project_agent_profiles_document_local(&record.root_path).await?; Ok(document .keys() .map(|profile_id| { @@ -60,10 +60,11 @@ pub(super) async fn load_project_subagent_overrides_local( } pub(super) async fn save_project_subagent_overrides_local( - workspace_root: &Path, + workspace_id: &str, overrides: &AgentSubagentOverrideConfig, ) -> OpenBitFunResult<()> { - let mut document = load_project_agent_profiles_document_local(workspace_root).await?; + let record = require_local_workspace(workspace_id).await?; + let mut document = load_project_agent_profiles_document_local(&record.root_path).await?; let existing_profile_ids: Vec = document.keys().cloned().collect(); for profile_id in existing_profile_ids { @@ -75,7 +76,7 @@ pub(super) async fn save_project_subagent_overrides_local( set_project_subagent_overrides(&mut document, profile_id, profile_overrides.clone()); } - save_project_agent_profiles_document_local(workspace_root, &document).await + save_project_agent_profiles_document_local(&record.root_path, &document).await } fn merge_dynamic_tool_names( @@ -101,6 +102,50 @@ fn merge_dynamic_tool_names( configured_tools } +pub(super) async fn require_workspace( + id: &str, +) -> OpenBitFunResult { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| crate::OpenBitFunError::service("Workspace service is unavailable"))?; + service.require_workspace(id).await +} + +/// Resolves the local directory that project-scoped agent files are read from +/// and written to. Remote workspaces are a hard error here: their project +/// agent documents live on the remote host, and silently reading or writing a +/// same-named local path would leak or lose user configuration. +pub(super) async fn require_local_workspace( + id: &str, +) -> OpenBitFunResult { + let record = require_workspace(id).await?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err(crate::OpenBitFunError::service( + "Local agent discovery cannot read a remote workspace", + )); + } + Ok(record) +} + +/// Resolves the project agent discovery root for a workspace record. +/// +/// Local records yield their root directory. Remote records yield `None`: +/// project agent discovery on the remote host is not a supported capability, +/// so the registry publishes an empty project set for them instead of failing +/// the whole load and leaving user-level custom agents unloaded. +pub(super) async fn project_agent_discovery_root( + id: &str, +) -> OpenBitFunResult> { + let record = require_workspace(id).await?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + log::debug!( + "Project agent discovery skipped for remote workspace: workspace_id={}", + record.id + ); + return Ok(None); + } + Ok(Some(record.root_path)) +} + pub(super) fn merge_dynamic_mcp_tools( configured_tools: Vec, registered_tool_names: &[String], diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 42165cee3d..92909dfe3e 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -13,7 +13,6 @@ use crate::agentic::agents::registry::visibility::{ use crate::agentic::agents::{ builtin_agent_specs, resolve_mode_config_profile_id, Agent, UserContextPolicy, }; -use crate::agentic::workspace::session_execution_workspace_root; use crate::service::config::types::AgentSubagentOverrideState; use async_trait::async_trait; use openbitfun_agent_runtime::custom_agent::{ @@ -131,18 +130,18 @@ fn test_source_custom_entry(id: &str, prompt: &str, kind: CustomSubagentKind) -> } } -fn insert_project_subagent(registry: &AgentRegistry, workspace: &Path, id: &str, model: &str) { +fn insert_project_subagent(registry: &AgentRegistry, workspace: &str, id: &str, model: &str) { let mut entries = HashMap::new(); entries.insert(id.to_string(), test_project_entry(id, model)); registry .write_project_subagents() - .insert(workspace.to_path_buf(), entries); + .insert(workspace.to_owned(), entries); } #[test] fn source_qualified_key_resolves_the_matching_custom_subagent() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("source-qualified-review-workspace"); + let workspace = String::from("source-qualified-review-workspace"); let id = "SameNamedReviewer"; registry.write_agents().insert( id.to_string(), @@ -176,8 +175,8 @@ fn source_qualified_key_resolves_the_matching_custom_subagent() { #[tokio::test] async fn review_lookup_is_scoped_to_the_requested_workspace() { let registry = AgentRegistry::new(); - let review_workspace = PathBuf::from("review-workspace"); - let ordinary_workspace = PathBuf::from("ordinary-workspace"); + let review_workspace = String::from("review-workspace"); + let ordinary_workspace = String::from("ordinary-workspace"); let agent_id = "SharedProjectAgent"; registry.write_project_subagents().insert( @@ -219,6 +218,9 @@ async fn review_lookup_is_scoped_to_the_requested_workspace() { #[tokio::test] async fn review_lookup_cold_loads_the_requested_project_registry() { let env = CustomAgentTestEnv::new("openbitfun-project-review-lookup"); + let record = + crate::service::workspace::legacy_compat::register_local_fixture(&env.workspace_root, None) + .await; let registry = AgentRegistry::new(); let agent_id = "ProjectReviewer"; write_project_custom_review_subagent( @@ -228,12 +230,48 @@ async fn review_lookup_cold_loads_the_requested_project_registry() { assert_eq!( registry - .get_subagent_is_review_for_workspace(agent_id, Some(&env.workspace_root)) + .get_subagent_is_review_for_workspace(agent_id, Some(&record.id)) .await, Some(true) ); } +#[tokio::test] +async fn remote_workspace_load_publishes_an_empty_project_set_and_keeps_user_agents() { + let remote = crate::service::workspace::legacy_compat::register_remote_fixture( + "/srv/agents/remote-project", + "agents-ssh", + "agents-host", + ) + .await; + let registry = AgentRegistry::new(); + + registry.load_custom_agents(Some(&remote.id)).await; + + assert!( + registry.user_custom_agents_loaded(), + "a remote workspace must not leave user-level custom agents unloaded" + ); + let project_subagents = registry.read_project_subagents(); + let published = project_subagents + .get(remote.id.as_str()) + .expect("remote workspace publishes a project set so queries stop rescanning"); + assert!( + published.is_empty(), + "remote hosts have no locally discoverable project agents" + ); + drop(project_subagents); + + let unknown = String::from("workspace-does-not-exist"); + registry.load_custom_agents(Some(&unknown)).await; + assert!( + !registry + .read_project_subagents() + .contains_key(unknown.as_str()), + "an unknown workspace ID must not be published as a project set" + ); +} + #[test] fn top_level_modes_default_to_auto() { for agent_type in [ @@ -263,8 +301,8 @@ fn custom_subagent_kind_maps_to_registry_source() { #[test] fn registry_exposes_sdk_agent_ids_without_leaking_core_agent_details() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/project"); - let other_workspace = PathBuf::from("D:/workspace/other"); + let workspace = String::from("id-project"); + let other_workspace = String::from("id-other"); insert_project_subagent(®istry, &workspace, "ProjectReviewer", "fast"); insert_project_subagent(®istry, &other_workspace, "OtherProjectReviewer", "fast"); @@ -279,7 +317,7 @@ fn registry_exposes_sdk_agent_ids_without_leaking_core_agent_details() { let agent_ids = RuntimeAgentRegistry::agent_ids( ®istry, RuntimeAgentRegistryQuery { - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), }, ); @@ -622,7 +660,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { let agentic_visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: None, + workspace_id: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -648,7 +686,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { let deep_review_visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("DeepReview"), - workspace_root: None, + workspace_id: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -667,7 +705,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { let deep_research_visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("DeepResearch"), - workspace_root: None, + workspace_id: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -683,7 +721,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { let ultra_visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Ultimate"), - workspace_root: None, + workspace_id: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -700,7 +738,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { let planner_visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("SwarmPlanner"), - workspace_root: None, + workspace_id: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -813,8 +851,8 @@ fn merge_dynamic_mode_tools_keeps_agents_without_dynamic_tools_unchanged() { #[test] fn project_subagent_config_lookup_is_workspace_scoped() { let registry = AgentRegistry::new(); - let workspace_a = PathBuf::from("D:/workspace/project-a"); - let workspace_b = PathBuf::from("D:/workspace/project-b"); + let workspace_a = String::from("id-project-a"); + let workspace_b = String::from("id-project-b"); insert_project_subagent(®istry, &workspace_a, "SharedReviewer", "fast"); insert_project_subagent(®istry, &workspace_b, "SharedReviewer", "primary"); @@ -844,7 +882,7 @@ fn project_subagent_config_lookup_is_workspace_scoped() { #[tokio::test] async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/project-c"); + let workspace = String::from("id-project-c"); registry.register_agent( Arc::new(TestAgent { @@ -907,7 +945,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() let visible = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: None, - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::RegistryManagement, include_disabled: false, external_sources_supported: false, @@ -932,7 +970,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() #[tokio::test] async fn parent_subagent_overrides_follow_source_scopes() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("__test_workspace__/project-d"); + let workspace = String::from("id-project-d"); registry.register_agent( Arc::new(CustomSubagent::new( @@ -983,7 +1021,7 @@ async fn parent_subagent_overrides_follow_source_scopes() { let builtin_query = SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::RegistryManagement, include_disabled: true, external_sources_supported: false, @@ -1149,13 +1187,15 @@ async fn project_scoped_custom_mode_is_skipped_while_project_subagent_loads() { registry .load_custom_agents_from_test_roots( - Some(&workspace_root), + Some("project-fixture-id"), &env.discovery_roots(Some(workspace_root.clone())), ) .await; let modes = registry.get_modes_info().await; - let subagents = registry.get_subagents_info(Some(&workspace_root)).await; + let subagents = registry + .get_subagents_info(Some("project-fixture-id")) + .await; assert!(!modes.iter().any(|agent| agent.id == "ProjectPlanner")); assert!(subagents.iter().any(|agent| agent.id == "ProjectHelper")); @@ -1510,7 +1550,7 @@ fn unique_suffix() -> String { #[tokio::test] async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("C:/workspace/external-agent-registry"); + let workspace = String::from("id-external-agent-registry"); let runtime_v1 = "external::candidate::behavior-v1"; let agent_v1: Arc = Arc::new(TestAgent { id: runtime_v1.to_string(), @@ -1542,7 +1582,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( let local_only = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, @@ -1555,7 +1595,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( let external = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: true, @@ -1594,7 +1634,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( assert!(registry.is_external_subagent_route("Explore", Some(&workspace))); assert!(registry.is_external_subagent_route("EXPLORE", Some(&workspace))); assert!(!registry.is_external_subagent_route("Explore", None)); - assert!(!registry.is_external_subagent_route("Explore", Some(Path::new("C:/workspace/other")))); + assert!(!registry.is_external_subagent_route("Explore", Some("id-other"))); assert!(registry .resolve_external_subagent_for_fresh_invocation( "Explore", @@ -1696,15 +1736,13 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( } #[tokio::test] -async fn external_routes_use_one_canonical_workspace_identity_for_all_operations() { +async fn external_routes_use_the_same_workspace_id_for_all_operations() { let registry = AgentRegistry::new(); - let workspace = tempfile::tempdir().expect("workspace"); - let alias_component = workspace.path().join("alias-component"); - std::fs::create_dir(&alias_component).expect("alias component"); - let workspace_alias = alias_component.join(".."); + let workspace = "workspace-id-exact"; + let workspace_alias = workspace; let runtime_key = "external::canonical-workspace"; registry.install_external_subagent_routes( - workspace.path(), + workspace, vec![ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: "canonical-profile".to_string(), @@ -1729,7 +1767,7 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations assert!(registry.is_external_subagent_route("canonical-profile", Some(&workspace_alias))); let binding = registry .resolve_primary_agent_for_turn("canonical-profile", Some(&workspace_alias), true, None) - .expect("alias path should resolve the installed external generation"); + .expect("workspace ID should resolve the installed external generation"); assert_eq!(binding.runtime_agent_key, runtime_key); drop(binding); let routed_entry = registry @@ -1750,7 +1788,7 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations .any(|agent| agent.id == "canonical-profile")); registry.release_external_subagent_workspace(&workspace_alias); - assert!(!registry.is_external_subagent_route("canonical-profile", Some(workspace.path()))); + assert!(!registry.is_external_subagent_route("canonical-profile", Some(workspace))); } #[test] @@ -1760,7 +1798,7 @@ fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { assert!(registry .resolve_primary_agent_for_turn( "Standard", - Some(Path::new("C:/workspace/restarted-external-owner")), + Some("id-restarted-external-owner"), true, Some(openbitfun_core_types::SessionAgentRouteOwner::External), ) @@ -1768,7 +1806,7 @@ fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { let local = registry .resolve_primary_agent_for_turn( "Standard", - Some(Path::new("C:/workspace/legacy-local-owner")), + Some("id-legacy-local-owner"), true, Some(openbitfun_core_types::SessionAgentRouteOwner::Local), ) @@ -1782,7 +1820,7 @@ fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { #[tokio::test] async fn external_agent_role_controls_main_and_task_projection() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("C:/workspace/external-agent-roles"); + let workspace = String::from("id-external-agent-roles"); let logical_id = "external-role-profile"; let registration = |runtime_key: &str, mode| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), @@ -1823,7 +1861,7 @@ async fn external_agent_role_controls_main_and_task_projection() { assert!(!registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: true, @@ -1848,7 +1886,7 @@ async fn external_agent_role_controls_main_and_task_projection() { let subagent = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("Standard"), - workspace_root: Some(&workspace), + workspace_id: Some(&workspace), list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: true, @@ -1865,7 +1903,7 @@ async fn external_agent_role_controls_main_and_task_projection() { #[test] fn persisted_primary_route_owner_rejects_same_name_route_takeover() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/owner-takeover"); + let workspace = String::from("id-owner-takeover"); let logical_id = "Standard"; let runtime_key = "external::agentic"; registry.install_external_subagent_routes( @@ -1920,7 +1958,7 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { #[test] fn validated_generation_replacement_restores_same_name_local_agent() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/plugin-agent-removed"); + let workspace = String::from("id-plugin-agent-removed"); let logical_id = "Standard"; let runtime_key = "external::agentic::generation-1"; registry.install_external_subagent_routes( @@ -1966,7 +2004,7 @@ fn validated_generation_replacement_restores_same_name_local_agent() { #[test] fn route_overlay_overrides_without_replacing_base_external_routes() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/plugin-agent-overlay"); + let workspace = String::from("id-plugin-agent-overlay"); let registration = |runtime_key: &str, logical_id: &str, provider: &str, @@ -2093,7 +2131,7 @@ fn route_overlay_overrides_without_replacing_base_external_routes() { #[test] fn persisted_route_key_rejects_same_name_external_provider_takeover() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/plugin-agent-takeover"); + let workspace = String::from("id-plugin-agent-takeover"); let logical_id = "Standard"; let registration = |runtime_key: &str, route_key: &str| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), @@ -2154,8 +2192,8 @@ fn persisted_route_key_rejects_same_name_external_provider_takeover() { #[test] fn external_primary_route_follows_the_session_execution_worktree() { let registry = AgentRegistry::new(); - let project = PathBuf::from("D:/workspace/project"); - let worktree = PathBuf::from("D:/workspace/worktrees/feature"); + let project = String::from("id-project"); + let worktree = String::from("id-worktrees-feature"); let logical_id = "workspace-profile"; let registration = |runtime_key: &str| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), @@ -2190,13 +2228,18 @@ fn external_primary_route_follows_the_session_execution_worktree() { ); let config = SessionConfig { - workspace_path: Some(worktree.to_string_lossy().into_owned()), - project_workspace_path: Some(project.to_string_lossy().into_owned()), + workspace_id: Some(worktree.clone()), + project_workspace_id: Some(project.clone()), + workspace_path: Some("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/same/execution-path".into()), + project_workspace_path: Some("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/same/execution-path".into()), ..SessionConfig::default() }; - let route_root = session_execution_workspace_root(&config).expect("execution root"); + let workspace_id = config + .workspace_id + .as_deref() + .expect("execution workspace ID"); let binding = registry - .resolve_primary_agent_for_turn(logical_id, Some(route_root), true, None) + .resolve_primary_agent_for_turn(logical_id, Some(workspace_id), true, None) .expect("worktree route should resolve"); assert_eq!(binding.runtime_agent_key, "external::worktree"); @@ -2298,7 +2341,7 @@ fn non_builtin_same_name_review_agent_does_not_resolve_as_session_primary() { #[test] fn local_route_resolves_review_agents_as_session_primaries() { let registry = AgentRegistry::new(); - let workspace = PathBuf::from("D:/workspace/review-local-route"); + let workspace = String::from("id-review-local-route"); registry.install_external_subagent_routes( &workspace, Vec::new(), diff --git a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs index 792d839446..e0631dcb60 100644 --- a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs +++ b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs @@ -673,6 +673,7 @@ mod tests { let root = tempfile::tempdir().expect("background outcome temp directory"); let workspace = root.path().join("workspace"); std::fs::create_dir_all(&workspace).expect("create workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace); let path_manager = Arc::new(PathManager::with_user_root_for_tests( root.path().join("config"), )); diff --git a/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs b/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs index 3c6b680af7..af5ad7b26f 100644 --- a/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs +++ b/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs @@ -43,6 +43,10 @@ pub struct CreateControlConversationRequest { #[serde(rename_all = "camelCase")] pub struct ControlConversation { pub session_id: String, + /// Owning workspace record of the control conversation. The directory is + /// app-owned (never in the recent list); clients scope the session by ID. + pub workspace_id: String, + /// Execution root of the conversation; an IO projection, not identity. pub workspace_path: String, } @@ -95,6 +99,22 @@ impl ConversationCoordinator { let _guard = ENSURE_CONTROL.lock().await; let manager = self.get_session_manager(); tokio::fs::create_dir_all(&workspace).await?; + // The control conversation runs in an app-owned folder. Register it as a + // hidden workspace record so the session, its history reads, and remote + // controllers address it by workspace ID rather than by this path. + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + let workspace_record = workspace_service + .track_workspace_activity( + workspace.to_path_buf(), + crate::service::workspace::WorkspaceCreateOptions { + add_to_recent: false, + auto_set_current: false, + ..Default::default() + }, + crate::service::workspace::WorkspaceActivityMode::TouchOnly, + ) + .await?; let state_path = workspace.join("active.json"); let store = JsonFileStore; let _file_guard = store @@ -128,6 +148,7 @@ impl ConversationCoordinator { "OpenBitFun".to_string(), "OpenBitFun".to_string(), SessionConfig { + workspace_id: Some(workspace_record.id.clone()), workspace_path: Some(workspace_path.clone()), ..SessionConfig::default() }, @@ -151,6 +172,7 @@ impl ConversationCoordinator { } Ok(ControlConversation { session_id, + workspace_id: workspace_record.id, workspace_path, }) } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 5a70f2b3b7..a6557fa352 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -79,14 +79,13 @@ use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig} use crate::service::config::{ get_global_config_service, AgentModelDefaultsConfig, SubagentModelSelection, }; -use crate::service::remote_ssh::normalize_remote_workspace_path; use crate::service::session::{ DialogTurnData, SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus, ToolItemIdentityExt, TurnStatus, }; use crate::service::workspace::{ - get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo, - WorkspaceKind, WorkspaceService, + get_global_workspace_service, WorkspaceActivityMode, WorkspaceInfo, WorkspaceKind, + WorkspaceService, }; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; @@ -335,21 +334,17 @@ fn inherit_matching_parent_workspace_binding( parent_config: &SessionConfig, child_config: &mut SessionConfig, ) { - let Some(parent_workspace_path) = parent_config.workspace_path.as_deref() else { - return; - }; - let Some(child_workspace_path) = child_config.workspace_path.as_deref() else { - return; - }; - if comparable_workspace_path(parent_workspace_path) - != comparable_workspace_path(child_workspace_path) + if parent_config.workspace_id.is_none() + || parent_config.workspace_id != child_config.workspace_id { return; } - + child_config.workspace_path = parent_config.workspace_path.clone(); + child_config.project_workspace_id = parent_config.project_workspace_id.clone(); child_config.project_workspace_path = parent_config.project_workspace_path.clone(); child_config.execution_target = parent_config.execution_target.clone(); child_config.workspace_id = parent_config.workspace_id.clone(); + child_config.workspace_kind = parent_config.workspace_kind.clone(); child_config.remote_connection_id = parent_config.remote_connection_id.clone(); child_config.remote_ssh_host = parent_config.remote_ssh_host.clone(); } @@ -507,7 +502,7 @@ pub(crate) struct InternalAgentExecutionRequest { pub(crate) task_description: String, pub(crate) agent_type: String, pub(crate) session_name: String, - pub(crate) workspace_path: String, + pub(crate) workspace_id: String, pub(crate) model_id: Option, pub(crate) created_by: Option, pub(crate) context: HashMap, @@ -1273,78 +1268,32 @@ pub struct ConversationCoordinator { impl ConversationCoordinator { pub(crate) async fn resolve_workspace_id_for_config(config: &SessionConfig) -> Option { - let explicit = config - .workspace_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if explicit.is_some() { - return explicit; - } - - let workspace_path = config.workspace_path.as_deref()?; - let workspace_service = get_global_workspace_service()?; - - if config.remote_connection_id.is_some() || config.remote_ssh_host.is_some() { - let normalized_path = normalize_remote_workspace_path(workspace_path); - let desired_connection_id = config - .remote_connection_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let desired_ssh_host = config - .remote_ssh_host - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - return workspace_service - .list_workspace_infos() - .await - .into_iter() - .find(|workspace| { - if workspace.workspace_kind != WorkspaceKind::Remote { - return false; - } - if normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()) - != normalized_path - { - return false; - } - if let Some(connection_id) = desired_connection_id { - if workspace.remote_ssh_connection_id() != Some(connection_id) { - return false; - } - } - if let Some(ssh_host) = desired_ssh_host { - let workspace_ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()); - if workspace_ssh_host != Some(ssh_host) { - return false; - } - } - true - }) - .map(|workspace| workspace.id); - } - - workspace_service - .get_workspace_by_path(Path::new(workspace_path)) + let mut resolved = config.clone(); + crate::agentic::workspace::normalize_session_workspace(&mut resolved) .await - .map(|workspace| workspace.id) + .ok()?; + resolved.workspace_id } + /// Refresh the recent/access state of the workspace a session belongs to. + /// The session names its workspace by ID; a path upsert would re-key the + /// catalog from the session's projection and could rewrite the record's + /// kind or SSH facts, so a session without an ID is not tracked. async fn track_session_workspace_activity_best_effort( config: &SessionConfig, mode: WorkspaceActivityMode, reason: &str, ) { - let Some(workspace_path) = config.workspace_path.as_ref() else { + let Some(workspace_id) = config + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + debug!( + "Skipping session workspace activity: reason={}, workspace_path={:?}, error=session has no workspace ID", + reason, config.workspace_path + ); return; }; @@ -1352,25 +1301,13 @@ impl ConversationCoordinator { return; }; - let mut options = WorkspaceCreateOptions { - auto_set_current: false, - add_to_recent: true, - ..Default::default() - }; - - if config.remote_connection_id.is_some() { - options.workspace_kind = WorkspaceKind::Remote; - options.remote_connection_id = config.remote_connection_id.clone(); - options.remote_ssh_host = config.remote_ssh_host.clone(); - } - if let Err(error) = workspace_service - .track_workspace_activity(PathBuf::from(workspace_path), options, mode) + .track_workspace_activity_by_id(workspace_id, mode) .await { warn!( - "Failed to track session workspace activity: reason={}, workspace_path={}, error={}", - reason, workspace_path, error + "Failed to track session workspace activity: reason={}, workspace_id={}, error={}", + reason, workspace_id, error ); } } @@ -1387,166 +1324,53 @@ impl ConversationCoordinator { pub(crate) async fn build_workspace_binding( config: &SessionConfig, ) -> Option { - let workspace_path = config.workspace_path.as_ref()?; - let path_buf = PathBuf::from(workspace_path); - let workspace_id = Self::resolve_workspace_id_for_config(config).await; - - #[cfg(not(feature = "remote-workspace"))] + let mut config = config.clone(); + if let Err(error) = + crate::agentic::workspace::normalize_session_workspace(&mut config).await { - let has_remote_metadata = config - .remote_connection_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || config - .remote_ssh_host - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - if has_remote_metadata { - let identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; - let connection_id = identity.remote_connection_id.clone()?; - let connection_name = config - .remote_ssh_host - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(&connection_id) - .to_string(); - return Some( - WorkspaceBinding::new_remote( - workspace_id, - path_buf, - connection_id, - connection_name, - identity, - ) - .with_execution_target(config.execution_target.clone()), - ); - } - - let mut binding = WorkspaceBinding::new(workspace_id, path_buf); - if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { - binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); - } - binding = binding.with_execution_target(config.execution_target.clone()); - return Some(binding); + warn!("Workspace identity resolution failed: {}", error); + return None; + } + let path = PathBuf::from(config.workspace_path.as_ref()?); + if config.is_remote_workspace() { + let connection_id = config.remote_connection_id.clone()?; + let host = config.remote_ssh_host.clone()?; + let identity = openbitfun_services_core::workspace_identity::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, + hostname: host.clone(), + logical_workspace_path: crate::service::remote_ssh::normalize_remote_workspace_path( + &path.to_string_lossy(), + ), + remote_connection_id: Some(connection_id.clone()), + }; + return Some(WorkspaceBinding::new_remote( + config.workspace_id, + path, + connection_id, + host, + identity, + )); } - - #[cfg(feature = "remote-workspace")] - { - let identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; - - if let Some(rid) = identity.remote_connection_id.as_deref() { - // Try to look up the connection by the session's stored ID first. - let lookup = - crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( - workspace_path, - Some(rid), - ) - .await; - - // If the stored connection_id does not resolve to a registered - // workspace, attempt a path-only lookup. This covers the case - // where the user changed the SSH port: the old connection_id is - // no longer registered, but the same remote path is now bound to - // a new connection with the updated port. - let (effective_rid, entry) = if lookup.is_some() { - (rid.to_string(), lookup) - } else { - let path_entry = - crate::service::remote_ssh::workspace_state::lookup_remote_connection( - workspace_path, - ) - .await; - if let Some(ref pe) = path_entry { - log::info!( - "Session connection_id {} not registered for workspace {}; remapping to {}", - rid, - workspace_path, - pe.connection_id - ); - (pe.connection_id.clone(), path_entry) - } else { - (rid.to_string(), lookup) - } - }; - - let connection_name = entry - .map(|e| e.connection_name) - .unwrap_or_else(|| effective_rid.clone()); - - // Re-resolve identity with the effective connection_id so the - // session storage path is correct. - let effective_identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - Some(&effective_rid), - config.remote_ssh_host.as_deref(), - ) - .await - .unwrap_or(identity); - - let binding = WorkspaceBinding::new_remote( - workspace_id.clone(), - path_buf, - effective_rid, - connection_name, - effective_identity, - ); - - return Some(binding); - } - - let mut binding = WorkspaceBinding::new(workspace_id, path_buf); - if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { - binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); - } - binding = binding.with_execution_target(config.execution_target.clone()); - - Some(binding) + let mut binding = WorkspaceBinding::new(config.workspace_id, path); + binding.project_workspace_id = config.project_workspace_id; + binding.session_identity.workspace_kind = config.workspace_kind.unwrap_or_default(); + if let Some(project) = config.project_workspace_path { + binding = binding.with_project_root_path(PathBuf::from(project)); } + Some(binding.with_execution_target(config.execution_target)) } async fn build_session_config_for_workspace( - workspace_path: String, + workspace_id: String, model_id: Option, - ) -> SessionConfig { - let config = SessionConfig { - workspace_path: Some(workspace_path), + ) -> OpenBitFunResult { + let mut config = SessionConfig { + workspace_id: Some(workspace_id), model_id, - ..SessionConfig::default() + ..Default::default() }; - - #[cfg(feature = "remote-workspace")] - { - let mut config = config; - let remote_entry = - crate::service::remote_ssh::workspace_state::lookup_remote_connection( - config.workspace_path.as_deref().unwrap_or_default(), - ) - .await; - if let Some(entry) = remote_entry { - config.remote_connection_id = Some(entry.connection_id); - if !entry.ssh_host.trim().is_empty() { - config.remote_ssh_host = Some(entry.ssh_host); - } - } - return config; - } - - #[cfg(not(feature = "remote-workspace"))] - config + crate::agentic::workspace::normalize_session_workspace(&mut config).await?; + Ok(config) } /// Build `WorkspaceServices` from a resolved `WorkspaceBinding`. @@ -1623,7 +1447,7 @@ impl ConversationCoordinator { async fn resolve_primary_agent_for_workspace( agent_type: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, external_sources_supported: bool, expected_owner: Option, expected_route_key: Option<&str>, @@ -1631,10 +1455,10 @@ impl ConversationCoordinator { let external_sources_supported = cfg!(feature = "external-sources") && external_sources_supported; let registry = get_agent_registry(); - registry.load_custom_agents(workspace_root).await; + registry.load_custom_agents(workspace_id).await; let local_binding = registry.resolve_primary_agent_for_turn_with_route( agent_type, - workspace_root, + workspace_id, false, expected_owner, expected_route_key, @@ -1648,11 +1472,11 @@ impl ConversationCoordinator { #[cfg(feature = "external-sources")] if let Err(error) = - crate::external_sources::ensure_external_source_workspace_snapshot(workspace_root).await + crate::external_sources::ensure_external_source_workspace_snapshot(workspace_id).await { if let Some(external_binding) = registry.resolve_primary_agent_for_turn_with_route( agent_type, - workspace_root, + workspace_id, true, expected_owner, expected_route_key, @@ -1666,7 +1490,7 @@ impl ConversationCoordinator { return Ok(external_binding); } if expected_owner == Some(SessionAgentRouteOwner::External) - || registry.is_external_subagent_route(agent_type, workspace_root) + || registry.is_external_subagent_route(agent_type, workspace_id) { return Err(OpenBitFunError::Validation(format!( "candidate_unavailable: external main agent {agent_type} could not be refreshed" @@ -1688,14 +1512,14 @@ impl ConversationCoordinator { registry .resolve_primary_agent_for_turn_with_route( agent_type, - workspace_root, + workspace_id, true, expected_owner, expected_route_key, ) .ok_or_else(|| { if expected_owner == Some(SessionAgentRouteOwner::External) - || registry.is_external_subagent_route(agent_type, workspace_root) + || registry.is_external_subagent_route(agent_type, workspace_id) { OpenBitFunError::Validation(format!( "candidate_unavailable: external main agent {agent_type} changed before the turn could start" @@ -1711,8 +1535,6 @@ impl ConversationCoordinator { agent_type: &str, workspace: &Option, ) -> OpenBitFunResult { - let workspace_root = - crate::agentic::workspace::session_execution_workspace_root(&session.config); let external_sources_supported = workspace .as_ref() .is_some_and(|workspace| !workspace.is_remote()); @@ -1725,7 +1547,7 @@ impl ConversationCoordinator { .flatten(); Self::resolve_primary_agent_for_workspace( agent_type, - workspace_root, + session.config.workspace_id.as_deref(), external_sources_supported, expected_owner, expected_route_key, @@ -2344,7 +2166,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self.hook_registry } - fn ensure_runtime_ownership( + pub(crate) fn ensure_runtime_ownership( &self, workspace_path: &Path, remote_connection_id: Option<&str>, @@ -2381,107 +2203,193 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.ensure_runtime_ownership(workspace_path, Some(remote_connection_id), remote_ssh_host) } - /// Rebuilds process-local Remote ownership from the Workspace owner's - /// saved identity after a host restart, without opening or selecting it. - pub(crate) async fn ensure_known_remote_workspace_runtime_ownership( + /// Resolve the session storage root for a workspace reference and take + /// runtime ownership of that workspace. The ID is authoritative; the path + /// and SSH fields are an upgrade-only projection consulted only without it. + pub async fn session_storage_for_reference( &self, - workspace_service: &WorkspaceService, - workspace_path: &Path, - connection_id: &str, - ssh_host: Option<&str>, - ) -> OpenBitFunResult<()> { - let known = workspace_service - .find_known_remote_workspace_for_path( - &workspace_path.to_string_lossy(), - Some(connection_id), - ssh_host, + workspace_id: Option<&str>, + legacy_path: &str, + legacy_connection_id: Option<&str>, + legacy_ssh_host: Option<&str>, + ) -> OpenBitFunResult { + let workspace = self + .ensure_workspace_runtime_ownership_for_reference( + workspace_id, + legacy_path, + legacy_connection_id, + legacy_ssh_host, ) + .await?; + crate::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) .await - .filter(|workspace| { - workspace.remote_ssh_connection_id() == Some(connection_id) - && ssh_host.is_none_or(|requested_host| { - workspace.metadata.get("sshHost").and_then(|value| value.as_str()) - == Some(requested_host) - }) - }) - .ok_or_else(|| OpenBitFunError::service(format!( - "Remote workspace ownership is unavailable: the saved workspace does not match connection '{connection_id}' at {}", - workspace_path.display() - )))?; - let known_host = known - .metadata - .get("sshHost") - .and_then(|value| value.as_str()); - self.ensure_verified_remote_workspace_runtime_ownership( - &known.root_path, - connection_id, - known_host, - )?; - self.ensure_runtime_ownership(workspace_path, Some(connection_id), ssh_host) + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| OpenBitFunError::service(error.to_string())) + } + + /// Take runtime ownership of the workspace a request names. The ID selects + /// the record and its kind decides the local or verified-remote scope; the + /// legacy `(path, connection, ssh)` selector only serves pre-ID references. + pub async fn ensure_workspace_runtime_ownership_for_reference( + &self, + workspace_id: Option<&str>, + legacy_path: &str, + legacy_connection_id: Option<&str>, + legacy_ssh_host: Option<&str>, + ) -> OpenBitFunResult { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + self.ensure_workspace_runtime_ownership_for_reference_with_service( + service.as_ref(), + workspace_id, + legacy_path, + legacy_connection_id, + legacy_ssh_host, + ) + .await } - /// Gates workspace attachment before opening it, then prepares local - /// Snapshot ownership without treating remote workspaces as local paths. - pub async fn open_workspace_with_runtime_ownership( + /// Same as [`Self::ensure_workspace_runtime_ownership_for_reference`] against + /// an explicit Workspace owner. Rebuilds process-local ownership from the + /// owner's saved identity after a host restart without opening or selecting + /// the workspace; a legacy remote reference must match the saved connection + /// and host exactly, so a shared path cannot authorize another target. + pub(crate) async fn ensure_workspace_runtime_ownership_for_reference_with_service( &self, workspace_service: &WorkspaceService, - path: PathBuf, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, - snapshot_log_context: &str, + workspace_id: Option<&str>, + legacy_path: &str, + legacy_connection_id: Option<&str>, + legacy_ssh_host: Option<&str>, ) -> OpenBitFunResult { - let known_remote = workspace_service - .resolve_remote_workspace_for_open( - &path.to_string_lossy(), - remote_connection_id, - remote_ssh_host, - ) - .await?; - if known_remote.is_none() && !path.exists() { - return Err(OpenBitFunError::service(format!( - "Workspace path does not exist locally and is not a known remote SSH workspace: {}. Open it once from the desktop SSH remote UI so OpenBitFun can remember the connection, then try again.", - path.display() - ))); + let workspace = match workspace_id.map(str::trim).filter(|id| !id.is_empty()) { + Some(id) => workspace_service.require_workspace(id).await?, + None => { + let resolved = workspace_service + .resolve_legacy_workspace_reference( + None, + legacy_path, + legacy_connection_id, + legacy_ssh_host, + ) + .await?; + match legacy_connection_id.map(str::trim).filter(|id| !id.is_empty()) { + Some(connection_id) => resolved + .filter(|workspace| { + workspace.remote_ssh_connection_id() == Some(connection_id) + && legacy_ssh_host.is_none_or(|requested_host| { + workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + == Some(requested_host) + }) + }) + .ok_or_else(|| { + OpenBitFunError::service(format!( + "Remote workspace ownership is unavailable: the saved workspace does not match connection '{connection_id}' at {legacy_path}" + )) + })?, + None => resolved.ok_or_else(|| { + OpenBitFunError::service("Legacy workspace reference is unavailable") + })?, + } + } + }; + if workspace.workspace_kind == WorkspaceKind::Remote { + let connection = workspace.remote_ssh_connection_id().ok_or_else(|| { + OpenBitFunError::service("Remote workspace is missing its saved SSH connection ID") + })?; + self.ensure_verified_remote_workspace_runtime_ownership( + &workspace.root_path, + connection, + workspace + .metadata + .get("sshHost") + .and_then(|host| host.as_str()), + )?; + } else { + self.ensure_runtime_ownership(&workspace.root_path, None, None)?; } - // Caller-provided remote facts only select a known workspace. They are - // not authority to bypass the local Runtime ownership lease. - let resolved_connection_id = known_remote - .as_ref() - .and_then(WorkspaceInfo::remote_ssh_connection_id) - .map(ToOwned::to_owned); - let resolved_ssh_host = known_remote.as_ref().and_then(|workspace| { - workspace + Ok(workspace) + } + + /// Attach a workspace selected by its owning-host ID. Caller paths and SSH + /// fields never participate in identity or execution-domain selection. + pub async fn select_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + workspace_id: &str, + ) -> OpenBitFunResult { + let workspace = workspace_service.require_workspace(workspace_id).await?; + if workspace.workspace_kind == WorkspaceKind::Remote { + let connection_id = workspace.remote_ssh_connection_id().ok_or_else(|| { + OpenBitFunError::service( + "Remote workspace record is missing its saved SSH connection ID", + ) + })?; + let host = workspace .metadata .get("sshHost") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned) - }); - if let Some(connection_id) = resolved_connection_id.as_deref() { + .and_then(|value| value.as_str()); self.ensure_verified_remote_workspace_runtime_ownership( - &path, + &workspace.root_path, connection_id, - resolved_ssh_host.as_deref(), + host, )?; } else { - self.ensure_runtime_ownership(&path, None, None)?; + self.ensure_runtime_ownership(&workspace.root_path, None, None)?; } - let info = workspace_service - .open_workspace_after_known_resolution(path, known_remote) - .await?; - if info.workspace_kind != WorkspaceKind::Remote { + let workspace = workspace_service.open_workspace_by_id(workspace_id).await?; + if workspace.workspace_kind != WorkspaceKind::Remote { if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), + &workspace.id, None, ) .await { - error!( - "Failed to initialize snapshot after {}: {}", - snapshot_log_context, error - ); + error!("Failed to initialize snapshot after workspace selection: {error}"); } } - Ok(info) + Ok(workspace) + } + + /// Register a user-selected local folder, then select its authoritative ID. + /// This is a creation boundary; existing-workspace selection accepts only IDs. + pub async fn create_local_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: PathBuf, + ) -> OpenBitFunResult { + self.ensure_runtime_ownership(&path, None, None)?; + let workspace = workspace_service.open_workspace(path).await?; + self.select_workspace_with_runtime_ownership(workspace_service, &workspace.id) + .await + } + + /// Create a remote workspace using a saved connection, then retain its ID. + pub async fn create_remote_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: &str, + connection_id: &str, + ssh_host: Option<&str>, + ) -> OpenBitFunResult { + let workspace = workspace_service + .prepare_remote_workspace(path, connection_id, ssh_host) + .await?; + self.ensure_verified_remote_workspace_runtime_ownership( + &workspace.root_path, + connection_id, + workspace + .metadata + .get("sshHost") + .and_then(|host| host.as_str()), + )?; + workspace_service + .open_known_remote_workspace(&workspace) + .await } /// Ensures ownership from the loaded session binding, or from a local @@ -2739,6 +2647,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); + crate::agentic::workspace::normalize_session_workspace(&mut config).await?; self.ensure_runtime_ownership( Path::new(&workspace_path), config.remote_connection_id.as_deref(), @@ -2748,14 +2657,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let agent_type = Self::normalize_agent_type(&agent_type); let expected_route_key = config.agent_route_key.clone(); let workspace_binding = Self::build_workspace_binding(&config).await; - let external_workspace_root = - crate::agentic::workspace::session_execution_workspace_root(&config); let external_sources_supported = workspace_binding .as_ref() .is_some_and(|workspace| !workspace.is_remote()); let primary_agent_binding = Self::resolve_primary_agent_for_workspace( &agent_type, - external_workspace_root, + config.workspace_id.as_deref(), external_sources_supported, None, expected_route_key.as_deref(), @@ -2830,6 +2737,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet is_remote_workspace: bool, ) -> NativeHookSessionFacts<'a> { NativeHookSessionFacts { + workspace_id: session.config.workspace_id.as_deref(), session_id: &session.session_id, turn_id: None, workspace_root, @@ -2955,6 +2863,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet { let memory_mode = new_session_memory_mode_from_global_config().await; let metadata = SessionMetadata { + workspace_id: None, + project_workspace_id: None, session_id: session_id.to_string(), session_name: "Recovered Session".to_string(), agent_type: "Standard".to_string(), @@ -3897,11 +3807,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .load_custom_agents( workspace .filter(|binding| !binding.is_remote()) - .map(|binding| binding.root_path()), + .and_then(|binding| binding.workspace_id.as_deref()), ) .await; let current_agent = agent_registry - .get_agent(agent_type, workspace.map(|binding| binding.root_path())) + .get_agent( + agent_type, + workspace.and_then(|binding| binding.workspace_id.as_deref()), + ) .ok_or_else(|| { OpenBitFunError::Validation(format!("Unknown agent type: {}", agent_type)) })?; @@ -4022,7 +3935,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let is_chinese = Self::is_chinese_locale().await; let kickoff_query = Self::assistant_bootstrap_kickoff_query(is_chinese); let expected_reply_language = if is_chinese { "Chinese" } else { "English" }; - let workspace_binding = WorkspaceBinding::new(None, workspace_root.clone()); + let workspace_binding = + WorkspaceBinding::resolve(session.config.workspace_id.as_deref().ok_or_else(|| { + OpenBitFunError::service("Assistant bootstrap requires its workspace ID") + })?) + .await?; let (model_id, _) = self .execution_engine .resolve_model_id_for_turn( @@ -4174,9 +4091,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet OpenBitFunError::NotFound(format!("Session not found: {session_id}")) })?; self.ensure_session_runtime_ownership(&session_id, None)?; - if session.config.remote_connection_id.is_some() - || session.config.remote_ssh_host.is_some() - { + if session.config.is_remote_workspace() { return Err(OpenBitFunError::NotImplemented( "External subagent command delegation is unavailable for remote workspaces" .to_string(), @@ -4252,7 +4167,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .resolve_external_subagent_for_fresh_invocation( &logical_id, &ecosystem_id, - Some(Path::new(&execution_workspace_path)), + session.config.workspace_id.as_deref(), ) .ok_or_else(|| { OpenBitFunError::Validation(format!( @@ -4269,7 +4184,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let permission_runtime_ceiling = crate::agentic::permission_policy::load_parent_permission_runtime_ceiling( Some(&primary_runtime_agent_key), - Some(Path::new(&execution_workspace_path)), + session.config.workspace_id.as_deref(), ) .await?; if session.agent_type != effective_agent_type @@ -7001,16 +6916,27 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .to_string(), )); } - if request.remote_connection_id.is_some() - || request.remote_ssh_host.is_some() - || session.config.remote_connection_id.is_some() - || session.config.remote_ssh_host.is_some() - { + if session.config.is_remote_workspace() { return Err(OpenBitFunError::Validation( "Interrupted turn recovery is unavailable for remote workspaces".to_string(), )); } - if let Some(requested_workspace) = request.workspace_path.as_deref() { + if let Some(requested_workspace_id) = request + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + let matches_session_workspace = session.config.workspace_id.as_deref() + == Some(requested_workspace_id) + || session.config.project_workspace_id.as_deref() == Some(requested_workspace_id); + if !matches_session_workspace { + return Err(OpenBitFunError::Validation( + "Interrupted turn recovery workspace does not match the session".to_string(), + )); + } + } else if let Some(requested_workspace) = request.workspace_path.as_deref() { + // Upgrade-only comparison for pre-ID clients. let matches_session_workspace = session.config.workspace_path.as_deref() == Some(requested_workspace) || session.config.project_workspace_path.as_deref() == Some(requested_workspace); @@ -7714,9 +7640,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .to_string(), )); } - if session.config.remote_connection_id.is_some() - || session.config.remote_ssh_host.is_some() - { + if session.config.is_remote_workspace() { return Err(OpenBitFunError::Validation( "Recoverable interruption is not available for remote workspaces".to_string(), )); @@ -8097,12 +8021,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(session) => Some(( Self::session_hooks_are_remote(&session).await, session.config.model_id.clone().unwrap_or_default(), + session.config.workspace_id.clone(), )), None => None, }; - if let Some((is_remote_workspace, model)) = session_hook_facts { + if let Some((is_remote_workspace, model, workspace_id)) = session_hook_facts { native_hooks::dispatch_session_end( NativeHookSessionFacts { + workspace_id: workspace_id.as_deref(), session_id, turn_id: None, workspace_root: Some(workspace_path), @@ -8252,28 +8178,32 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.reconcile_restored_session(session_id, session).await } - pub(crate) fn local_revert_workspace(&self, session_id: &str) -> OpenBitFunResult { + pub(crate) async fn local_revert_workspace( + &self, + session_id: &str, + ) -> OpenBitFunResult { let session = self .session_manager .get_session(session_id) .ok_or_else(|| OpenBitFunError::NotFound(format!("Session not found: {session_id}")))?; - if session.config.remote_connection_id.is_some() || session.config.remote_ssh_host.is_some() - { + let id = session.config.workspace_id.as_deref().ok_or_else(|| { + OpenBitFunError::Validation(format!("Session workspace ID is missing: {session_id}")) + })?; + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + let workspace = service.require_workspace(id).await?; + if workspace.workspace_kind == WorkspaceKind::Remote { return Err(OpenBitFunError::Validation( - "Session undo and redo are unavailable for remote workspaces".to_string(), + "Session undo and redo are unavailable for remote workspaces".into(), )); } - let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| { - OpenBitFunError::Validation(format!("Session workspace_path is missing: {session_id}")) - })?; - let workspace_path = PathBuf::from(workspace_path); - if !workspace_path.is_dir() { + if !workspace.root_path.is_dir() { return Err(OpenBitFunError::Validation(format!( "Session workspace directory does not exist: {}", - workspace_path.display() + workspace.root_path.display() ))); } - Ok(workspace_path) + Ok(workspace) } pub(crate) async fn apply_session_revert_locked( @@ -8282,9 +8212,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, undo: bool, ) -> OpenBitFunResult<(AgentSessionComposerUpdate, bool, usize)> { - let workspace_path = self.local_revert_workspace(session_id)?; + let workspace = self.local_revert_workspace(session_id).await?; let snapshot_manager = - crate::service::snapshot::get_or_create_snapshot_manager(workspace_path.clone(), None) + crate::service::snapshot::get_or_create_snapshot_manager(&workspace.id, None) .await .map_err(|error| OpenBitFunError::service(error.to_string()))?; let persistence = self.session_manager.persistence_manager(); @@ -8428,7 +8358,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Vec, Vec, )> { - let workspace_path = self.local_revert_workspace(session_id)?; + let workspace = self.local_revert_workspace(session_id).await?; let persistence = self.session_manager.persistence_manager(); let current = persistence .load_session_revert_state(session_storage_path, session_id) @@ -8500,7 +8430,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet unreachable!("targeted rollback always stages a boundary") }; let snapshot_manager = - crate::service::snapshot::get_or_create_snapshot_manager(workspace_path.clone(), None) + crate::service::snapshot::get_or_create_snapshot_manager(&workspace.id, None) .await .map_err(|error| OpenBitFunError::service(error.to_string()))?; @@ -8725,9 +8655,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet state: crate::agentic::session::revert::SessionRevertState, ) -> OpenBitFunResult<()> { let persistence = self.session_manager.persistence_manager(); - let workspace_path = self.local_revert_workspace(session_id)?; + let workspace = self.local_revert_workspace(session_id).await?; let snapshot_manager = - crate::service::snapshot::get_or_create_snapshot_manager(workspace_path.clone(), None) + crate::service::snapshot::get_or_create_snapshot_manager(&workspace.id, None) .await .map_err(|error| OpenBitFunError::service(error.to_string()))?; snapshot_manager @@ -8803,9 +8733,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; state = reconciled; } - let workspace_path = self.local_revert_workspace(session_id)?; + let workspace = self.local_revert_workspace(session_id).await?; + let workspace_path = workspace.root_path.clone(); let snapshot_manager = - crate::service::snapshot::get_or_create_snapshot_manager(workspace_path.clone(), None) + crate::service::snapshot::get_or_create_snapshot_manager(&workspace.id, None) .await .map_err(|error| OpenBitFunError::service(error.to_string()))?; if state.phase == SessionRevertPhase::Staged { @@ -8951,6 +8882,52 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.reconcile_restored_session(session_id, session).await } + /// Restore a persisted session for a workspace named by ID. The record + /// selects the runtime scope and the storage; the legacy `(path, + /// connection, ssh)` selector only serves references that predate IDs. + pub async fn restore_session_for_workspace_reference( + &self, + workspace_id: Option<&str>, + legacy_path: &str, + legacy_connection_id: Option<&str>, + legacy_ssh_host: Option<&str>, + session_id: &str, + ) -> OpenBitFunResult { + let session_storage_path = self + .session_storage_for_reference( + workspace_id, + legacy_path, + legacy_connection_id, + legacy_ssh_host, + ) + .await?; + let session = self + .session_manager + .restore_session_from_storage_path(&session_storage_path, session_id) + .await?; + self.reconcile_restored_session(session_id, session).await + } + + /// Restore a persisted session through its resolved workspace binding. + pub async fn restore_session_for_workspace_binding( + &self, + binding: &WorkspaceBinding, + session_id: &str, + ) -> OpenBitFunResult { + let ssh_host = binding + .is_remote() + .then(|| binding.session_identity.hostname.clone()) + .filter(|value| !value.trim().is_empty()); + self.restore_session_for_workspace_reference( + binding.workspace_id.as_deref(), + &binding.logical_workspace_path_string(), + binding.connection_id(), + ssh_host.as_deref(), + session_id, + ) + .await + } + pub async fn restore_internal_session_for_workspace( &self, request: SessionStoragePathRequest, @@ -10093,7 +10070,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .as_ref() .is_some_and(|workspace| workspace.is_remote()); let subagent_hook_model = session.config.model_id.clone().unwrap_or_default(); + let subagent_hook_workspace_id = subagent_workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.clone()); let subagent_hook_facts = NativeHookSessionFacts { + workspace_id: subagent_hook_workspace_id.as_deref(), session_id: &session_id, turn_id: Some(&dialog_turn_id), workspace_root: subagent_hook_workspace_root.as_deref(), @@ -11220,7 +11201,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet explicit_model_id: Option<&str>, inherit_parent_model: bool, agent_type: &str, - workspace_path: &str, parent_session_id: &str, ) -> OpenBitFunResult { let defaults = Self::agent_model_defaults().await; @@ -11230,9 +11210,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await; } + let parent_session = self + .session_manager + .get_session(parent_session_id) + .ok_or_else(|| { + OpenBitFunError::NotFound(format!("Session not found: {parent_session_id}")) + })?; let registry = get_agent_registry(); let configured_selection = registry - .get_explicit_subagent_model_selection(agent_type, Some(Path::new(workspace_path))) + .get_explicit_subagent_model_selection( + agent_type, + parent_session.config.workspace_id.as_deref(), + ) .unwrap_or_else(|| defaults.builtin_subagent_selection(agent_type)); let parent_model_id = if explicit_model_id.is_none() && matches!(&configured_selection, SubagentModelSelection::Inherit) @@ -11422,12 +11411,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "subagent_type is required when context_mode is 'fresh'".to_string(), ) })?; - let workspace_path = request.workspace_path.ok_or_else(|| { - OpenBitFunError::Validation( - "workspace_path is required when creating a fresh subagent session" - .to_string(), - ) - })?; + // A fresh subagent inherits its workspace from the parent session's + // workspace ID; the request path is a legacy projection only. let (resolved_model_id, immutable_model_fingerprint) = if matches!( request.model_binding_policy, SessionModelBindingPolicy::ApprovedImmutable @@ -11456,7 +11441,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet model_id.as_deref(), inherit_parent_model, &agent_type, - &workspace_path, &request.subagent_parent_info.session_id, ) .await?, @@ -11467,11 +11451,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.logical_subagent_type.as_deref(), &agent_type, ); - let mut session_config = Self::build_session_config_for_workspace( - workspace_path, - Some(resolved_model_id), - ) - .await; + let workspace_id = parent_session.config.workspace_id.clone().ok_or_else(|| { + OpenBitFunError::service("Parent session workspace ID is unavailable") + })?; + let mut session_config = + Self::build_session_config_for_workspace(workspace_id, Some(resolved_model_id)) + .await?; inherit_matching_parent_workspace_binding( &parent_session.config, &mut session_config, @@ -12016,6 +12001,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if let Some(workspace_path) = workspace_path.as_deref() { native_hooks::dispatch_session_end( NativeHookSessionFacts { + workspace_id: session.config.workspace_id.as_deref(), session_id, turn_id: None, workspace_root: Some(workspace_path), @@ -12202,10 +12188,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type: request.agent_type, logical_agent_type, session_config: Self::build_session_config_for_workspace( - request.workspace_path, + request.workspace_id, request.model_id, ) - .await, + .await?, initial_messages: vec![Message::user(task_description.clone())], user_input_text: task_description, created_by: request.created_by, @@ -12603,19 +12589,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Clean up snapshot system resources let session = self.session_manager.get_session(session_id); - if let Some(workspace_path) = session + if let Some(workspace_id) = session .as_ref() - .and_then(|session| session.config.workspace_path.as_deref()) - .map(std::path::PathBuf::from) + .and_then(|session| session.config.workspace_id.as_deref()) { debug!( - "Subagent cleanup stage starting: session_id={}, stage=snapshot_cleanup, workspace_path={}", + "Subagent cleanup stage starting: session_id={}, stage=snapshot_cleanup, workspace_id={}", session_id, - workspace_path.display() + workspace_id ); let stage_started_at = Instant::now(); if let Ok(snapshot_manager) = - crate::service::snapshot::ensure_snapshot_manager_for_workspace(&workspace_path) + crate::service::snapshot::ensure_snapshot_manager_for_workspace(workspace_id) { let snapshot_service = snapshot_manager.get_snapshot_service(); let snapshot_service = snapshot_service.read().await; @@ -12795,14 +12780,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .get_session(session_id) .ok_or_else(|| OpenBitFunError::NotFound(format!("Session not found: {session_id}")))?; let workspace = Self::build_workspace_binding(&session.config).await; - let workspace_root = - crate::agentic::workspace::session_execution_workspace_root(&session.config); let external_sources_supported = workspace .as_ref() .is_some_and(|workspace| !workspace.is_remote()); let binding = Self::resolve_primary_agent_for_workspace( mode_id, - workspace_root, + session.config.workspace_id.as_deref(), external_sources_supported, None, expected_route_key, @@ -13343,6 +13326,8 @@ fn runtime_session_summary( fn runtime_session_workspace_binding(binding: WorkspaceBinding) -> AgentSessionWorkspaceBinding { AgentSessionWorkspaceBinding { + workspace_kind: Some(binding.session_identity.workspace_kind.clone()), + project_workspace_id: binding.project_workspace_id.clone(), workspace_id: binding.workspace_id.clone(), workspace_path: binding.root_path_string(), project_workspace_path: Some(binding.project_root_path_string()), @@ -13423,18 +13408,20 @@ impl openbitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordi request: openbitfun_runtime_ports::AgentSessionListRequest, ) -> openbitfun_runtime_ports::PortResult> { - let effective_storage_path = Self::resolve_session_restore_path( - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await - .map_err(|error| { - openbitfun_runtime_ports::PortError::new( - openbitfun_runtime_ports::PortErrorKind::Backend, - error.to_string(), + let effective_storage_path = self + .session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), ) - })?; + .await + .map_err(|error| { + openbitfun_runtime_ports::PortError::new( + openbitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; self.list_sessions(&effective_storage_path) .await @@ -13462,24 +13449,20 @@ impl openbitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordi message, ) })?; - self.ensure_runtime_ownership( - Path::new(&request.workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(runtime_port_error_preserving_message)?; - let effective_storage_path = Self::resolve_session_restore_path( - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await - .map_err(|error| { - openbitfun_runtime_ports::PortError::new( - openbitfun_runtime_ports::PortErrorKind::Backend, - error.to_string(), + let effective_storage_path = self + .session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), ) - })?; + .await + .map_err(|error| { + openbitfun_runtime_ports::PortError::new( + openbitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; self.delete_session(&effective_storage_path, &request.session_id) .await @@ -13501,19 +13484,15 @@ impl openbitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordi message, ) })?; - self.ensure_runtime_ownership( - Path::new(&request.workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(runtime_port_error_preserving_message)?; - let effective_storage_path = Self::resolve_session_restore_path( - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await - .map_err(runtime_port_error_preserving_message)?; + let effective_storage_path = self + .session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .map_err(runtime_port_error_preserving_message)?; let session_manager = self.get_session_manager(); if !session_manager @@ -13537,6 +13516,7 @@ impl openbitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordi openbitfun_runtime_ports::AgentSessionManagementPort::set_session_archived( self, openbitfun_runtime_ports::AgentSessionArchiveStateRequest { + workspace_id: request.workspace_id, workspace_path: request.workspace_path, session_id: request.session_id, archived: true, @@ -13557,19 +13537,15 @@ impl openbitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordi message, ) })?; - self.ensure_runtime_ownership( - Path::new(&request.workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .map_err(runtime_port_error_preserving_message)?; - let effective_storage_path = Self::resolve_session_restore_path( - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await - .map_err(runtime_port_error_preserving_message)?; + let effective_storage_path = self + .session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .map_err(runtime_port_error_preserving_message)?; let session_manager = self.get_session_manager(); let _mutation = session_manager @@ -13879,16 +13855,20 @@ impl openbitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoor message, ) })?; - let storage_request = SessionStoragePathRequest { - workspace_path: PathBuf::from(request.workspace_path), - remote_connection_id: request.remote_connection_id, - remote_ssh_host: request.remote_ssh_host, - }; + let storage_path = self + .session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await + .map_err(runtime_port_error_preserving_message)?; let session = if request.include_internal { - self.restore_internal_session_for_workspace(storage_request, &request.session_id) + self.restore_internal_session_from_storage_path(&storage_path, &request.session_id) .await } else { - self.restore_session_for_workspace(storage_request, &request.session_id) + self.restore_session_from_storage_path(&storage_path, &request.session_id) .await } .map_err(runtime_port_error_preserving_message)?; @@ -15215,6 +15195,12 @@ mod tests { #[tokio::test] async fn remote_workspace_services_unavailable_is_an_error() { + crate::service::workspace::legacy_compat::register_remote_fixture( + "/srv/remote-project", + "ssh-user@example.test:22", + "example.test", + ) + .await; let binding = ConversationCoordinator::build_workspace_binding(&SessionConfig { workspace_path: Some("/srv/remote-project".to_string()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), @@ -15464,6 +15450,7 @@ mod tests { async fn post_admission_state_write_failure_still_delivers_all_cancellation_signals() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("lineage-cancel-{}", uuid::Uuid::new_v4()); let turn_id = format!("turn-{}", uuid::Uuid::new_v4()); session_manager @@ -15599,12 +15586,11 @@ mod tests { model_runtime_binding_fingerprint, AIConfig, AIModelConfig, }; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; - #[cfg(feature = "remote-workspace")] - use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::{ DialogTurnData, DialogTurnKind, SessionMetadata, SessionRelationship, SessionStatus, TurnStatus, UserMessageData, }; + #[cfg(feature = "remote-workspace")] use crate::service::workspace::WorkspaceKind; use openbitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use openbitfun_core_types::{ @@ -15654,6 +15640,7 @@ mod tests { async fn manual_compaction_fails_closed_before_admission_when_external_agent_is_unavailable() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("external-compact-{}", uuid::Uuid::new_v4()); let external_agent_id = format!("missing-external-{}", uuid::Uuid::new_v4()); session_manager @@ -15699,6 +15686,7 @@ mod tests { async fn explicit_agent_change_switches_owner_but_case_variant_does_not() { let (_coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("external-to-local-{}", uuid::Uuid::new_v4()); let external_agent_id = format!("external-profile-{}", uuid::Uuid::new_v4()); session_manager @@ -15873,6 +15861,7 @@ mod tests { async fn manual_compaction_cancelled_before_setup_preserves_context_and_settles_turn() { let (coordinator, session_manager) = test_coordinator(); let workspace = tempfile::tempdir().unwrap(); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session = session_manager .create_session( "Cancelled compaction".to_string(), @@ -15936,6 +15925,7 @@ mod tests { let root = tempfile::tempdir().expect("test root"); let workspace = root.path().join("workspace"); std::fs::create_dir_all(&workspace).expect("workspace should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace); let path_manager = Arc::new(PathManager::with_user_root_for_tests( root.path().join("user-root"), )); @@ -16210,18 +16200,21 @@ mod tests { let references = vec![ SessionReferenceLocator { session_id: "12345678aaaa0000".to_string(), + workspace_id: None, workspace_path: "/workspace-a".to_string(), remote_connection_id: None, remote_ssh_host: None, }, SessionReferenceLocator { session_id: "12345678bbbb0000".to_string(), + workspace_id: None, workspace_path: "/workspace-b".to_string(), remote_connection_id: None, remote_ssh_host: None, }, SessionReferenceLocator { session_id: "12345678aaaa0000".to_string(), + workspace_id: None, workspace_path: "/workspace-a".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -16411,6 +16404,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace_path_string = workspace_path.to_string_lossy().into_owned(); let session = TEST_AGENT_MODEL_DEFAULTS .scope( @@ -16457,6 +16451,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace_path_string = workspace_path.to_string_lossy().into_owned(); let session = TEST_AGENT_MODEL_DEFAULTS .scope( @@ -16504,6 +16499,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace_path_string = workspace_path.to_string_lossy().into_owned(); let session = TEST_AGENT_MODEL_DEFAULTS .scope( @@ -16557,6 +16553,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace_path_string = workspace_path.to_string_lossy().into_owned(); let session = TEST_AGENT_MODEL_DEFAULTS .scope( @@ -16844,12 +16841,27 @@ mod tests { #[tokio::test] async fn control_conversation_reset_preserves_history_and_retries_one_selection() { let workspace = tempfile::tempdir().expect("control workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); + // Installs the global workspace service the control conversation + // registers its app-owned folder with. + crate::service::workspace::legacy_compat::register_local_fixture(workspace.path(), None) + .await; let (coordinator, manager) = test_persistent_coordinator(); let original = coordinator .select_control_conversation_in_workspace(workspace.path(), None) .await .unwrap(); assert_eq!(original.session_id, "openbitfun-control"); + assert!(!original.workspace_id.is_empty()); + assert_eq!( + manager + .get_session(&original.session_id) + .unwrap() + .config + .workspace_id + .as_deref(), + Some(original.workspace_id.as_str()) + ); coordinator .record_voice_exchange(super::super::VoiceExchangeRequest { session_id: original.session_id.clone(), @@ -16916,6 +16928,7 @@ mod tests { #[tokio::test] async fn completed_persisted_turn_emits_a_durable_history_fence() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let (coordinator, session_manager) = test_persistent_coordinator(); let session = session_manager .create_session( @@ -16989,6 +17002,7 @@ mod tests { #[tokio::test] async fn transient_turns_keep_authoritative_terminal_results() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let (coordinator, session_manager) = test_persistent_coordinator(); let session = session_manager .create_transient_session_with_id_and_details( @@ -17197,6 +17211,7 @@ mod tests { async fn stale_targeted_revert_keeps_an_existing_staged_suffix() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("targeted-stale-{}", uuid::Uuid::new_v4()); let storage_path = create_staged_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id) @@ -17248,6 +17263,7 @@ mod tests { async fn staged_revert_is_committed_before_local_and_maintenance_turns() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let local_session_id = format!("local-revert-{}", uuid::Uuid::new_v4()); let local_storage = create_staged_two_turn_session( @@ -17386,6 +17402,7 @@ mod tests { async fn mutating_restore_reconciles_a_marker_written_before_workspace_apply() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let file_path = workspace.path().join("src/lib.rs"); std::fs::create_dir_all(file_path.parent().expect("file parent")) .expect("create file parent"); @@ -17395,12 +17412,15 @@ mod tests { let session_id = format!("restore-revert-{}", uuid::Uuid::new_v4()); let storage_path = create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; - let snapshot_manager = crate::service::snapshot::get_or_create_snapshot_manager( - workspace.path().to_path_buf(), + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), None, ) - .await - .expect("snapshot manager"); + .await; + let snapshot_manager = + crate::service::snapshot::get_or_create_snapshot_manager(&record.id, None) + .await + .expect("snapshot manager"); let operation_id = snapshot_manager .record_file_change( &session_id, @@ -17493,6 +17513,7 @@ mod tests { async fn coordinator_delete_reconciles_an_unfinished_revert_before_cleanup() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("delete-revert-{}", uuid::Uuid::new_v4()); let storage_path = create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; @@ -17528,6 +17549,7 @@ mod tests { let (coordinator, session_manager) = test_persistent_coordinator(); let coordinator = Arc::new(coordinator); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session_id = format!("transcript-mutation-{}", uuid::Uuid::new_v4()); let storage_path = create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; @@ -17579,6 +17601,7 @@ mod tests { async fn transient_transcript_locked_fallback_does_not_reenter_session_mutation() { let (coordinator, session_manager) = test_coordinator(); let workspace = tempfile::tempdir().expect("transient workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let session = session_manager .create_session( "Transient transcript".to_string(), @@ -17624,6 +17647,7 @@ mod tests { async fn create_session_checks_runtime_ownership_before_persisting() { let ownership_root = tempfile::tempdir().expect("ownership root"); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let key = openbitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace( workspace.path(), "openbitfun", @@ -17667,6 +17691,9 @@ mod tests { for agent_type in ["CodeReview", "DeepReview"] { let workspace = tempfile::tempdir().expect("review workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking( + workspace.path(), + ); let session = coordinator .create_session_with_workspace( None, @@ -17690,6 +17717,7 @@ mod tests { async fn review_fixer_turn_is_admitted_after_updating_a_deep_review_session_binding() { let (coordinator, session_manager) = test_coordinator(); let workspace = tempfile::tempdir().expect("review workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let workspace_path = workspace.path().to_string_lossy().into_owned(); let session = session_manager .create_session( @@ -17750,6 +17778,7 @@ mod tests { async fn assistant_bootstrap_checks_runtime_ownership_before_files_or_attach() { let ownership_root = tempfile::tempdir().expect("ownership root"); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let key = openbitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace( workspace.path(), "openbitfun", @@ -17795,11 +17824,11 @@ mod tests { fn workspace_open_owner_gates_before_open_and_guards_snapshot_by_kind() { let source = include_str!("coordinator.rs"); let helper = source - .split("pub async fn open_workspace_with_runtime_ownership") + .split("pub async fn select_workspace_with_runtime_ownership") .nth(1) .and_then(|source| { source - .split("pub fn ensure_session_runtime_ownership") + .split("pub async fn create_local_workspace_with_runtime_ownership") .next() }) .expect("workspace open owner"); @@ -17807,14 +17836,14 @@ mod tests { .find("ensure_runtime_ownership") .expect("workspace ownership gate"); let workspace_open = helper - .find("open_workspace_after_known_resolution") + .find("open_workspace_by_id") .expect("workspace open call"); assert!(ownership_gate < workspace_open); assert!(helper.contains("WorkspaceKind::Remote")); assert!(helper.contains("initialize_snapshot_manager_for_workspace")); let bot_router = include_str!("../../service/remote_connect/bot/command_router.rs"); - assert!(bot_router.contains("open_workspace_with_runtime_ownership")); + assert!(bot_router.contains("select_workspace_with_runtime_ownership")); assert!(!bot_router.contains("initialize_snapshot_manager_for_workspace")); } @@ -17853,7 +17882,7 @@ mod tests { let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); let opened = coordinator - .open_workspace_with_runtime_ownership( + .upgrade_legacy_workspace_with_runtime_ownership( &workspace_service, remote_path, None, @@ -17903,10 +17932,11 @@ mod tests { .ensure_workspace_runtime_ownership(&remote_path, Some("conn-a"), Some("host-a")) .is_err()); coordinator - .ensure_known_remote_workspace_runtime_ownership( + .ensure_workspace_runtime_ownership_for_reference_with_service( &workspace_service, - &remote_path, - "conn-a", + None, + &remote_path.to_string_lossy(), + Some("conn-a"), Some("host-a"), ) .await @@ -17956,10 +17986,11 @@ mod tests { let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); for (connection, host) in [("unknown", "host-a"), ("conn-a", "wrong-host")] { let error = coordinator - .ensure_known_remote_workspace_runtime_ownership( + .ensure_workspace_runtime_ownership_for_reference_with_service( &workspace_service, - &remote_path, - connection, + None, + &remote_path.to_string_lossy(), + Some(connection), Some(host), ) .await @@ -17982,6 +18013,7 @@ mod tests { async fn unverified_remote_hint_cannot_bypass_local_workspace_ownership() { let ownership_root = tempfile::tempdir().expect("ownership root"); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let key = openbitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace( workspace.path(), "openbitfun", @@ -18008,7 +18040,7 @@ mod tests { .await; let error = coordinator - .open_workspace_with_runtime_ownership( + .upgrade_legacy_workspace_with_runtime_ownership( &workspace_service, workspace.path().to_path_buf(), Some("bogus-connection"), @@ -18035,6 +18067,7 @@ mod tests { async fn attach_and_mutation_paths_check_runtime_ownership_before_side_effects() { let ownership_root = tempfile::tempdir().expect("ownership root"); let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let key = openbitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace( workspace.path(), "openbitfun", @@ -18086,6 +18119,7 @@ mod tests { openbitfun_runtime_ports::AgentSessionManagementPort::set_session_archived( &coordinator, openbitfun_runtime_ports::AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path, session_id: "missing-session".to_string(), archived: true, @@ -18153,6 +18187,7 @@ mod tests { #[tokio::test] async fn user_shell_command_persists_a_standard_exec_command_tool_turn() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let (coordinator, session_manager) = test_persistent_user_shell_coordinator(); let session = session_manager .create_session( @@ -18234,6 +18269,7 @@ mod tests { #[tokio::test] async fn user_shell_command_auto_approves_ask_but_preserves_project_denies() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let permission_path = workspace .path() .join(".openbitfun") @@ -18307,6 +18343,7 @@ mod tests { #[tokio::test] async fn user_shell_command_reports_a_nonzero_exit_as_a_tool_error() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let (coordinator, session_manager) = test_persistent_user_shell_coordinator(); let session = session_manager .create_session( @@ -18353,6 +18390,7 @@ mod tests { #[tokio::test] async fn user_shell_command_cancelled_during_validation_never_executes() { let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); let validation_started = Arc::new(Notify::new()); let release_validation = Arc::new(Notify::new()); let call_count = Arc::new(AtomicUsize::new(0)); @@ -19132,6 +19170,10 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let workspace_record = + crate::service::workspace::legacy_compat::register_local_fixture_blocking( + &workspace_path, + ); let mut metadata = serde_json::Map::new(); metadata.insert( "createdBy".to_string(), @@ -19147,7 +19189,7 @@ mod tests { workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, - workspace_id: Some("workspace-1".to_string()), + workspace_id: Some(workspace_record.id.clone()), remote_connection_id: None, remote_ssh_host: None, model_id: Some("explicit-model".to_string()), @@ -19163,7 +19205,10 @@ mod tests { assert_eq!(result.session_name, "Worker"); assert_eq!(result.session_name, created.session_name); assert_eq!(created.created_by.as_deref(), Some("session-parent")); - assert_eq!(created.config.workspace_id.as_deref(), Some("workspace-1")); + assert_eq!( + created.config.workspace_id.as_deref(), + Some(workspace_record.id.as_str()) + ); assert_eq!(created.config.model_id.as_deref(), Some("explicit-model")); let _ = std::fs::remove_dir_all(workspace_path); @@ -19177,6 +19222,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace = workspace_path.to_string_lossy().into_owned(); let created = AgentSubmissionPort::create_session( &coordinator, @@ -19213,6 +19259,7 @@ mod tests { AgentSessionManagementPort::rename_session( &coordinator, AgentSessionRenameRequest { + workspace_id: None, workspace_path: workspace.clone(), session_id: created.session_id.clone(), session_name: "Renamed".to_string(), @@ -19242,6 +19289,7 @@ mod tests { AgentSessionManagementPort::archive_session( &coordinator, AgentSessionArchiveRequest { + workspace_id: None, workspace_path: workspace.clone(), session_id: created.session_id.clone(), remote_connection_id: None, @@ -19261,6 +19309,7 @@ mod tests { AgentSessionManagementPort::set_session_archived( &coordinator, openbitfun_runtime_ports::AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path: workspace.clone(), session_id: created.session_id.clone(), archived: false, @@ -19315,6 +19364,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let result = AgentSubmissionPort::create_session_with_id( &coordinator, @@ -19387,7 +19437,10 @@ mod tests { .unload_session_from_memory("fixed-session-id") .await .expect("fixed-id session should unload")); - let unloaded_default_workspace_goal = AgentThreadGoalManagementPort::get_thread_goal( + // Once the session is unloaded there is no in-memory workspace to + // inherit and "." is not a registered workspace record, so the read + // must fail loudly instead of guessing a current-directory store. + let unloaded_default_workspace_error = AgentThreadGoalManagementPort::get_thread_goal( &coordinator, AgentThreadGoalGetRequest { session_id: "fixed-session-id".to_string(), @@ -19397,8 +19450,14 @@ mod tests { }, ) .await - .expect("unloaded local session should retain the current-directory fallback"); - assert_eq!(unloaded_default_workspace_goal, None); + .expect_err("unloaded session without a workspace scope must not resolve storage"); + assert!( + unloaded_default_workspace_error + .message + .contains("does not resolve to a local workspace"), + "{}", + unloaded_default_workspace_error.message + ); } #[tokio::test] @@ -19445,6 +19504,12 @@ mod tests { let mut storage_paths = Vec::new(); for (index, (connection_id, ssh_host, objective)) in remote_identities.iter().enumerate() { + crate::service::workspace::legacy_compat::register_remote_fixture( + logical_workspace_path, + connection_id, + ssh_host, + ) + .await; let storage_path = ConversationCoordinator::resolve_session_restore_path( logical_workspace_path, Some(connection_id), @@ -19614,6 +19679,12 @@ mod tests { let logical_workspace_path = format!("/workspace/remote-goal-{fixture_id}"); let remote_connection_id = format!("connection-{fixture_id}"); let remote_ssh_host = format!("host-{fixture_id}"); + crate::service::workspace::legacy_compat::register_remote_fixture( + &logical_workspace_path, + &remote_connection_id, + &remote_ssh_host, + ) + .await; coordinator .ensure_verified_remote_workspace_runtime_ownership( @@ -19678,6 +19749,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace_path_string = workspace_path.to_string_lossy().into_owned(); let first = TEST_AGENT_MODEL_DEFAULTS @@ -19768,6 +19840,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); let workspace = workspace_path.to_string_lossy().into_owned(); let request = |name: &str| AgentSessionCreateRequest { session_name: name.to_string(), @@ -19930,24 +20003,18 @@ mod tests { #[cfg(feature = "remote-workspace")] #[tokio::test] async fn subagent_session_config_preserves_registered_remote_workspace_identity() { - let manager = init_remote_workspace_manager(); - manager - .register_remote_workspace( - "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/remote/subagent-test".to_string(), - "conn-subagent-test".to_string(), - "Remote Test".to_string(), - "remote-host".to_string(), - ) - .await; - manager - .set_active_connection_hint(Some("conn-subagent-test".to_string())) - .await; - + let workspace = crate::service::workspace::legacy_compat::register_remote_fixture( + "/remote/subagent-test/project", + "conn-subagent-test", + "remote-host", + ) + .await; let config = ConversationCoordinator::build_session_config_for_workspace( - "/remote/subagent-test/project".to_string(), + workspace.id, Some("model-fast".to_string()), ) - .await; + .await + .unwrap(); assert_eq!( config.workspace_path.as_deref(), @@ -19969,6 +20036,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -19990,15 +20058,7 @@ mod tests { .expect("parent session should be created"); let model_id = coordinator - .resolve_fresh_subagent_model_id( - None, - true, - "Explore", - workspace_path - .to_str() - .expect("workspace path should be UTF-8"), - &parent_session.session_id, - ) + .resolve_fresh_subagent_model_id(None, true, "Explore", &parent_session.session_id) .await .expect("fresh subagent request should inherit the parent model"); @@ -20073,12 +20133,24 @@ mod tests { async fn fresh_subagent_inherits_matching_parent_worktree_binding() { let (coordinator, session_manager) = test_coordinator(); let temp_root = tempfile::tempdir().expect("temp root should exist"); - let project_path = temp_root.path().join("OpenBitFun"); - let worktree_path = temp_root.path().join("managed-worktree"); + // Workspace records store canonical roots (macOS `/var` -> `/private/var`); + // build the expected IO projections from the same canonical root. + let canonical_root = + dunce::canonicalize(temp_root.path()).expect("temp root should canonicalize"); + let project_path = canonical_root.join("OpenBitFun"); + let worktree_path = canonical_root.join("managed-worktree"); std::fs::create_dir_all(&project_path).expect("project dir should exist"); std::fs::create_dir_all(&worktree_path).expect("worktree dir should exist"); let project_workspace_path = project_path.to_string_lossy().into_owned(); let workspace_path = worktree_path.to_string_lossy().into_owned(); + let project_record = + crate::service::workspace::legacy_compat::register_local_fixture(&project_path, None) + .await; + let workspace_record = crate::service::workspace::legacy_compat::register_local_fixture( + &worktree_path, + Some(&project_path), + ) + .await; let execution_target = SessionExecutionTarget { kind: SessionExecutionTargetKind::ManagedWorktree, worktree_id: Some("worktree-1".to_string()), @@ -20097,7 +20169,8 @@ mod tests { workspace_path: Some(workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(execution_target.clone()), - workspace_id: Some("workspace-1".to_string()), + workspace_id: Some(workspace_record.id.clone()), + project_workspace_id: Some(project_record.id.clone()), prompt_cache_lineage_id: Some("parent-lineage".to_string()), ..Default::default() }, @@ -20145,7 +20218,7 @@ mod tests { ); assert_eq!( resolved.session_config.workspace_id.as_deref(), - Some("workspace-1") + Some(workspace_record.id.as_str()) ); assert!(resolved.session_config.prompt_cache_lineage_id.is_none()); } @@ -20158,6 +20231,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -20166,6 +20240,9 @@ mod tests { } let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); let workspace = workspace_path.to_string_lossy().into_owned(); + let workspace_record = + crate::service::workspace::legacy_compat::register_local_fixture(&workspace_path, None) + .await; let parent_session = session_manager .create_transient_session_with_id_and_details( None, @@ -20174,6 +20251,7 @@ mod tests { SessionConfig { model_id: Some("primary".to_string()), workspace_path: Some(workspace.clone()), + workspace_id: Some(workspace_record.id.clone()), ..Default::default() }, None, @@ -20300,6 +20378,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -20464,6 +20543,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -20574,6 +20654,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -20615,6 +20696,7 @@ mod tests { uuid::Uuid::new_v4() )); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace_path); struct TempWorkspaceGuard(std::path::PathBuf); impl Drop for TempWorkspaceGuard { fn drop(&mut self) { @@ -20689,25 +20771,22 @@ mod tests { #[tokio::test] async fn btw_session_persists_relationship_and_seeds_forked_listing_baselines() { let (coordinator, session_manager) = test_persistent_coordinator(); - let workspace_path = std::env::temp_dir().join(format!( - "openbitfun-btw-baseline-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); - struct TempWorkspaceGuard(std::path::PathBuf); - impl Drop for TempWorkspaceGuard { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); + // The parent lives in a registered remote workspace; the child must + // inherit that record's SSH facts rather than transport hints. + let remote_workspace = crate::service::workspace::legacy_compat::register_remote_fixture( + &format!("/srv/btw-baseline/{}", uuid::Uuid::new_v4()), + "ssh-user@example.test:22", + "example.test", + ) + .await; let parent_session = session_manager .create_session( "Parent".to_string(), "Standard".to_string(), SessionConfig { - workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + workspace_id: Some(remote_workspace.id.clone()), + workspace_path: Some(remote_workspace.root_path.to_string_lossy().into_owned()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), remote_ssh_host: Some("example.test".to_string()), ..Default::default() @@ -20829,7 +20908,13 @@ mod tests { let session_storage_path = session_manager .storage_path_binding_for_test(&child_session.session_id) .expect("BTW storage path should be bound"); - let _storage_guard = TempWorkspaceGuard(session_storage_path.clone()); + struct TempStorageGuard(std::path::PathBuf); + impl Drop for TempStorageGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _storage_guard = TempStorageGuard(session_storage_path.clone()); let metadata = session_manager .load_session_metadata(&session_storage_path, &child_session.session_id) .await diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index a71d498720..f8da52705b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -79,7 +79,10 @@ pub struct QueuedTurn { pub prepended_messages: Vec, pub turn_id: Option, pub agent_type: String, + /// Execution root projection; storage selection prefers `workspace_id`. pub workspace_path: Option, + /// Owning workspace ID supplied by ID-aware callers to locate an unloaded session. + pub workspace_id: Option, pub remote_connection_id: Option, pub remote_ssh_host: Option, pub policy: DialogSubmissionPolicy, @@ -814,6 +817,7 @@ impl DialogScheduler { turn_id: Some(resolved_turn_id.clone()), agent_type: delivery.agent_type, workspace_path: delivery.workspace_path, + workspace_id: None, remote_connection_id: delivery.remote_connection_id, remote_ssh_host: delivery.remote_ssh_host, policy: DialogSubmissionPolicy::new(DialogTriggerSource::AgentSession, queue_priority), @@ -956,6 +960,7 @@ impl DialogScheduler { turn_id: Some(resolved_turn_id.clone()), agent_type, workspace_path, + workspace_id: None, remote_connection_id, remote_ssh_host, policy, @@ -1004,6 +1009,7 @@ impl DialogScheduler { turn_id: Some(resolved_turn_id.clone()), agent_type, workspace_path: session.config.workspace_path.clone(), + workspace_id: None, remote_connection_id: session.config.remote_connection_id.clone(), remote_ssh_host: session.config.remote_ssh_host.clone(), policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), @@ -1174,13 +1180,35 @@ impl DialogScheduler { session.config.project_workspace_path.as_deref(), ); } - if let Some(workspace_path) = queued_turn.workspace_path.as_deref() { - let requested_storage_path = Self::resolve_session_restore_path( - workspace_path, - queued_turn.remote_connection_id.as_deref(), - queued_turn.remote_ssh_host.as_deref(), + let requested_workspace_id = queued_turn + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned); + let requested_storage_path = if let Some(workspace_id) = requested_workspace_id.as_deref() { + // ID-aware callers locate the session by its owning workspace; the + // path on the request is only an execution-root projection. + Some( + CoreSessionStorePort::default() + .resolve_workspace_storage(workspace_id) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(SchedulerSubmitError::Port)?, ) - .await?; + } else if let Some(workspace_path) = queued_turn.workspace_path.as_deref() { + Some( + Self::resolve_session_restore_path( + workspace_path, + queued_turn.remote_connection_id.as_deref(), + queued_turn.remote_ssh_host.as_deref(), + ) + .await?, + ) + } else { + None + }; + if let Some(requested_storage_path) = requested_storage_path { if let Some(restored_session) = self .restore_missing_session_before_admission(&session_id, &requested_storage_path) .await? @@ -1194,6 +1222,11 @@ impl DialogScheduler { self.session_manager .validate_session_storage_path_binding(&session_id, &requested_storage_path) .map_err(SchedulerSubmitError::Core)?; + if requested_workspace_id.is_some() { + // The session is loaded and bound by ID; an omitted locator makes + // the coordinator reuse that binding instead of re-resolving a path. + queued_turn.workspace_path = None; + } } let state = self .session_manager @@ -2702,7 +2735,12 @@ impl DialogScheduler { "External subagent delegation does not accept attachments or prepended reminders", )); } - if request.remote_connection_id.is_some() || request.remote_ssh_host.is_some() { + if self + .coordinator + .get_session_manager() + .get_session(&request.session_id) + .is_some_and(|session| session.config.is_remote_workspace()) + { return Err(PortError::new( PortErrorKind::NotAvailable, "External subagent delegation is unavailable for remote workspaces", @@ -2746,6 +2784,7 @@ impl DialogScheduler { turn_id: Some(resolved_turn_id.clone()), agent_type: request.agent_type, workspace_path: request.workspace_path, + workspace_id: request.workspace_id, remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, policy: request.policy, @@ -3211,6 +3250,15 @@ mod tests { assert_eq!(error.kind, PortErrorKind::InvalidRequest); } + /// Creates a fixture workspace directory and registers it as a local + /// workspace record. Sessions only exist inside registered workspaces, so + /// path-only session configs naming it resolve like a host-opened folder. + fn fixture_workspace_dir(path: PathBuf) -> PathBuf { + std::fs::create_dir_all(&path).expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&path); + path + } + fn test_scheduler() -> ( Arc, Arc, @@ -3299,8 +3347,7 @@ mod tests { async fn submission_preflight_commits_a_persisted_revert_marker() { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "reverted-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3361,8 +3408,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "parent-session"; let turn_id = "parent-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3421,8 +3467,7 @@ mod tests { async fn idle_background_result_uses_the_session_logical_agent_route() { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "external-parent-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3469,7 +3514,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler_with_persistence(true); let session_id = "evicted-interrupted-session"; let turn_id = "interrupted-turn"; - let workspace = root.path().join("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); let model_binding_fingerprint = model_runtime_binding_fingerprint(&AIModelConfig { id: "model-original".to_string(), name: "model-original".to_string(), @@ -3553,8 +3598,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "goal-parent-session"; let turn_id = "goal-parent-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3619,6 +3663,7 @@ mod tests { turn_id: Some(turn_id.to_string()), agent_type: "Standard".to_string(), workspace_path: Some("/workspace".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), @@ -3684,8 +3729,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler_with_persistence(true); let session_id = "interrupted-queue-hold"; let turn_id = "turn-interrupted"; - let workspace = root.path().join("workspace-interrupted-hold"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace-interrupted-hold")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3740,8 +3784,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let parent_session_id = "parent-session"; let child_session_id = "background-child-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(parent_session_id.to_string()), @@ -3813,8 +3856,7 @@ mod tests { #[tokio::test] async fn dialog_port_preserves_not_found_for_a_missing_session() { let (scheduler, _, _, root) = test_scheduler(); - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); let error = scheduler .submit_dialog_turn(AgentDialogTurnRequest { @@ -3826,6 +3868,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some(workspace.to_string_lossy().to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -3857,8 +3900,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "queued-session"; let turn_id = "queued-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3892,6 +3934,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -3933,8 +3976,7 @@ mod tests { async fn delegated_dialog_turn_rejects_instead_of_queueing_behind_an_active_turn() { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "delegated-busy-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -3972,6 +4014,7 @@ mod tests { }, agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -3992,8 +4035,7 @@ mod tests { async fn delegated_dialog_turn_does_not_clear_a_queue_from_an_error_session() { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "delegated-error-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4027,6 +4069,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4062,6 +4105,7 @@ mod tests { }, agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4086,8 +4130,7 @@ mod tests { async fn reject_busy_dialog_port_does_not_enqueue_or_replace_the_active_turn() { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "acp-session"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4121,6 +4164,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4156,8 +4200,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "duplicate-active-session"; let turn_id = "duplicate-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4194,6 +4237,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4213,10 +4257,8 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "workspace-bound-session"; let turn_id = "wrong-workspace-turn"; - let workspace_a = root.path().join("workspace-a"); - let workspace_b = root.path().join("workspace-b"); - std::fs::create_dir_all(&workspace_a).expect("workspace a"); - std::fs::create_dir_all(&workspace_b).expect("workspace b"); + let workspace_a = fixture_workspace_dir(root.path().join("workspace-a")); + let workspace_b = fixture_workspace_dir(root.path().join("workspace-b")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4239,6 +4281,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some(workspace_b.to_string_lossy().to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4265,8 +4308,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "invalid-agent-session"; let turn_id = "invalid-agent-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4290,6 +4332,7 @@ mod tests { execution: Default::default(), agent_type: "agent-that-does-not-exist".to_string(), workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), @@ -4309,8 +4352,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "known-turn-session"; let turn_id = "known-turn"; - let workspace = root.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace")); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4375,8 +4417,7 @@ mod tests { session_id: &str, turn_id: &str, ) { - let workspace = root.path().join(format!("workspace-{session_id}")); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join(format!("workspace-{session_id}"))); session_manager .create_session_with_id( Some(session_id.to_string()), @@ -4579,8 +4620,7 @@ mod tests { let (scheduler, session_manager, _, root) = test_scheduler(); let session_id = "session-maintenance-retire"; let turn_id = "turn-active"; - let workspace = root.path().join("workspace-maintenance-retire"); - std::fs::create_dir_all(&workspace).expect("workspace"); + let workspace = fixture_workspace_dir(root.path().join("workspace-maintenance-retire")); session_manager .create_session_with_id( Some(session_id.to_string()), diff --git a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs index 40de59f935..d62c0ce035 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs +++ b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs @@ -72,8 +72,8 @@ pub async fn review_capability_catalog( let mut agents = get_agent_registry() .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: context.agent_type.as_deref(), - workspace_root: (!context.is_remote()) - .then(|| context.workspace_root()) + workspace_id: (!context.is_remote()) + .then(|| context.workspace_id()) .flatten(), list_scope: SubagentListScope::TaskVisible, include_disabled: false, @@ -121,7 +121,7 @@ pub async fn review_capability_catalog( } let workspace_root = (!context.is_remote()) - .then(|| context.workspace_root()) + .then(|| context.workspace_id()) .flatten(); for agent in agents.into_iter().take(agent_limit) { let Ok(detail) = get_agent_registry() @@ -170,7 +170,7 @@ async fn implicitly_invocable_skills(context: &ToolUseContext) -> Vec } else { skill_registry .get_implicitly_invocable_skills_for_workspace( - context.workspace_root(), + context.workspace.as_ref(), context.agent_type.as_deref(), ) .await @@ -220,8 +220,8 @@ pub async fn resolve_review_capability( let agent = get_agent_registry() .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: context.agent_type.as_deref(), - workspace_root: (!context.is_remote()) - .then(|| context.workspace_root()) + workspace_id: (!context.is_remote()) + .then(|| context.workspace_id()) .flatten(), list_scope: SubagentListScope::TaskVisible, include_disabled: false, @@ -234,7 +234,7 @@ pub async fn resolve_review_capability( OpenBitFunError::tool("Review agent is no longer available".to_string()) })?; let workspace_root = (!context.is_remote()) - .then(|| context.workspace_root()) + .then(|| context.workspace_id()) .flatten(); let detail = get_agent_registry() .get_custom_subagent_detail_by_key(&agent.key, workspace_root) @@ -314,7 +314,7 @@ async fn load_review_skill( registry .find_and_load_skill_by_key_for_workspace( skill_key, - context.workspace_root(), + context.workspace.as_ref(), context.agent_type.as_deref(), ) .await diff --git a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs index 42f4315446..30887f903a 100644 --- a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs +++ b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs @@ -11,6 +11,9 @@ fn engine() -> (tempfile::TempDir, ExecutionEngine) { .prefix("compression-") .tempdir() .unwrap(); + // The temp root doubles as the session workspace; sessions only exist + // inside registered workspaces, so register it like a host-opened folder. + crate::service::workspace::legacy_compat::register_local_fixture_blocking(temp.path()); let manager = SessionManager::new( Arc::new(SessionContextStore::new()), Arc::new( diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0dde1e6471..f583774dbd 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -1908,7 +1908,10 @@ impl ExecutionEngine { let agent_registry = get_agent_registry(); let fallback_model_id = agent_registry - .get_model_id_for_agent(agent_type, workspace.map(|binding| binding.root_path())) + .get_model_id_for_agent( + agent_type, + workspace.and_then(|binding| binding.workspace_id.as_deref()), + ) .await .map_err(|e| OpenBitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; let configured_model_id = session @@ -2319,7 +2322,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .await; @@ -2329,7 +2332,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .ok_or_else(|| { OpenBitFunError::NotFound(format!("Agent not found: {}", context.agent_type)) @@ -2445,7 +2448,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .await; let allowed_tools = tool_policy.allowed_tools.clone(); @@ -2538,6 +2541,7 @@ impl ExecutionEngine { model: &'a str, ) -> NativeHookSessionFacts<'a> { NativeHookSessionFacts { + workspace_id: workspace.and_then(|workspace| workspace.workspace_id.as_deref()), session_id, turn_id: Some(dialog_turn_id), workspace_root: workspace.map(|workspace| workspace.root_path()), @@ -2939,7 +2943,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .await; let current_agent = agent_registry @@ -2948,7 +2952,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .ok_or_else(|| OpenBitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; info!( @@ -3148,7 +3152,7 @@ impl ExecutionEngine { context .workspace .as_ref() - .map(|workspace| workspace.root_path()), + .and_then(|workspace| workspace.workspace_id.as_deref()), ) .await; let allowed_tools = tool_policy.allowed_tools.clone(); diff --git a/src/crates/assembly/core/src/agentic/insights/collector.rs b/src/crates/assembly/core/src/agentic/insights/collector.rs index 8618752161..3c0c73257e 100644 --- a/src/crates/assembly/core/src/agentic/insights/collector.rs +++ b/src/crates/assembly/core/src/agentic/insights/collector.rs @@ -17,7 +17,9 @@ use chrono::{DateTime, Local, Utc}; use log::{debug, warn}; use openbitfun_agent_tools::ResolvedToolInvocation; use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const MAX_TRANSCRIPT_CHARS: usize = 16000; @@ -54,12 +56,12 @@ impl InsightsCollector { let now_ms = system_time_to_unix_ms(now); let cutoff_ms = now_ms.saturating_sub(days as u64 * 86_400_000); - let workspace_targets = collect_effective_session_storage_targets().await; + let workspace_targets = collect_effective_session_storage_targets().await?; let mut transcripts = Vec::new(); let mut base_stats = BaseStats::default(); - let mut seen_sessions: HashSet<(PathBuf, String)> = HashSet::new(); - let mut modified_files: HashSet<(PathBuf, String)> = HashSet::new(); + let mut seen_sessions: HashSet<(String, String)> = HashSet::new(); + let mut modified_files: HashSet<(String, String)> = HashSet::new(); for target in &workspace_targets { let ws_path = &target.session_storage_path; @@ -84,7 +86,7 @@ impl InsightsCollector { { continue; } - let session_key = (ws_path.clone(), summary.session_id.clone()); + let session_key = (target.workspace_id.clone(), summary.session_id.clone()); if !seen_sessions.insert(session_key) { continue; } @@ -221,9 +223,10 @@ impl InsightsCollector { duration_millis, ); for path in - accumulate_code_stats(&mut base_stats, workspace_path, &selected_turns).await + accumulate_code_stats(&mut base_stats, &target.workspace_id, &selected_turns) + .await { - modified_files.insert((workspace_path.clone(), path)); + modified_files.insert((target.workspace_id.clone(), path)); } transcripts.push(transcript); } @@ -614,6 +617,7 @@ fn compute_active_duration_millis( let report = build_session_usage_report_from_turns( SessionUsageReportRequest { session_id: session_id.to_string(), + workspace_id: None, workspace_path: Some(workspace_path.to_string_lossy().to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -887,10 +891,10 @@ fn compute_days_covered(range: &DateRange) -> u32 { /// according to [`language_name_for_path`]. async fn accumulate_code_stats( base_stats: &mut BaseStats, - workspace_path: &Path, + workspace_id: &str, turns: &[DialogTurnData], ) -> HashSet { - let Some(snapshot_manager) = get_snapshot_manager_for_workspace(workspace_path) else { + let Some(snapshot_manager) = get_snapshot_manager_for_workspace(workspace_id) else { return accumulate_code_stats_from_turns(base_stats, turns); }; diff --git a/src/crates/assembly/core/src/agentic/insights/session_paths.rs b/src/crates/assembly/core/src/agentic/insights/session_paths.rs index 7f99f9ad22..043bfdbea3 100644 --- a/src/crates/assembly/core/src/agentic/insights/session_paths.rs +++ b/src/crates/assembly/core/src/agentic/insights/session_paths.rs @@ -1,76 +1,38 @@ //! Resolve on-disk session roots for insights (local + remote SSH mirror). use crate::agentic::session::session_store_port::CoreSessionStorePort; -use crate::service::workspace::{get_global_workspace_service, WorkspaceInfo}; -use openbitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; +use crate::service::workspace::get_global_workspace_service; +use openbitfun_runtime_ports::SessionStorePort; use std::collections::HashSet; use std::path::PathBuf; #[derive(Debug, Clone)] pub struct EffectiveSessionStorageTarget { + pub workspace_id: String, pub workspace_path: PathBuf, pub session_storage_path: PathBuf, } -/// Resolve the final sessions directory for a tracked workspace. -pub async fn effective_session_storage_dir_for_workspace(ws: &WorkspaceInfo) -> PathBuf { - let path_str = ws.root_path.to_string_lossy().to_string(); - let conn = ws.remote_ssh_connection_id().map(|s| s.to_string()); - let mut host = ws - .metadata - .get("sshHost") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - - if host.is_none() { - if let (Some(cid), Some(ws_service)) = (conn.as_ref(), get_global_workspace_service()) { - host = ws_service - .remote_ssh_host_for_remote_workspace(cid.as_str(), &path_str) - .await; - } - } - - CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: ws.root_path.clone(), - remote_connection_id: conn, - remote_ssh_host: host, - }) - .await - .map(|resolution| resolution.effective_storage_path) - .unwrap_or_else(|_| PathBuf::from(path_str)) -} - -/// Unique workspace paths whose persisted session directories exist on disk. -/// -/// Each returned path is the value to pass to [`PersistenceManager::list_sessions`]. -pub async fn collect_effective_session_storage_roots() -> Vec { - collect_effective_session_storage_targets() - .await - .into_iter() - .map(|target| target.session_storage_path) - .collect() -} - -pub async fn collect_effective_session_storage_targets() -> Vec { - let mut paths = Vec::new(); +/// Enumerate registered workspace IDs, then ask the persistence owner for roots. +/// A failed resolution is never replaced by the logical execution directory. +pub async fn collect_effective_session_storage_targets( +) -> crate::OpenBitFunResult> { + let service = get_global_workspace_service() + .ok_or_else(|| crate::OpenBitFunError::service("Workspace service is unavailable"))?; + let mut targets = Vec::new(); let mut seen = HashSet::new(); - - let Some(ws_service) = get_global_workspace_service() else { - return paths; - }; - - for ws in ws_service.list_workspace_infos().await { - let sessions_dir = effective_session_storage_dir_for_workspace(&ws).await; - - if sessions_dir.exists() && seen.insert(sessions_dir.clone()) { - paths.push(EffectiveSessionStorageTarget { - workspace_path: ws.root_path, - session_storage_path: sessions_dir, + for workspace in service.list_workspace_infos().await { + let resolution = CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map_err(|error| crate::OpenBitFunError::service(error.to_string()))?; + if resolution.effective_storage_path.exists() && seen.insert(workspace.id.clone()) { + targets.push(EffectiveSessionStorageTarget { + workspace_id: workspace.id, + workspace_path: workspace.root_path, + session_storage_path: resolution.effective_storage_path, }); } } - - paths + Ok(targets) } diff --git a/src/crates/assembly/core/src/agentic/memories/runner.rs b/src/crates/assembly/core/src/agentic/memories/runner.rs index 6a812793b2..d271424858 100644 --- a/src/crates/assembly/core/src/agentic/memories/runner.rs +++ b/src/crates/assembly/core/src/agentic/memories/runner.rs @@ -66,11 +66,26 @@ impl MemoryPhase2Consolidator for InternalAgentMemoryPhase2Consolidator { model_id, task_description ); + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + // Explicitly register the internal execution folder. Once registered, + // internal Agent requests carry its ID and never infer SSH from a path. + let workspace = workspace_service + .track_workspace_activity( + memory_root.to_path_buf(), + crate::service::workspace::WorkspaceCreateOptions { + add_to_recent: false, + auto_set_current: false, + ..Default::default() + }, + crate::service::workspace::WorkspaceActivityMode::TouchOnly, + ) + .await?; let request = InternalAgentExecutionRequest { task_description, agent_type: "MemoryPhase2".to_string(), session_name: "Memory Phase 2 Consolidation".to_string(), - workspace_path: memory_root.to_string_lossy().to_string(), + workspace_id: workspace.id, model_id: model_id.clone(), created_by: Some("memory-phase2".to_string()), context: HashMap::new(), diff --git a/src/crates/assembly/core/src/agentic/memories/session_roots.rs b/src/crates/assembly/core/src/agentic/memories/session_roots.rs index 4ac1635708..92b3a69086 100644 --- a/src/crates/assembly/core/src/agentic/memories/session_roots.rs +++ b/src/crates/assembly/core/src/agentic/memories/session_roots.rs @@ -2,7 +2,7 @@ use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::service::workspace::{get_global_workspace_service, WorkspaceInfo, WorkspaceKind}; -use openbitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; +use openbitfun_runtime_ports::SessionStorePort; use std::collections::HashSet; use std::path::PathBuf; @@ -30,7 +30,16 @@ pub(super) async fn collect_local_session_storage_roots() -> Vec bool { ws.workspace_kind != WorkspaceKind::Remote } -async fn local_session_storage_dir_for_workspace(ws: &WorkspaceInfo) -> PathBuf { - let path_str = ws.root_path.to_string_lossy().to_string(); +async fn local_session_storage_dir_for_workspace(ws: &WorkspaceInfo) -> Option { CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: ws.root_path.clone(), - remote_connection_id: None, - remote_ssh_host: None, - }) + .resolve_workspace_storage(&ws.id) .await .map(|resolution| resolution.effective_storage_path) - .unwrap_or_else(|_| PathBuf::from(path_str)) + .ok() } #[cfg(test)] diff --git a/src/crates/assembly/core/src/agentic/permission_policy.rs b/src/crates/assembly/core/src/agentic/permission_policy.rs index 8f77edcd6a..a637fb7569 100644 --- a/src/crates/assembly/core/src/agentic/permission_policy.rs +++ b/src/crates/assembly/core/src/agentic/permission_policy.rs @@ -68,7 +68,7 @@ pub(crate) fn derive_parent_permission_runtime_ceiling( pub(crate) async fn load_parent_permission_runtime_ceiling( agent_type: Option<&str>, - workspace_root: Option<&std::path::Path>, + workspace_id: Option<&str>, ) -> OpenBitFunResult { let service = GlobalConfigManager::get_service().await?; let global: GlobalConfig = service.get_config(None).await?; @@ -78,7 +78,7 @@ pub(crate) async fn load_parent_permission_runtime_ceiling( }); let definition_constraints = agent_type.and_then(|agent_type| { crate::agentic::agents::get_agent_registry() - .get_agent(agent_type, workspace_root) + .get_agent(agent_type, workspace_id) .map(|agent| agent.permission_constraints().clone()) }); Ok(derive_parent_permission_runtime_ceiling( diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 938280ed02..df687d6386 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -1132,17 +1132,21 @@ impl PersistenceManager { ) -> SessionMetadata { let last_active_at = Self::system_time_to_unix_ms(session.last_activity_at); - let resolved_identity = - if let Some(workspace_root) = session.config.workspace_path.as_deref() { - resolve_workspace_session_identity( - workspace_root, - session.config.remote_connection_id.as_deref(), - session.config.remote_ssh_host.as_deref(), - ) + let resolved_identity = if let Some(id) = session.config.workspace_id.as_deref() { + crate::agentic::WorkspaceBinding::resolve(id) .await - } else { - None - }; + .ok() + .map(|binding| binding.session_identity) + } else if let Some(workspace_root) = session.config.workspace_path.as_deref() { + resolve_workspace_session_identity( + workspace_root, + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + ) + .await + } else { + None + }; let workspace_root = resolved_identity .as_ref() @@ -1155,7 +1159,7 @@ impl PersistenceManager { .map(|identity| identity.hostname.clone()) .or_else(|| existing.and_then(|value| value.workspace_hostname.clone())) .or_else(|| { - if session.config.remote_connection_id.is_some() { + if session.config.is_remote_workspace() { session.config.remote_ssh_host.clone() } else { Some(LOCAL_WORKSPACE_SSH_HOST.to_string()) @@ -1163,6 +1167,8 @@ impl PersistenceManager { }); build_persisted_session_metadata(SessionMetadataBuildFacts { + workspace_id: session.config.workspace_id.as_deref(), + project_workspace_id: session.config.project_workspace_id.as_deref(), session_id: &session.session_id, session_name: &session.session_name, agent_type: &session.agent_type, @@ -2546,11 +2552,11 @@ impl PersistenceManager { Ok(session) } - fn build_session_from_persisted_parts( + async fn build_session_from_persisted_parts( metadata: SessionMetadata, stored_state: Option, turns: &[DialogTurnData], - ) -> Session { + ) -> OpenBitFunResult { let legacy_minimal = stored_state .as_ref() .is_some_and(|value| value.config.legacy_minimal_agent); @@ -2559,6 +2565,12 @@ impl PersistenceManager { .map(|value| value.config.clone()) .unwrap_or_default(); config.legacy_minimal_agent = false; + if config.project_workspace_id.is_none() { + config.project_workspace_id = metadata.project_workspace_id.clone(); + } + if config.workspace_id.is_none() { + config.workspace_id = metadata.workspace_id.clone(); + } if config.workspace_path.is_none() { config.workspace_path = metadata.workspace_path.clone(); } @@ -2568,6 +2580,7 @@ impl PersistenceManager { .clone() .filter(|host| host != LOCAL_WORKSPACE_SSH_HOST && host != "_unresolved"); } + crate::agentic::workspace::normalize_session_workspace(&mut config).await?; if config.model_id.is_none() && !metadata.model_name.is_empty() { config.model_id = Some(metadata.model_name.clone()); } @@ -2584,7 +2597,7 @@ impl PersistenceManager { let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); let dialog_turn_ids = turns.iter().map(|turn| turn.turn_id.clone()).collect(); - Session { + Ok(Session { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: if legacy_minimal { @@ -2613,7 +2626,7 @@ impl PersistenceManager { created_at, updated_at: last_activity_at, last_activity_at, - } + }) } /// Read identity/config facts without loading dialog content or restoring a @@ -2633,11 +2646,7 @@ impl PersistenceManager { let state = self .load_stored_session_state(storage_path, session_id) .await?; - Ok(Self::build_session_from_persisted_parts( - metadata, - state, - &[], - )) + Self::build_session_from_persisted_parts(metadata, state, &[]).await } /// Load session and return the persisted turns read while rebuilding the session header. @@ -2697,7 +2706,8 @@ impl PersistenceManager { let turns = read_result.turns; let build_started_at = Instant::now(); - let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let session = + Self::build_session_from_persisted_parts(metadata, stored_state, &turns).await?; let build_session_duration_ms = elapsed_ms_u64(build_started_at); let total_duration_ms = elapsed_ms_u64(started_at); @@ -2834,7 +2844,8 @@ impl PersistenceManager { ) }; let build_started_at = Instant::now(); - let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let session = + Self::build_session_from_persisted_parts(metadata, stored_state, &turns).await?; let build_session_duration_ms = elapsed_ms_u64(build_started_at); let total_duration = started_at.elapsed(); @@ -4606,6 +4617,11 @@ mod tests { Uuid::new_v4() )); std::fs::create_dir_all(&path).expect("test workspace should be created"); + // Sessions only exist inside registered workspaces; register the + // fixture directory like a host that opened this folder. Keep the + // canonical path so record-derived IO projections compare equal. + let path = dunce::canonicalize(&path).expect("test workspace should canonicalize"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&path); Self { path } } @@ -4777,6 +4793,52 @@ mod tests { assert!(error.to_string().contains("session_id"), "{error}"); } + #[tokio::test] + async fn legacy_local_session_identity_is_repaired_after_restart_and_resave() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(workspace.path_manager()).unwrap(); + let mut value = serde_json::to_value(SessionConfig::default()).unwrap(); + value["workspace_path"] = serde_json::json!(workspace.path().to_string_lossy()); + value["remote_ssh_host"] = serde_json::json!("localhost"); + value.as_object_mut().unwrap().remove("workspace_kind"); + let config: SessionConfig = serde_json::from_value(value).unwrap(); + let session = Session::new_with_id( + "legacy-local-identity".into(), + "Keep title".into(), + "Standard".into(), + config, + ); + manager + .save_session(workspace.path(), &session) + .await + .unwrap(); + drop(manager); + let manager = PersistenceManager::new(workspace.path_manager()).unwrap(); + let restored = manager + .load_session(workspace.path(), &session.session_id) + .await + .unwrap(); + assert_eq!(restored.session_name, "Keep title"); + assert_eq!( + restored.config.workspace_kind, + Some(openbitfun_core_types::WorkspaceKind::Normal) + ); + assert!(restored.config.remote_ssh_host.is_none()); + assert!(!restored.config.is_remote_workspace()); + manager + .save_session(workspace.path(), &restored) + .await + .unwrap(); + drop(manager); + let manager = PersistenceManager::new(workspace.path_manager()).unwrap(); + let again = manager + .load_session(workspace.path(), &session.session_id) + .await + .unwrap(); + assert_eq!(again.config.workspace_kind, restored.config.workspace_kind); + assert!(again.config.remote_ssh_host.is_none()); + } + #[tokio::test] async fn session_list_preserves_the_persisted_model_selector() { let workspace = TestWorkspace::new(); @@ -7571,11 +7633,19 @@ mod tests { let workspace = TestWorkspace::new(); let path_manager = workspace.path_manager(); let manager = PersistenceManager::new(path_manager.clone()).expect("persistence manager"); + let remote_path = format!("/home/wsp/corrupt-index-{}", Uuid::new_v4()); + let remote_workspace = crate::service::workspace::legacy_compat::register_remote_fixture( + &remote_path, + "ssh-1", + "dev-host", + ) + .await; let sessions_dir = crate::service::WorkspaceRuntimeService::new(path_manager) - .context_for_remote_workspace("dev-host", "/home/wsp/project") + .context_for_remote_workspace("dev-host", &remote_path) .sessions_dir; let config = SessionConfig { - workspace_path: Some("/home/wsp/project".to_string()), + workspace_id: Some(remote_workspace.id.clone()), + workspace_path: Some(remote_path.clone()), remote_connection_id: Some("ssh-1".to_string()), remote_ssh_host: Some("dev-host".to_string()), ..Default::default() diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index 19738268e4..6f95dc6ac9 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -292,6 +292,11 @@ mod tests { let path = std::env::temp_dir() .join(format!("openbitfun-session-branch-test-{}", Uuid::new_v4())); std::fs::create_dir_all(&path).expect("test workspace should be created"); + // Sessions only exist inside registered workspaces; register the + // fixture directory like a host that opened this folder. Keep the + // canonical path so record-derived IO projections compare equal. + let path = dunce::canonicalize(&path).expect("test workspace should canonicalize"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&path); Self { path } } diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 91df792db8..25d083d527 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -39,7 +39,6 @@ use crate::service::config::{ get_app_language_code, get_global_config_service, short_model_user_language_instruction, subscribe_config_updates, ConfigUpdateEvent, }; -use crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST; use crate::service::session::{ DialogTurnData, DialogTurnKind, DialogTurnRecoveryData, DialogTurnRecoveryStatus, ModelRoundData, SessionContextUsage, SessionMemoryMode, SessionMetadata, SessionRelationship, @@ -49,7 +48,7 @@ use crate::service::session::{ use crate::service::snapshot::{ ensure_snapshot_manager_for_workspace, get_or_create_snapshot_manager, }; -use crate::service::workspace::{get_global_workspace_service, WorkspaceInfo, WorkspaceKind}; +use crate::service::workspace::{get_global_workspace_service, WorkspaceInfo}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; @@ -98,6 +97,11 @@ pub struct SessionManagerConfig { #[serde(rename_all = "camelCase")] pub struct SessionReferenceLocator { pub session_id: String, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. New producers send workspace_id. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -1069,6 +1073,15 @@ impl SessionManager { persistence_manager: &PersistenceManager, config: &SessionConfig, ) -> Option { + if let Some(id) = config.workspace_id.as_deref() { + return CoreSessionStorePort::with_path_manager( + persistence_manager.path_manager().clone(), + ) + .resolve_workspace_storage(id) + .await + .ok() + .map(|resolution| resolution.effective_storage_path); + } let workspace_path = config.workspace_path.as_ref()?; let identity = crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( @@ -1079,7 +1092,7 @@ impl SessionManager { .await?; let runtime_service = persistence_manager.runtime_service(); - Some(if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { + Some(if !identity.is_remote() { let project_workspace_path = config .project_workspace_path .as_deref() @@ -1311,10 +1324,15 @@ impl SessionManager { .map_err(OpenBitFunError::Validation)?; openbitfun_core_types::validate_session_id(reference_artifact_stem) .map_err(OpenBitFunError::Validation)?; + let workspace_id = reference + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); let workspace_path = reference.workspace_path.trim(); - if workspace_path.is_empty() { + if workspace_id.is_none() && workspace_path.is_empty() { return Err(OpenBitFunError::Validation( - "Referenced session workspace_path is required".to_string(), + "Referenced session workspace_id is required".to_string(), )); } @@ -1332,13 +1350,18 @@ impl SessionManager { source_session_id )) })?; - let reference_storage_path = self - .resolve_storage_path_for_request(SessionStoragePathRequest { - workspace_path: PathBuf::from(workspace_path), - remote_connection_id: reference.remote_connection_id.clone(), - remote_ssh_host: reference.remote_ssh_host.clone(), - }) - .await?; + let reference_storage_path = CoreSessionStorePort::with_path_manager( + self.persistence_manager.path_manager().clone(), + ) + .resolve_storage_for_reference( + workspace_id, + workspace_path, + reference.remote_connection_id.clone(), + reference.remote_ssh_host.clone(), + ) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| OpenBitFunError::Session(error.to_string()))?; if source_session_id == reference.session_id && source_storage_path == reference_storage_path @@ -1498,96 +1521,26 @@ impl SessionManager { metadata: &SessionMetadata, workspace_hint: Option<&WorkspaceInfo>, ) -> Option { - let workspace_path = metadata - .workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string) - .or_else(|| { - workspace_hint.map(|workspace| workspace.root_path.to_string_lossy().to_string()) - })?; - let mut config = SessionConfig { - workspace_path: Some(workspace_path.clone()), + workspace_id: metadata + .workspace_id + .clone() + .or_else(|| workspace_hint.map(|record| record.id.clone())), + project_workspace_id: metadata.project_workspace_id.clone(), + workspace_path: metadata.workspace_path.clone().or_else(|| { + workspace_hint.map(|record| record.root_path.to_string_lossy().into_owned()) + }), project_workspace_path: metadata.project_workspace_path.clone(), execution_target: metadata.execution_target.clone(), + remote_ssh_host: metadata.workspace_hostname.clone(), ..SessionConfig::default() }; - - let remote_hostname = metadata - .workspace_hostname - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty() && *value != LOCAL_WORKSPACE_SSH_HOST) - .map(str::to_string); - - let matched_workspace = match workspace_hint { - Some(workspace) => Some(workspace.clone()), - None if remote_hostname.is_some() => { - self.match_tracked_remote_workspace(&workspace_path, remote_hostname.as_deref()) - .await - } - None => None, - }; - - if let Some(workspace) = matched_workspace.as_ref() { - config.workspace_id = Some(workspace.id.clone()); - if workspace.workspace_kind == WorkspaceKind::Remote { - config.remote_connection_id = - workspace.remote_ssh_connection_id().map(ToOwned::to_owned); - config.remote_ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - config.remote_connection_id.as_ref()?; - } - } else if remote_hostname.is_some() { - return None; - } - + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .ok()?; Some(config) } - async fn match_tracked_remote_workspace( - &self, - workspace_path: &str, - ssh_host: Option<&str>, - ) -> Option { - let ssh_host = ssh_host.map(str::trim).filter(|value| !value.is_empty())?; - - let normalized_workspace_path = - crate::service::remote_ssh::normalize_remote_workspace_path(workspace_path); - - self.tracked_workspace_candidates() - .await? - .into_iter() - .find(|workspace| { - if workspace.workspace_kind != WorkspaceKind::Remote { - return false; - } - - if crate::service::remote_ssh::normalize_remote_workspace_path( - &workspace.root_path.to_string_lossy(), - ) != normalized_workspace_path - { - return false; - } - - let workspace_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()); - - workspace_host == Some(ssh_host) - }) - } - async fn tracked_workspace_candidates(&self) -> Option> { let workspace_service = get_global_workspace_service()?; let mut workspaces = workspace_service.list_workspace_infos().await; @@ -1596,21 +1549,8 @@ impl SessionManager { } async fn session_storage_path_for_workspace_info(workspace: &WorkspaceInfo) -> Option { - let remote_connection_id = workspace.remote_ssh_connection_id().map(ToOwned::to_owned); - let remote_ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: workspace.root_path.clone(), - remote_connection_id, - remote_ssh_host, - }) + .resolve_workspace_storage(&workspace.id) .await .ok() .map(|resolution| resolution.effective_storage_path) @@ -2770,6 +2710,8 @@ impl SessionManager { kind: SessionKind, transient: bool, ) -> OpenBitFunResult { + let mut config = config; + crate::agentic::workspace::normalize_session_workspace(&mut config).await?; let _workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { OpenBitFunError::Validation("Session workspace_path is required".to_string()) })?; @@ -4971,7 +4913,7 @@ impl SessionManager { ))); } self.cleanup_session_owned_resources( - workspace_path, + session.config.workspace_id.as_deref(), session_id, SessionResourceCleanupPolicy::Required, ) @@ -5046,7 +4988,7 @@ impl SessionManager { async fn cleanup_session_owned_resources( &self, - cleanup_workspace_path: &Path, + workspace_id: Option<&str>, session_id: &str, policy: SessionResourceCleanupPolicy, ) -> OpenBitFunResult<()> { @@ -5063,7 +5005,8 @@ impl SessionManager { } }; - if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(cleanup_workspace_path) + if let Some(snapshot_manager) = + workspace_id.and_then(|id| ensure_snapshot_manager_for_workspace(id).ok()) { let snapshot_service = snapshot_manager.get_snapshot_service(); let snapshot_service = snapshot_service.read().await; @@ -5116,6 +5059,10 @@ impl SessionManager { session_id: &str, ) -> OpenBitFunResult<()> { let delete_started_at = Instant::now(); + let cleanup_workspace_id = self + .resolve_session_workspace_binding(session_id) + .await + .and_then(|binding| binding.workspace_id); let _temporary_write_lock = if self.config.enable_persistence && !self.is_transient_session(session_id) && !self.session_write_locks.contains_key(session_id) @@ -5161,11 +5108,10 @@ impl SessionManager { session_id, elapsed_ms_u64(persistence_stage_started_at) ); - if let Some(revert_state) = revert_state { - match get_or_create_snapshot_manager( - cleanup_workspace_path.to_path_buf(), - None, - ) + if let (Some(revert_state), Some(workspace_id)) = + (revert_state, cleanup_workspace_id.as_deref()) + { + match get_or_create_snapshot_manager(workspace_id, None) .await { Ok(snapshot_manager) => { @@ -5188,7 +5134,7 @@ impl SessionManager { } self.cleanup_session_owned_resources( - cleanup_workspace_path, + cleanup_workspace_id.as_deref(), session_id, SessionResourceCleanupPolicy::BestEffort, ) @@ -5910,26 +5856,13 @@ impl SessionManager { let mut auto_cleared_reasoning_preset = None; if !include_internal { - let external_workspace_root = - crate::agentic::workspace::session_execution_workspace_root(&session.config); - let workspace_path_is_remote = match external_workspace_root { - Some(path) => { - crate::service::remote_ssh::workspace_state::is_remote_path( - &path.to_string_lossy(), - ) - .await - } - None => false, - }; - let external_sources_supported = cfg!(feature = "external-sources") - && session.config.remote_connection_id.is_none() - && session.config.remote_ssh_host.is_none() - && !workspace_path_is_remote; + let external_sources_supported = + cfg!(feature = "external-sources") && !session.config.is_remote_workspace(); #[cfg(feature = "external-sources")] if external_sources_supported { if let Err(error) = crate::external_sources::ensure_external_source_workspace_snapshot( - external_workspace_root, + session.config.workspace_id.as_deref(), ) .await { @@ -5942,14 +5875,17 @@ impl SessionManager { } let agent_registry = get_agent_registry(); agent_registry - .load_custom_agents(external_workspace_root) + .load_custom_agents(session.config.workspace_id.as_deref()) .await; let available_modes = agent_registry - .get_modes_info_for_workspace(external_workspace_root, external_sources_supported) + .get_modes_info_for_workspace( + session.config.workspace_id.as_deref(), + external_sources_supported, + ) .await; let persisted_binding = agent_registry.resolve_primary_agent_for_turn_with_route( &session.agent_type, - external_workspace_root, + session.config.workspace_id.as_deref(), external_sources_supported, Some(session.config.agent_route_owner), session.config.agent_route_key.as_deref(), @@ -9561,7 +9497,7 @@ impl SessionManager { .map(str::trim) .filter(|model_id| !model_id.is_empty()); let fallback_model_id = if explicit_model_id.is_none() { - let workspace = session.config.workspace_path.as_deref().map(Path::new); + let workspace = session.config.workspace_id.as_deref(); Some( get_agent_registry() .get_model_id_for_agent(&session.agent_type, workspace) @@ -9986,22 +9922,39 @@ mod tests { struct TestWorkspace { path: PathBuf, + workspace_id: String, } impl TestWorkspace { + /// Creates the directory and registers it as a local workspace record + /// in the shared fixture catalog. Sessions only exist inside registered + /// workspaces, so a path-only `SessionConfig` naming this directory + /// resolves to that record exactly like a folder a host has opened. + /// The path is canonical so IO projections written back from the + /// record compare equal on hosts with symlinked temp roots. fn new() -> Self { let path = std::env::temp_dir().join(format!( "openbitfun-session-restore-test-{}", Uuid::new_v4() )); std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } + let path = dunce::canonicalize(&path).expect("test workspace should canonicalize"); + let record = + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&path); + Self { + path, + workspace_id: record.id, + } } fn path(&self) -> &Path { &self.path } + fn workspace_id(&self) -> &str { + &self.workspace_id + } + fn path_manager(&self) -> Arc { Arc::new(PathManager::with_user_root_for_tests( self.path.join("user-root"), @@ -11088,7 +11041,7 @@ mod tests { execution_target: Some(SessionExecutionTarget::local( original_workspace.clone(), )), - workspace_id: Some("workspace-original".to_string()), + workspace_id: Some(workspace.workspace_id().to_string()), ..SessionConfig::default() }, ) @@ -13149,12 +13102,18 @@ mod tests { .await .expect("session should create"); - manager - .sessions - .get_mut(&session.session_id) - .expect("loaded session") - .config - .workspace_path = None; + { + // Simulate a session whose persistence location can no longer be + // resolved: neither its workspace record nor an IO projection. + let mut loaded = manager + .sessions + .get_mut(&session.session_id) + .expect("loaded session"); + loaded.config.workspace_id = None; + loaded.config.project_workspace_id = None; + loaded.config.workspace_path = None; + loaded.config.project_workspace_path = None; + } manager .update_session_title(&session.session_id, "Not persisted") @@ -14780,30 +14739,54 @@ mod tests { #[tokio::test] async fn core_session_store_port_resolves_unresolved_remote_storage_path() { use openbitfun_runtime_ports::{ - SessionStorageKind, SessionStoragePathRequest, SessionStorePort, + PortErrorKind, SessionStorageKind, SessionStoragePathRequest, SessionStorePort, }; let workspace = TestWorkspace::new(); - let port = CoreSessionStorePort::with_path_manager_for_tests(workspace.path_manager()); - let resolution = port + let path_manager = workspace.path_manager(); + let port = CoreSessionStorePort::with_path_manager_for_tests(path_manager.clone()); + + // A connection ID alone is transport metadata, not a workspace + // identity. Without a registered remote record the request must fail + // loudly instead of inventing an `_unresolved` mirror. + let error = port .resolve_session_storage_path(SessionStoragePathRequest { workspace_path: PathBuf::from("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/remote/project"), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: None, }) .await - .expect("storage path should resolve"); + .expect_err("unregistered remote reference must not resolve storage"); + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + assert!( + error.message.contains("does not resolve"), + "unexpected error: {}", + error.message + ); + // Sessions already persisted under a legacy `_unresolved` mirror stay + // readable: the resolved sessions dir passes through with its kind. + let legacy_unresolved_dir = + openbitfun_services_core::workspace_identity::unresolved_remote_session_storage_dir( + path_manager.remote_ssh_mirror_root_dir(), + "conn-1", + "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/remote/project", + ); + let resolution = port + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: legacy_unresolved_dir.clone(), + remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: None, + }) + .await + .expect("legacy unresolved sessions dir should pass through"); assert_eq!( resolution.storage_kind, SessionStorageKind::UnresolvedRemote ); assert!(resolution.is_remote_storage()); assert_eq!(resolution.remote_connection_id.as_deref(), Some("conn-1")); - assert_ne!( - resolution.effective_storage_path, - PathBuf::from("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/remote/project") - ); + assert_eq!(resolution.effective_storage_path, legacy_unresolved_dir); } #[cfg(feature = "remote-workspace")] @@ -14921,6 +14904,12 @@ mod tests { #[tokio::test] async fn restore_session_for_workspace_uses_remote_identity() { + crate::service::workspace::legacy_compat::register_remote_fixture( + "/home/wsp/project", + "ssh-1", + "dev-host", + ) + .await; let workspace = TestWorkspace::new(); let path_manager = workspace.path_manager(); let persistence_manager = @@ -17851,6 +17840,12 @@ mod tests { #[cfg(feature = "remote-workspace")] #[tokio::test] async fn remote_workspace_evidence_uses_the_resolved_session_mirror() { + crate::service::workspace::legacy_compat::register_remote_fixture( + "/home/wsp/project", + "ssh-1", + "dev-host", + ) + .await; let workspace = TestWorkspace::new(); let path_manager = workspace.path_manager(); let persistence_manager = diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index a876f1ad54..afb93aefef 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -9,10 +9,8 @@ use openbitfun_runtime_ports::{ use crate::agentic::core::SessionConfig; use crate::infrastructure::{get_path_manager_arc, PathManager}; use crate::service::WorkspaceRuntimeService; -#[cfg(not(feature = "remote-workspace"))] -use openbitfun_services_core::workspace_identity::workspace_session_identity; use openbitfun_services_core::workspace_identity::{ - unresolved_remote_session_storage_dir, WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, + unresolved_remote_session_storage_dir, WorkspaceSessionIdentity, }; async fn resolve_workspace_session_identity( @@ -20,18 +18,20 @@ async fn resolve_workspace_session_identity( remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> Option { - #[cfg(feature = "remote-workspace")] - { - return crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - remote_connection_id, - remote_ssh_host, - ) - .await; - } - - #[cfg(not(feature = "remote-workspace"))] - workspace_session_identity(workspace_path, remote_connection_id, remote_ssh_host) + let mut config = SessionConfig { + workspace_path: Some(workspace_path.to_owned()), + remote_connection_id: remote_connection_id.map(str::to_owned), + remote_ssh_host: remote_ssh_host.map(str::to_owned), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .ok()?; + openbitfun_services_core::workspace_identity::workspace_session_identity( + workspace_path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) } #[derive(Debug, Clone, Default)] @@ -60,6 +60,9 @@ impl CoreSessionStorePort { pub async fn resolve_storage_path_for_config( config: &SessionConfig, ) -> Option { + if let Some(id) = config.workspace_id.as_deref() { + return Self::default().resolve_workspace_storage(id).await.ok(); + } let workspace_path = config.workspace_path.as_ref()?; let request = SessionStoragePathRequest { workspace_path: PathBuf::from(workspace_path), @@ -72,6 +75,34 @@ impl CoreSessionStorePort { .ok() } + /// ID-first storage resolution for request DTOs that still carry a + /// pre-ID `(path, connection, ssh)` selector. The ID is authoritative + /// whenever present; the legacy selector is only consulted for producers + /// that predate workspace IDs. + pub(crate) async fn resolve_storage_for_reference( + &self, + workspace_id: Option<&str>, + workspace_path: &str, + remote_connection_id: Option, + remote_ssh_host: Option, + ) -> PortResult { + if let Some(id) = workspace_id.map(str::trim).filter(|id| !id.is_empty()) { + return self.resolve_workspace_storage(id).await; + } + if workspace_path.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Session request must carry a workspace_id or a legacy workspace_path", + )); + } + self.resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: PathBuf::from(workspace_path), + remote_connection_id, + remote_ssh_host, + }) + .await + } + fn has_parent_traversal(path: &Path) -> bool { path.components() .any(|component| matches!(component, Component::ParentDir)) @@ -167,6 +198,75 @@ impl RuntimeServicePort for CoreSessionStorePort { #[async_trait::async_trait] impl SessionStorePort for CoreSessionStorePort { + async fn resolve_workspace_storage( + &self, + workspace_id: &str, + ) -> PortResult { + use crate::service::workspace::{get_global_workspace_service, WorkspaceKind}; + let service = get_global_workspace_service().ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + "Workspace service is unavailable", + ) + })?; + let workspace = service + .require_workspace(workspace_id) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; + let project_id = workspace + .project_workspace_id() + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error))?; + let project = service + .require_workspace(project_id) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; + let runtime = WorkspaceRuntimeService::new(self.path_manager()); + let (storage, kind, connection_id, host) = match workspace.workspace_kind { + WorkspaceKind::Normal | WorkspaceKind::Assistant => ( + runtime + .context_for_local_workspace(&project.root_path) + .sessions_dir, + SessionStorageKind::Local, + None, + None, + ), + WorkspaceKind::Remote => { + let connection_id = workspace.remote_ssh_connection_id().ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + "Remote workspace record is missing its saved SSH connection ID", + ) + })?; + let host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .filter(|host| !host.trim().is_empty()) + .ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + "Remote workspace record is missing its SSH host", + ) + })?; + ( + runtime + .context_for_remote_workspace(host, &workspace.root_path.to_string_lossy()) + .sessions_dir, + SessionStorageKind::Remote, + Some(connection_id.to_owned()), + Some(host.to_owned()), + ) + } + }; + Ok(SessionStoragePathResolution::new( + workspace.root_path, + storage, + kind, + connection_id, + host, + )) + } + async fn resolve_session_storage_path( &self, request: SessionStoragePathRequest, @@ -215,37 +315,36 @@ impl SessionStorePort for CoreSessionStorePort { let requested_workspace_path = request.workspace_path; let runtime_service = WorkspaceRuntimeService::new(path_manager.clone()); - let (effective_storage_path, storage_kind, remote_ssh_host) = - if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { - ( - runtime_service - .context_for_local_workspace(Path::new(identity.logical_workspace_path())) - .sessions_dir, - SessionStorageKind::Local, - None, - ) - } else if identity.hostname == "_unresolved" { - ( - unresolved_remote_session_storage_dir( - path_manager.remote_ssh_mirror_root_dir(), - identity.remote_connection_id.as_deref().unwrap_or_default(), + let (effective_storage_path, storage_kind, remote_ssh_host) = if !identity.is_remote() { + ( + runtime_service + .context_for_local_workspace(Path::new(identity.logical_workspace_path())) + .sessions_dir, + SessionStorageKind::Local, + None, + ) + } else if identity.hostname == "_unresolved" { + ( + unresolved_remote_session_storage_dir( + path_manager.remote_ssh_mirror_root_dir(), + identity.remote_connection_id.as_deref().unwrap_or_default(), + identity.logical_workspace_path(), + ), + SessionStorageKind::UnresolvedRemote, + None, + ) + } else { + ( + runtime_service + .context_for_remote_workspace( + &identity.hostname, identity.logical_workspace_path(), - ), - SessionStorageKind::UnresolvedRemote, - None, - ) - } else { - ( - runtime_service - .context_for_remote_workspace( - &identity.hostname, - identity.logical_workspace_path(), - ) - .sessions_dir, - SessionStorageKind::Remote, - Some(identity.hostname.clone()), - ) - }; + ) + .sessions_dir, + SessionStorageKind::Remote, + Some(identity.hostname.clone()), + ) + }; Ok(SessionStoragePathResolution::new( requested_workspace_path, diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs index 82183488d5..6056850f60 100644 --- a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -41,12 +41,15 @@ pub async fn resolve_skill_agent_snapshot( .load_custom_agents( workspace .filter(|binding| !binding.is_remote()) - .map(|binding| binding.root_path()), + .and_then(|binding| binding.workspace_id.as_deref()), ) .await; let tool_policy = agent_registry - .get_agent_tool_policy(agent_type, workspace.map(|binding| binding.root_path())) + .get_agent_tool_policy( + agent_type, + workspace.and_then(|binding| binding.workspace_id.as_deref()), + ) .await; let tool_description_context = tool_context_runtime::build_tool_description_context( @@ -167,10 +170,7 @@ async fn load_skill_entries( } Some(workspace) => { registry - .get_implicitly_invocable_skills_for_workspace( - Some(workspace.root_path()), - agent_type, - ) + .get_implicitly_invocable_skills_for_workspace(Some(workspace), agent_type) .await } None => { @@ -200,13 +200,13 @@ async fn load_subagent_entries( runtime_tool_restrictions: &ToolRuntimeRestrictions, ) -> Vec { let registry = get_agent_registry(); - let workspace_root = workspace + let workspace_id = workspace .filter(|workspace| !workspace.is_remote()) - .map(|workspace| workspace.root_path()); + .and_then(|workspace| workspace.workspace_id.as_deref()); let agents = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: agent_type, - workspace_root, + workspace_id, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: false, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 8a43dca7a1..de6775c0d6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -164,12 +164,10 @@ impl CronTool { ) -> OpenBitFunResult<()> { let sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace_ref - .project_workspace_path - .clone() - .unwrap_or_else(|| workspace_ref.workspace_path.clone()), - remote_connection_id: workspace_ref.remote_connection_id.clone(), - remote_ssh_host: workspace_ref.remote_ssh_host.clone(), + workspace_id: workspace_ref.workspace_id.clone(), + workspace_path: String::new(), + remote_connection_id: None, + remote_ssh_host: None, }) .await .map_err(|error| { @@ -1089,9 +1087,7 @@ Patch schema for "update": let workspace = workspace_ref.workspace_path.clone(); let mut jobs = cron_service .list_jobs_filtered( - Some(&workspace_ref.workspace_path), workspace_ref.workspace_id.as_deref(), - workspace_ref.remote_connection_id.as_deref(), Some(&session_id), Some(CronJobTargetKind::Session), ) @@ -1418,6 +1414,8 @@ mod tests { fn workspace_ref_from_agent_binding_preserves_full_workspace_identity() { let workspace_ref = CronTool::workspace_ref_from_agent_binding(AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace-1".to_string()), workspace_path: "/home/wsp/projects/test".to_string(), project_workspace_path: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs index f275b38dfc..29885c5327 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs @@ -249,6 +249,7 @@ mod tests { "connection-1".to_string(), "Remote".to_string(), crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "remote.example".to_string(), logical_workspace_path: "/srv/project".to_string(), remote_connection_id: Some("connection-1".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 7c595690bb..90c53c698b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -7,7 +7,7 @@ use crate::service::git::git_types::GitDiffParams; use crate::service::git::git_utils::get_repository_root; use crate::service::review_platform::{ ReviewPlatformError, ReviewPlatformKind, ReviewPlatformPullRequestFileDiff, - ReviewPlatformService, + ReviewPlatformService, ReviewRepositoryLocator, }; use crate::service::snapshot::manager::get_snapshot_manager_for_workspace; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; @@ -57,10 +57,10 @@ enum ProviderFileDiffRoute { head_revision: String, file_path: String, file_page_hint: Option, - repository_path: Option, + repository: Option, }, WorkspaceRemote { - repository_path: String, + repository: ReviewRepositoryLocator, remote_id: String, pull_request_id: String, base_revision: String, @@ -97,7 +97,7 @@ impl ProviderFileDiffService for CoreProviderFileDiffService { head_revision, file_path, file_page_hint, - repository_path, + repository, } => { ReviewPlatformService::pull_request_file_diff_by_identity( *platform, @@ -108,12 +108,12 @@ impl ProviderFileDiffService for CoreProviderFileDiffService { head_revision, file_path, *file_page_hint, - repository_path.as_deref(), + repository.as_ref(), ) .await } ProviderFileDiffRoute::WorkspaceRemote { - repository_path, + repository, remote_id, pull_request_id, base_revision, @@ -122,7 +122,7 @@ impl ProviderFileDiffService for CoreProviderFileDiffService { file_page_hint, } => { ReviewPlatformService::pull_request_file_diff( - repository_path, + repository, remote_id, pull_request_id, base_revision, @@ -226,9 +226,12 @@ impl GetFileDiffTool { head_revision: head_revision.to_string(), file_path: logical_path.to_string(), file_page_hint, - repository_path: context - .workspace_root() - .map(|path| path.to_string_lossy().into_owned()), + repository: context.workspace_root().map(|path| { + ReviewRepositoryLocator::new( + context.workspace_id().map(str::to_string), + path.to_string_lossy().into_owned(), + ) + }), }); } let Some(repository_path) = context.workspace_root() else { @@ -238,7 +241,10 @@ impl GetFileDiffTool { return Ok(ProviderFileDiffRoute::Unavailable); } Ok(ProviderFileDiffRoute::WorkspaceRemote { - repository_path: repository_path.to_string_lossy().into_owned(), + repository: ReviewRepositoryLocator::new( + context.workspace_id().map(str::to_string), + repository_path.to_string_lossy().into_owned(), + ), remote_id: pull_request.remote_id().to_string(), pull_request_id: pull_request.pull_request_id().to_string(), base_revision: base_revision.to_string(), @@ -852,9 +858,9 @@ impl GetFileDiffTool { async fn try_baseline_diff( &self, file_path: &Path, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option> { - let snapshot_manager = workspace_root.and_then(get_snapshot_manager_for_workspace)?; + let snapshot_manager = workspace_id.and_then(get_snapshot_manager_for_workspace)?; // Get snapshot service let snapshot_service = snapshot_manager.get_snapshot_service(); @@ -1736,7 +1742,16 @@ Usage: } if !prepared_review { - if let Some(result) = self.try_baseline_diff(path, context.workspace_root()).await { + if let Some(result) = self + .try_baseline_diff( + path, + context + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()), + ) + .await + { match result { Ok(data) => { debug!("GetFileDiff tool using baseline diff"); @@ -2090,7 +2105,7 @@ mod tests { head_revision: "2222222222222222222222222222222222222222".to_string(), file_path: "src/lib.rs".to_string(), file_page_hint: Some(1), - repository_path: None, + repository: None, } ); } @@ -2102,7 +2117,7 @@ mod tests { let mut context = prepared_context(); attach_review_budget_identity(&mut context, "gitcode-workspace-route"); context.workspace = Some(crate::agentic::WorkspaceBinding::new( - None, + Some("ws-gitcode-route".to_string()), directory.path().to_path_buf(), )); context.custom_data.insert( @@ -2157,7 +2172,10 @@ mod tests { .expect("recording service lock should be available") .as_slice(), &[ProviderFileDiffRoute::WorkspaceRemote { - repository_path: directory.path().to_string_lossy().into_owned(), + repository: ReviewRepositoryLocator::new( + Some("ws-gitcode-route".to_string()), + directory.path().to_string_lossy().into_owned(), + ), remote_id: "origin:gitcode:example__repo".to_string(), pull_request_id: "42".to_string(), base_revision: "1111111111111111111111111111111111111111".to_string(), @@ -2679,6 +2697,7 @@ mod tests { "connection-id".to_string(), "Remote".to_string(), crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "example.test".to_string(), logical_workspace_path: "/workspace".to_string(), remote_connection_id: Some("connection-id".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 03f5c5b69b..e2263b7560 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -1,7 +1,7 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::implementations::grep_tool::annotate_workspace_probe_pending; use crate::service::search::{ - get_global_workspace_search_service, remote_workspace_search_service_for_path, + get_global_workspace_search_service, remote_workspace_search_service_for_workspace, workspace_search_feature_enabled, workspace_search_runtime_available, GlobSearchRequest, }; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; @@ -275,18 +275,11 @@ impl Tool for GlobTool { let resolved_path = PathBuf::from(&effective_glob.search_path); let (_walk_root, effective_pattern) = resolve_effective_glob_scope(&resolved_path, &effective_glob.pattern); - let repo_root = workspace_root.to_string_lossy().to_string(); - let preferred_connection_id = context - .workspace - .as_ref() - .and_then(|workspace| workspace.connection_id()) - .map(str::to_string); - let search_service = remote_workspace_search_service_for_path( - &repo_root, - preferred_connection_id, - ) - .await - .map_err(OpenBitFunError::tool)?; + let workspace_id = context.workspace.as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()) + .ok_or_else(|| OpenBitFunError::tool("Remote search requires a workspace ID"))?; + let search_service = remote_workspace_search_service_for_workspace(workspace_id) + .await.map_err(OpenBitFunError::tool)?; let glob_result = search_service .glob(GlobSearchRequest { repo_root: workspace_root.clone(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs index 30ad740d32..01306c9b51 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs @@ -6,7 +6,7 @@ use crate::agentic::tools::miniapp_context_runtime::{ use crate::agentic::tools::parse_u64_value; use crate::agentic::tools::ToolPathOperation; use crate::service::search::{ - get_global_workspace_search_service, remote_workspace_search_service_for_path, + get_global_workspace_search_service, remote_workspace_search_service_for_workspace, workspace_search_feature_enabled, workspace_search_runtime_available, ContentSearchOutputMode, ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, }; @@ -666,16 +666,11 @@ Usage: .as_ref() .map(|path| path.to_string_lossy().to_string()) .unwrap_or_else(|| request.repo_root.to_string_lossy().to_string()); - let repo_root = request.repo_root.to_string_lossy().to_string(); - let preferred_connection_id = context - .workspace - .as_ref() - .and_then(|workspace| workspace.connection_id()) - .map(str::to_string); - let search_service = - remote_workspace_search_service_for_path(&repo_root, preferred_connection_id) - .await - .map_err(OpenBitFunError::tool)?; + let workspace_id = context.workspace.as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()) + .ok_or_else(|| OpenBitFunError::tool("Remote search requires a workspace ID"))?; + let search_service = remote_workspace_search_service_for_workspace(workspace_id) + .await.map_err(OpenBitFunError::tool)?; let search_started_at = Instant::now(); let search_result = search_service .search_content(request) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs index 0b1cbdc829..50f8470991 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs @@ -68,7 +68,7 @@ async fn ensure_mcp_server_available_for_context( context: &ToolUseContext, ) -> OpenBitFunResult<()> { if !manager - .server_available_for_context(server_id, context.workspace_root(), context.is_remote()) + .server_available_for_context(server_id, context.workspace_id(), context.is_remote()) .await { return Err(tool_error(format!( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs index bec8931d04..82506541eb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs @@ -222,7 +222,7 @@ impl OpenBitFunControlTool { return false; }; get_agent_registry() - .get_agent(agent_type, context.workspace_root()) + .get_agent(agent_type, context.workspace_id()) .is_some_and(|agent| agent.is_readonly()) } @@ -808,6 +808,7 @@ mod tests { "connection-1".to_string(), "Remote".to_string(), crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "remote.example".to_string(), logical_workspace_path: "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/remote/workspace".to_string(), remote_connection_id: Some("connection-1".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs index 9991207fdb..4d8a0ec683 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs @@ -494,6 +494,7 @@ mod tests { "connection-1".to_string(), "Remote".to_string(), crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "remote.example".to_string(), logical_workspace_path: "/srv/page".to_string(), remote_connection_id: Some("connection-1".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index e100808a50..8ef28a5fac 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -11,7 +11,7 @@ use crate::service::review_platform::{ ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformListState, ReviewPlatformRemote, ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, ReviewPlatformResolveThreadRequest, ReviewPlatformService, ReviewPlatformSubmitReviewRequest, - ReviewSubmitEvent, + ReviewRepositoryLocator, ReviewSubmitEvent, }; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use async_trait::async_trait; @@ -150,14 +150,15 @@ impl ReviewPlatformTool { async fn resolve_remote_id( action: &str, - repository_path: &str, + repository: &ReviewRepositoryLocator, input: &Value, ) -> OpenBitFunResult> { if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { return Ok(Ok(remote_id)); } - let remotes = ReviewPlatformService::discover_remotes(repository_path) + let repository_path = repository.repository_path.as_str(); + let remotes = ReviewPlatformService::discover_remotes(repository) .await .map_err(|error| OpenBitFunError::tool(error.to_string()))?; let supported = canonical_supported_remotes(&remotes); @@ -760,8 +761,17 @@ When returning pull request results to the user, include the provider web URL so } _ => Self::repository_path(input, context)?, }; + // The owning workspace ID routes Git probes (local vs. remote host); + // `repository_path` stays the IO operand inside that workspace. + let repository = ReviewRepositoryLocator::new( + context + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.clone()), + repository_path.clone(), + ); let resolved_remote_id = if action_requires_remote(&action) { - match Self::resolve_remote_id(&action, &repository_path, input).await? { + match Self::resolve_remote_id(&action, &repository, input).await? { Ok(remote_id) => Some(remote_id), Err(selection_result) => { let result_for_assistant = self.render_result_for_assistant(&selection_result); @@ -778,7 +788,7 @@ When returning pull request results to the user, include the provider web URL so let data = match action.as_str() { ACTION_LIST_REMOTES => { - let remotes = ReviewPlatformService::discover_remotes(&repository_path) + let remotes = ReviewPlatformService::discover_remotes(&repository) .await .map_err(|error| OpenBitFunError::tool(error.to_string()))?; json!({ @@ -798,7 +808,7 @@ When returning pull request results to the user, include the provider web URL so .map(|value| value as u32); let remote_id = Self::optional_string_field(input, "remote_id"); let snapshot = ReviewPlatformService::workspace_snapshot_with_state( - &repository_path, + &repository, remote_id.as_deref(), page, per_page, @@ -837,7 +847,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let snapshot = ReviewPlatformService::workspace_snapshot_with_state( - &repository_path, + &repository, Some(remote_id.as_str()), Some(1), Some(1), @@ -882,7 +892,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let snapshot = ReviewPlatformService::workspace_snapshot_with_state( - &repository_path, + &repository, Some(remote_id.as_str()), page, per_page, @@ -919,7 +929,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); match ReviewPlatformService::pull_request_detail( - &repository_path, + &repository, &remote_id, &pull_request_id, ) @@ -965,7 +975,7 @@ When returning pull request results to the user, include the provider web URL so .and_then(Value::as_u64) .map(|value| value as u32); match ReviewPlatformService::pull_request_detail_page( - &repository_path, + &repository, &remote_id, &pull_request_id, section, @@ -1016,7 +1026,7 @@ When returning pull request results to the user, include the provider web URL so let ci_item_id = Self::string_field(input, "ci_item_id")?; let ci_item_name = Self::string_field(input, "ci_item_name")?; match ReviewPlatformService::pull_request_ci_log( - &repository_path, + &repository, &remote_id, &pull_request_id, &ci_item_id, @@ -1053,6 +1063,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformCreatePullRequestRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id: Some(remote_id), title: Self::string_field(input, "title")?, @@ -1071,6 +1082,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformReplyToThreadRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, @@ -1087,6 +1099,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformSubmitReviewRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, @@ -1103,6 +1116,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformApprovalRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, @@ -1118,6 +1132,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformApprovalRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, @@ -1133,6 +1148,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformRequestChangesRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, @@ -1148,6 +1164,7 @@ When returning pull request results to the user, include the provider web URL so .clone() .expect("remote-bound action should resolve a remote"); let request = ReviewPlatformResolveThreadRequest { + workspace_id: repository.workspace_id.clone(), repository_path: repository_path.clone(), remote_id, pull_request_id: Self::string_field(input, "pull_request_id")?, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 583a121de7..945f34a549 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -177,6 +177,7 @@ impl SessionControlTool { session_name: &str, ) -> AgentSessionRenameRequest { AgentSessionRenameRequest { + workspace_id: workspace.workspace_id.clone(), workspace_path: workspace.project_workspace.clone(), session_id: session_id.to_string(), session_name: session_name.to_string(), @@ -209,9 +210,10 @@ impl SessionControlTool { ) -> OpenBitFunResult<()> { let existing_sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace.project_workspace.clone(), - remote_connection_id: workspace.remote_connection_id.clone(), - remote_ssh_host: workspace.remote_ssh_host.clone(), + workspace_id: workspace.workspace_id.clone(), + workspace_path: String::new(), + remote_connection_id: None, + remote_ssh_host: None, }) .await .map_err(|error| { @@ -567,6 +569,7 @@ Arguments: deletion_runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: workspace.workspace_id.clone(), workspace_path: workspace.project_workspace.clone(), session_id: session_id.to_string(), remote_connection_id: workspace.remote_connection_id.clone(), @@ -663,9 +666,10 @@ Arguments: .await?; let sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace.project_workspace.clone(), - remote_connection_id: workspace.remote_connection_id.clone(), - remote_ssh_host: workspace.remote_ssh_host.clone(), + workspace_id: workspace.workspace_id.clone(), + workspace_path: String::new(), + remote_connection_id: None, + remote_ssh_host: None, }) .await .map_err(|error| { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 532a1b0362..29e2f0ec39 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -230,13 +230,22 @@ impl SessionMessageTool { } } + /// Two targets share a workspace only when their owning workspace IDs + /// match. Targets that predate IDs fall back to the legacy path + SSH + /// projection; a target with an ID never matches one without. fn same_workspace_identity( left: &SessionMessageWorkspaceTarget, right: &SessionMessageWorkspaceTarget, ) -> bool { - left.workspace_path == right.workspace_path - && left.remote_connection_id == right.remote_connection_id - && left.remote_ssh_host == right.remote_ssh_host + match (left.workspace_id.as_deref(), right.workspace_id.as_deref()) { + (Some(left_id), Some(right_id)) => left_id == right_id, + (None, None) => { + left.workspace_path == right.workspace_path + && left.remote_connection_id == right.remote_connection_id + && left.remote_ssh_host == right.remote_ssh_host + } + _ => false, + } } fn target_agent_type_from_resolution(agent_type: Option) -> Option { @@ -598,9 +607,10 @@ Allowed agent types when creating a session: let visible_sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace_target.project_workspace_path.clone(), - remote_connection_id: workspace_target.remote_connection_id.clone(), - remote_ssh_host: workspace_target.remote_ssh_host.clone(), + workspace_id: workspace_target.workspace_id.clone(), + workspace_path: String::new(), + remote_connection_id: None, + remote_ssh_host: None, }) .await .map_err(|error| { @@ -705,6 +715,7 @@ Allowed agent types when creating a session: execution: Default::default(), agent_type: target_agent_type.clone(), workspace_path: Some(workspace_target.workspace_path.clone()), + workspace_id: workspace_target.workspace_id.clone(), remote_connection_id: workspace_target.remote_connection_id.clone(), remote_ssh_host: workspace_target.remote_ssh_host.clone(), policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), @@ -876,6 +887,37 @@ mod tests { )); } + #[test] + fn workspace_identity_compares_ids_before_paths() { + let mut same_path_a = workspace_target("/root/repo", None, None); + same_path_a.workspace_id = Some("ws-a".to_string()); + let mut same_path_b = workspace_target("/root/repo", None, None); + same_path_b.workspace_id = Some("ws-b".to_string()); + assert!(!SessionMessageTool::same_workspace_identity( + &same_path_a, + &same_path_b + )); + + let mut moved_root = workspace_target("/root/elsewhere", None, None); + moved_root.workspace_id = Some("ws-a".to_string()); + assert!(SessionMessageTool::same_workspace_identity( + &same_path_a, + &moved_root + )); + } + + #[test] + fn workspace_identity_never_matches_id_against_legacy_projection() { + let mut identified = workspace_target("/root/repo", None, None); + identified.workspace_id = Some("ws-a".to_string()); + let legacy = workspace_target("/root/repo", None, None); + + assert!(!SessionMessageTool::same_workspace_identity( + &identified, + &legacy + )); + } + #[test] fn target_agent_type_rejects_empty_agent_type_resolution() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 40ca1b97e3..2f3fb10b4f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -73,7 +73,7 @@ Important: Some(ctx) => { registry .get_resolved_skills_xml_for_workspace( - ctx.workspace_root(), + ctx.workspace.as_ref(), ctx.agent_type.as_deref(), ) .await @@ -284,7 +284,7 @@ impl Tool for SkillTool { registry .find_and_load_skill_by_key_for_workspace( skill_name, - context.workspace_root(), + context.workspace.as_ref(), context.agent_type.as_deref(), ) .await? @@ -292,7 +292,7 @@ impl Tool for SkillTool { registry .find_and_load_skill_for_workspace( skill_name, - context.workspace_root(), + context.workspace.as_ref(), context.agent_type.as_deref(), ) .await? @@ -336,9 +336,19 @@ impl Default for SkillTool { } } +#[cfg(test)] +async fn test_workspace_binding( + path: &std::path::Path, +) -> crate::agentic::workspace::WorkspaceBinding { + let record = crate::service::workspace::legacy_compat::register_local_fixture(path, None).await; + crate::agentic::workspace::WorkspaceBinding::resolve(&record.id) + .await + .unwrap() +} + #[cfg(test)] mod tests { - use super::SkillTool; + use super::{test_workspace_binding, SkillTool}; use crate::agentic::tools::framework::{Tool, ToolResult}; use crate::agentic::tools::implementations::skills::{registry::SkillRegistry, SkillLocation}; use crate::agentic::workspace::{ @@ -620,7 +630,7 @@ Use the remote project skill. assert!(SkillRegistry::global() .find_and_load_skill_by_key_for_workspace( &imported.key, - Some(temp.path()), + Some(&test_workspace_binding(temp.path()).await), Some("agent") ) .await @@ -652,10 +662,16 @@ Use the remote project skill. let mut context = local_context(temp.path().to_path_buf()); context.agent_type = mode.map(str::to_string); let visible = registry - .get_resolved_skills_for_workspace(Some(temp.path()), mode) + .get_resolved_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + mode, + ) .await; let xml = registry - .get_resolved_skills_xml_for_workspace(Some(temp.path()), mode) + .get_resolved_skills_xml_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + mode, + ) .await; for skill in &external { assert!(visible.iter().any(|entry| entry.key == skill.key)); @@ -674,7 +690,15 @@ Use the remote project skill. } } let modes = registry - .get_mode_skill_infos_for_workspace(Some(temp.path()), "agent") + .get_mode_skill_infos_for_workspace( + Some( + crate::agentic::tools::implementations::skills::mode_overrides::SkillPolicyWorkspace { + workspace_id: "workspace-skill-tool-test", + root: temp.path(), + }, + ), + "agent", + ) .await; assert_eq!( modes @@ -773,7 +797,10 @@ Use the remote project skill. import_test_skill(temp.path(), "project::codex::deep-research", None).await; let registry = SkillRegistry::global(); let resolved = registry - .get_resolved_skills_for_workspace(Some(temp.path()), Some("DeepResearch")) + .get_resolved_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + Some("DeepResearch"), + ) .await; let skill = resolved .iter() @@ -781,14 +808,17 @@ Use the remote project skill. .expect("same-named skill should remain enabled and discoverable"); assert!(skill.allow_implicit_invocation); let implicit = registry - .get_implicitly_invocable_skills_for_workspace(Some(temp.path()), Some("DeepResearch")) + .get_implicitly_invocable_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + Some("DeepResearch"), + ) .await; assert!(!implicit.iter().any(|skill| skill.name == "deep-research")); let loaded = registry .find_and_load_skill_by_key_for_workspace( &skill.key, - Some(temp.path()), + Some(&test_workspace_binding(temp.path()).await), Some("DeepResearch"), ) .await @@ -811,7 +841,10 @@ Use the remote project skill. let context = local_context(temp.path().to_path_buf()); let visible = SkillRegistry::global() - .get_resolved_skills_for_workspace(Some(temp.path()), None) + .get_resolved_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; assert!(visible .iter() @@ -1302,13 +1335,22 @@ Use the remote project skill. import_test_skill(temp.path(), "project::codex::local-explicit-only", None).await; let registry = SkillRegistry::global(); let resolved = registry - .get_resolved_skills_for_workspace(Some(temp.path()), None) + .get_resolved_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; let implicit = registry - .get_implicitly_invocable_skills_for_workspace(Some(temp.path()), None) + .get_implicitly_invocable_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; let user_invocable = registry - .get_user_invocable_skills_for_workspace(Some(temp.path()), None) + .get_user_invocable_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; assert!(resolved @@ -1321,7 +1363,11 @@ Use the remote project skill. .iter() .any(|skill| skill.name == "local-explicit-only")); let loaded = registry - .find_and_load_skill_for_workspace("local-explicit-only", Some(temp.path()), None) + .find_and_load_skill_for_workspace( + "local-explicit-only", + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await .expect("explicit invocation should remain available"); assert_eq!(loaded.name, "local-explicit-only"); @@ -1341,13 +1387,22 @@ Use the remote project skill. import_test_skill(temp.path(), "project::claude::model-only", None).await; let registry = SkillRegistry::global(); let resolved = registry - .get_resolved_skills_for_workspace(Some(temp.path()), None) + .get_resolved_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; let implicit = registry - .get_implicitly_invocable_skills_for_workspace(Some(temp.path()), None) + .get_implicitly_invocable_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; let user_invocable = registry - .get_user_invocable_skills_for_workspace(Some(temp.path()), None) + .get_user_invocable_skills_for_workspace( + Some(&test_workspace_binding(temp.path()).await), + None, + ) .await; assert!(resolved.iter().any(|skill| skill.name == "model-only")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs index e4e50be6d6..9b860b4aa7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs @@ -129,37 +129,133 @@ pub async fn set_global_user_skill_disabled( .await } -/// Local workspace identity is canonicalized so aliases share one availability policy. -fn skill_workspace_identity(root: &Path) -> OpenBitFunResult { +/// The workspace whose project-level Skill availability policy is being read +/// or written. The record ID owns the policy; the root only names the folder +/// whose retired canonical-path entry may still need a one-time upgrade. +#[derive(Debug, Clone, Copy)] +pub struct SkillPolicyWorkspace<'a> { + pub workspace_id: &'a str, + pub root: &'a Path, +} + +impl<'a> SkillPolicyWorkspace<'a> { + pub fn from_record(record: &'a crate::service::workspace::WorkspaceInfo) -> Self { + Self { + workspace_id: record.id.as_str(), + root: record.root_path.as_path(), + } + } + + /// A session binding names its workspace by ID; a binding written before + /// workspace IDs has no project policy scope until it is upgraded. + pub fn from_binding(binding: &'a crate::agentic::workspace::WorkspaceBinding) -> Option { + let workspace_id = binding + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty())?; + Some(Self { + workspace_id, + root: binding.root_path(), + }) + } +} + +/// Settings key that owns a workspace's project-level Skill availability. +fn skill_workspace_identity(workspace_id: &str) -> String { + format!("workspace:{}", workspace_id.trim()) +} + +/// Retired canonical-path key written by builds that predate workspace IDs. +fn legacy_skill_workspace_identity(root: &Path) -> Option { dunce::canonicalize(root) + .ok() .map(|path| path.to_string_lossy().into_owned()) - .map_err(|error| OpenBitFunError::tool(format!("Invalid Skill workspace: {error}"))) } -pub async fn load_globally_disabled_project_skills(root: &Path) -> OpenBitFunResult> { - let identity = skill_workspace_identity(root)?; +/// Copy a retired canonical-path entry to the workspace ID key once. The old +/// entry is kept for rollback and an existing ID entry is never overwritten. +fn upgrade_legacy_project_skill_availability( + settings: &mut SkillSettingsConfig, + legacy_identity: Option<&str>, + identity: &str, +) -> bool { + if settings + .globally_disabled_project_skills + .contains_key(identity) + { + return false; + } + let Some(keys) = legacy_identity + .and_then(|legacy| settings.globally_disabled_project_skills.get(legacy)) + .cloned() + else { + return false; + }; + settings + .globally_disabled_project_skills + .insert(identity.to_string(), keys); + true +} + +pub async fn load_globally_disabled_project_skills( + workspace: SkillPolicyWorkspace<'_>, +) -> OpenBitFunResult> { + let identity = skill_workspace_identity(workspace.workspace_id); let config_service = GlobalConfigManager::get_service().await?; let settings: SkillSettingsConfig = config_service.get_config(Some("ai.skill_settings")).await?; - Ok(settings - .globally_disabled_project_skills - .get(&identity) - .cloned() - .unwrap_or_default()) + if let Some(keys) = settings.globally_disabled_project_skills.get(&identity) { + return Ok(keys.clone()); + } + let legacy_identity = legacy_skill_workspace_identity(workspace.root); + if legacy_identity.as_deref().is_none_or(|legacy| { + !settings + .globally_disabled_project_skills + .contains_key(legacy) + }) { + return Ok(Vec::new()); + } + config_service + .update_config("ai.skill_settings", |settings: &mut SkillSettingsConfig| { + upgrade_legacy_project_skill_availability( + settings, + legacy_identity.as_deref(), + &identity, + ); + Ok(settings + .globally_disabled_project_skills + .get(&identity) + .cloned() + .unwrap_or_default()) + }) + .await } pub async fn set_global_project_skill_disabled( - root: &Path, + workspace: SkillPolicyWorkspace<'_>, skill_key: &str, disabled: bool, ) -> OpenBitFunResult> { - let identity = skill_workspace_identity(root)?; + let identity = skill_workspace_identity(workspace.workspace_id); + let legacy_identity = legacy_skill_workspace_identity(workspace.root); let config_service = GlobalConfigManager::get_service().await?; config_service .update_config("ai.skill_settings", |settings: &mut SkillSettingsConfig| { - Ok(update_project_skill_availability( - settings, &identity, skill_key, disabled, - )) + upgrade_legacy_project_skill_availability( + settings, + legacy_identity.as_deref(), + &identity, + ); + let keys = update_project_skill_availability(settings, &identity, skill_key, disabled); + if keys.is_empty() { + // Re-enabling the last project Skill applies to the retired + // entry too; otherwise the next load would upgrade it again. + if let Some(legacy) = legacy_identity.as_deref() { + settings.globally_disabled_project_skills.remove(legacy); + } + } + Ok(keys) }) .await } @@ -191,23 +287,18 @@ mod availability_tests { use super::*; #[test] - fn project_switches_share_path_aliases_but_isolate_workspaces_and_user_policy() { - let temp = tempfile::tempdir().unwrap(); - let first = temp.path().join("first"); - let second = temp.path().join("second"); - std::fs::create_dir(&first).unwrap(); - std::fs::create_dir(&second).unwrap(); - let first_id = skill_workspace_identity(&first).unwrap(); - let alias = skill_workspace_identity(&first.join(".")).unwrap(); - assert_eq!(first_id, alias); - let second_id = skill_workspace_identity(&second).unwrap(); + fn project_policy_is_keyed_by_workspace_id_and_isolates_user_policy() { + let first_id = skill_workspace_identity("workspace-first"); + let padded = skill_workspace_identity(" workspace-first "); + assert_eq!(first_id, padded); + let second_id = skill_workspace_identity("workspace-second"); let key = "project::agents::review"; let mut settings = SkillSettingsConfig::default(); settings .globally_disabled_user_skills .push("user::home.agents::review".into()); update_project_skill_availability(&mut settings, &first_id, key, true); - update_project_skill_availability(&mut settings, &alias, key, true); + update_project_skill_availability(&mut settings, &padded, key, true); assert_eq!(settings.globally_disabled_project_skills[&first_id], [key]); assert!(!settings .globally_disabled_project_skills @@ -215,7 +306,7 @@ mod availability_tests { update_project_skill_availability(&mut settings, &second_id, key, true); let mut restored: SkillSettingsConfig = serde_json::from_value(serde_json::to_value(settings).unwrap()).unwrap(); - update_project_skill_availability(&mut restored, &alias, key, false); + update_project_skill_availability(&mut restored, &padded, key, false); assert!(!restored .globally_disabled_project_skills .contains_key(&first_id)); @@ -225,6 +316,58 @@ mod availability_tests { ["user::home.agents::review"] ); } + + #[test] + fn legacy_canonical_path_policy_upgrades_once_to_the_workspace_id_key() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let legacy = legacy_skill_workspace_identity(&root).unwrap(); + assert_eq!( + legacy, + legacy_skill_workspace_identity(&root.join(".")).unwrap() + ); + let identity = skill_workspace_identity("workspace-1"); + let key = "project::agents::review"; + + // Settings written by a pre-ID build carry only the canonical-path key. + let mut settings = SkillSettingsConfig::default(); + settings + .globally_disabled_project_skills + .insert(legacy.clone(), vec![key.to_string()]); + let mut settings: SkillSettingsConfig = + serde_json::from_value(serde_json::to_value(&settings).unwrap()).unwrap(); + assert!(upgrade_legacy_project_skill_availability( + &mut settings, + Some(&legacy), + &identity + )); + assert_eq!(settings.globally_disabled_project_skills[&identity], [key]); + // The retired entry stays for rollback. + assert_eq!(settings.globally_disabled_project_skills[&legacy], [key]); + + // An existing ID entry is never overwritten by the retired one. + update_project_skill_availability(&mut settings, &identity, "project::other", true); + assert!(!upgrade_legacy_project_skill_availability( + &mut settings, + Some(&legacy), + &identity + )); + assert_eq!( + settings.globally_disabled_project_skills[&identity], + [key, "project::other"] + ); + // Without a legacy entry there is nothing to upgrade. + let mut fresh = SkillSettingsConfig::default(); + assert!(!upgrade_legacy_project_skill_availability( + &mut fresh, + Some("/nowhere"), + &identity + )); + assert!(!upgrade_legacy_project_skill_availability( + &mut fresh, None, &identity + )); + } } pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index b9380fed74..b03f91bdfe 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -6,7 +6,7 @@ use super::builtin::ensure_builtin_skills_installed; use super::mode_overrides::{ load_disabled_mode_skills_local, load_disabled_mode_skills_remote, load_globally_disabled_project_skills, load_globally_disabled_user_skills, - load_user_mode_skill_overrides, UserModeSkillOverrides, + load_user_mode_skill_overrides, SkillPolicyWorkspace, UserModeSkillOverrides, }; #[cfg(feature = "file-watch")] use super::source_cache::{LocalSkillWatchMonitor, LocalSkillWatchRoot, VersionedSnapshotCache}; @@ -649,15 +649,17 @@ impl SkillRegistry { SKILL_REGISTRY.get_or_init(Self::new) } - async fn globally_disabled_skill_keys(workspace_root: Option<&Path>) -> HashSet { + async fn globally_disabled_skill_keys( + workspace: Option>, + ) -> HashSet { let mut keys: HashSet = load_globally_disabled_user_skills() .await .unwrap_or_default() .into_iter() .collect(); - if let Some(root) = workspace_root { + if let Some(workspace) = workspace { keys.extend( - load_globally_disabled_project_skills(root) + load_globally_disabled_project_skills(workspace) .await .unwrap_or_default(), ); @@ -1495,15 +1497,16 @@ impl SkillRegistry { async fn apply_mode_filters_for_workspace( &self, candidates: Vec, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> Vec { + let workspace_root = workspace.map(|workspace| workspace.root_path()); // Static discovered Skills are directly usable; plugin contributions remain owner-controlled. #[cfg(feature = "opencode-plugin-host")] let candidates = { let mut candidates = candidates; let plugin_roots = crate::plugin_capability_publication::skill_roots_for_agent( - workspace_root, + workspace.and_then(|workspace| workspace.workspace_id.as_deref()), agent_type, ) .into_iter() @@ -1532,8 +1535,10 @@ impl SkillRegistry { } candidates }; - let globally_disabled_user_skills = - Self::globally_disabled_skill_keys(workspace_root).await; + let globally_disabled_user_skills = Self::globally_disabled_skill_keys( + workspace.and_then(SkillPolicyWorkspace::from_binding), + ) + .await; let candidates = Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let Some(mode_id) = agent_type.map(str::trim).filter(|value| !value.is_empty()) else { @@ -1608,18 +1613,21 @@ impl SkillRegistry { async fn find_skill_info_for_explicit_invocation_workspace( &self, skill_name: &str, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> OpenBitFunResult { + let workspace_root = workspace.map(|workspace| workspace.root_path()); let candidates = self .scan_skill_candidates_for_workspace(workspace_root) .await; - let globally_disabled_user_skills = - Self::globally_disabled_skill_keys(workspace_root).await; + let globally_disabled_user_skills = Self::globally_disabled_skill_keys( + workspace.and_then(SkillPolicyWorkspace::from_binding), + ) + .await; let candidates = Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let filtered = self - .apply_mode_filters_for_workspace(candidates.clone(), workspace_root, agent_type) + .apply_mode_filters_for_workspace(candidates.clone(), workspace, agent_type) .await; if let Some(info) = resolve_visible_skills(filtered) .into_iter() @@ -1729,14 +1737,15 @@ impl SkillRegistry { pub async fn get_resolved_skills_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> Vec { + let workspace_root = workspace.map(|workspace| workspace.root_path()); let candidates = self .scan_skill_candidates_for_workspace(workspace_root) .await; let filtered = self - .apply_mode_filters_for_workspace(candidates, workspace_root, agent_type) + .apply_mode_filters_for_workspace(candidates, workspace, agent_type) .await; sort_skills(resolve_visible_skills(filtered)) } @@ -1758,11 +1767,11 @@ impl SkillRegistry { pub async fn get_implicitly_invocable_skills_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> Vec { filter_implicitly_invocable_skills_for_agent( - self.get_resolved_skills_for_workspace(workspace_root, agent_type) + self.get_resolved_skills_for_workspace(workspace, agent_type) .await, agent_type, ) @@ -1770,11 +1779,11 @@ impl SkillRegistry { pub async fn get_user_invocable_skills_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> Vec { filter_user_invocable_skills( - self.get_resolved_skills_for_workspace(workspace_root, agent_type) + self.get_resolved_skills_for_workspace(workspace, agent_type) .await, ) } @@ -1794,19 +1803,23 @@ impl SkillRegistry { pub async fn get_mode_skill_infos_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option>, mode_id: &str, ) -> Vec { - self.get_mode_skill_scan_report_for_workspace(workspace_root, mode_id) + self.get_mode_skill_scan_report_for_workspace(workspace, mode_id) .await .skills } + /// Mode Skill management for a local workspace. The workspace ID owns the + /// project availability policy; its root is only the directory scanned for + /// project Skill files and project-local mode overrides. pub async fn get_mode_skill_scan_report_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option>, mode_id: &str, ) -> SkillScanReport { + let workspace_root = workspace.map(|workspace| workspace.root); let scan = self .scan_skill_candidates_with_diagnostics_for_workspace(workspace_root) .await; @@ -1823,8 +1836,7 @@ impl SkillRegistry { }; let disabled_project: HashSet = normalize_skill_keys(disabled_project).into_iter().collect(); - let globally_disabled_user_skills = - Self::globally_disabled_skill_keys(workspace_root).await; + let globally_disabled_user_skills = Self::globally_disabled_skill_keys(workspace).await; let filtered = Self::filter_globally_disabled_candidates( filter_candidates_for_mode(candidates, mode_id, &user_overrides, &disabled_project), &globally_disabled_user_skills, @@ -1920,15 +1932,11 @@ impl SkillRegistry { pub async fn find_and_load_skill_for_workspace( &self, skill_name: &str, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> OpenBitFunResult { let info = self - .find_skill_info_for_explicit_invocation_workspace( - skill_name, - workspace_root, - agent_type, - ) + .find_skill_info_for_explicit_invocation_workspace(skill_name, workspace, agent_type) .await?; let content = Self::read_local_skill_markdown(&info).await?; @@ -1954,14 +1962,15 @@ impl SkillRegistry { pub async fn find_and_load_skill_by_key_for_workspace( &self, skill_key: &str, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> OpenBitFunResult { + let workspace_root = workspace.map(|workspace| workspace.root_path()); let candidates = self .scan_skill_candidates_for_workspace(workspace_root) .await; let filtered = self - .apply_mode_filters_for_workspace(candidates, workspace_root, agent_type) + .apply_mode_filters_for_workspace(candidates, workspace, agent_type) .await; let info = filtered .into_iter() @@ -2074,14 +2083,15 @@ impl SkillRegistry { pub async fn get_resolved_skills_xml_for_workspace( &self, - workspace_root: Option<&Path>, + workspace: Option<&crate::agentic::workspace::WorkspaceBinding>, agent_type: Option<&str>, ) -> Vec { + let workspace_root = workspace.map(|workspace| workspace.root_path()); let scan = self .scan_skill_candidates_with_diagnostics_for_workspace(workspace_root) .await; let filtered = self - .apply_mode_filters_for_workspace(scan.candidates, workspace_root, agent_type) + .apply_mode_filters_for_workspace(scan.candidates, workspace, agent_type) .await; let mut xml: Vec<_> = filter_implicitly_invocable_skills_for_agent( sort_skills(resolve_visible_skills(filtered)), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 5d50f73f49..21c689e3cb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -170,7 +170,7 @@ impl TaskTool { ) -> OpenBitFunResult { crate::agentic::permission_policy::load_parent_permission_runtime_ceiling( context.agent_type.as_deref(), - context.workspace_root(), + context.workspace_id(), ) .await } @@ -361,7 +361,7 @@ impl TaskTool { let binding = get_agent_registry() .resolve_subagent_for_fresh_invocation( &subagent_type, - context.workspace_root(), + context.workspace_id(), !parent_is_swarm_planner && !context.is_remote(), ) .ok_or_else(|| { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index fdc2af52b5..2c55108d5e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -103,13 +103,13 @@ impl TaskTool { async fn get_enabled_agents(context: Option<&ToolUseContext>) -> Vec { let registry = get_agent_registry(); - let workspace_root = context.and_then(|ctx| ctx.workspace_root()); + let workspace_id = context.and_then(|ctx| ctx.workspace_id()); let parent_agent_type = context.and_then(|ctx| ctx.agent_type.as_deref()); - registry.load_custom_agents(workspace_root).await; + registry.load_custom_agents(workspace_id).await; registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type, - workspace_root, + workspace_id, list_scope: SubagentListScope::TaskVisible, include_disabled: false, external_sources_supported: context.is_none_or(|ctx| !ctx.is_remote()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index fe8cf43621..a541cf55fd 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -553,6 +553,7 @@ async fn adaptive_review_delegation_is_hidden_for_remote_workspaces() { "connection-1".to_string(), "remote".to_string(), WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "remote.example".to_string(), logical_workspace_path: "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/workspace/project".to_string(), remote_connection_id: Some("connection-1".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs index 6734cbb373..2af97c3f45 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -97,6 +97,7 @@ impl WorktreeTool { OpenBitFunError::tool("Current workspace root is unavailable".to_string()) })?; let worktree = WorktreeService::list(WorktreeListRequest { + project_workspace_id: None, project_workspace_path: project_workspace_path.to_string(), }) .await @@ -345,6 +346,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio let data = match input.operation { WorktreeToolOperation::List => { let worktrees = WorktreeService::list(WorktreeListRequest { + project_workspace_id: None, project_workspace_path: project_workspace_path.clone(), }) .await @@ -373,6 +375,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio .to_string(); let created = WorktreeService::create(WorktreeCreateRequest { request_id: operation_request_id, + project_workspace_id: None, project_workspace_path: project_workspace_path.clone(), source_workspace_path: Some(source_workspace_path), base_ref: input.base_ref, @@ -435,7 +438,11 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio }; let existing = match runtime .list_sessions(AgentSessionListRequest { - workspace_path: project_workspace_path.clone(), + workspace_id: context + .workspace + .as_ref() + .and_then(|workspace| workspace.project_workspace_id.clone()), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) @@ -560,6 +567,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio .await?; let result = WorktreeService::create_branch(WorktreeCreateBranchRequest { request_id: Self::request_id(context, "create_branch"), + project_workspace_id: None, project_workspace_path, worktree_id: worktree_id.to_string(), branch: input.branch.unwrap_or_default(), @@ -578,6 +586,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio .await?; let result = WorktreeService::remove(WorktreeRemoveRequest { request_id: Self::request_id(context, "remove"), + project_workspace_id: None, project_workspace_path, worktree_id: worktree_id.to_string(), force: false, diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 96c50304dc..35c63f980a 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -117,7 +117,7 @@ fn plugin_after_presentation( #[cfg(feature = "opencode-plugin-host")] fn local_plugin_workspace_scope(workspace: &WorkspaceBinding) -> Option { (!workspace.is_remote()) - .then(|| crate::plugin_host::canonical_plugin_workspace_scope(workspace.root_path())) + .then(|| workspace.workspace_id.clone()) .flatten() } @@ -633,6 +633,10 @@ fn native_hook_session_facts<'a>( options: &ToolExecutionOptions, ) -> NativeHookSessionFacts<'a> { NativeHookSessionFacts { + workspace_id: context + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()), session_id: &context.session_id, turn_id: Some(&context.dialog_turn_id), workspace_root: context @@ -2942,15 +2946,24 @@ mod tests { #[test] fn remote_workspace_never_resolves_a_local_plugin_hook_scope() { let workspace = tempfile::tempdir().expect("workspace"); - let local = WorkspaceBinding::new(None, workspace.path().to_path_buf()); + // The plugin scope is the workspace record ID, never the root path. + let local = WorkspaceBinding::new( + Some("workspace-local".to_string()), + workspace.path().to_path_buf(), + ); let mut remote = local.clone(); remote.backend = crate::agentic::workspace::WorkspaceBackend::Remote { connection_id: "remote-a".to_string(), connection_name: "Remote A".to_string(), }; + let unregistered = WorkspaceBinding::new(None, workspace.path().to_path_buf()); - assert!(local_plugin_workspace_scope(&local).is_some()); + assert_eq!( + local_plugin_workspace_scope(&local).as_deref(), + Some("workspace-local") + ); assert!(local_plugin_workspace_scope(&remote).is_none()); + assert!(local_plugin_workspace_scope(&unregistered).is_none()); } #[test] @@ -3441,7 +3454,10 @@ mod tests { let root = std::env::current_dir().expect("absolute test workspace root"); let mut local_task = test_tool_task("local-route", "Read"); - local_task.context.workspace = Some(WorkspaceBinding::new(None, root.clone())); + local_task.context.workspace = Some(WorkspaceBinding::new( + Some("local-workspace".into()), + root.clone(), + )); let local = pipeline.build_tool_use_context(&local_task, CancellationToken::new()); let session_identity = @@ -3463,17 +3479,17 @@ mod tests { assert_eq!( crate::external_tools::external_tool_route_root( - local.workspace_root(), + local.workspace_id(), local.is_remote(), ), - Some(root.as_path()) + Some("local-workspace") ); let remote_route_root = crate::external_tools::external_tool_route_root( - remote.workspace_root(), + remote.workspace_id(), remote.is_remote(), ); - assert_eq!(remote_route_root, Some(std::path::Path::new("\0"))); - assert!(dunce::canonicalize(remote_route_root.expect("remote sentinel")).is_err()); + assert_eq!(remote_route_root, Some("")); + assert_ne!(remote_route_root, local.workspace_id()); } async fn register_static_test_tool( diff --git a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs index 5309bf35ec..52f7eb3080 100644 --- a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs @@ -123,9 +123,7 @@ impl PluginHostToolMux { return None; } let runtime_agent_key = context.agent_type.as_deref()?; - let scope = context - .workspace_root() - .and_then(crate::plugin_host::canonical_plugin_workspace_scope)?; + let scope = context.workspace_id()?; self.routes_for_scope(&scope).into_iter().find(|route| { route.allowed_runtime_agent_keys.is_empty() || route.allowed_runtime_agent_keys.contains(runtime_agent_key) @@ -444,7 +442,7 @@ async fn handle_tool_ask(params: HookFunctionReverseAsk) -> PortResult &'static RwLock>> { pub(crate) async fn register_workspace_tool( workspace_scope: &str, - workspace_root: &std::path::Path, + workspace_id: &str, runtime: Arc, host_generation: u64, instance_id: &str, @@ -612,7 +610,7 @@ pub(crate) async fn register_workspace_tool( }, ); crate::external_tools::register_live_external_tool_candidate( - workspace_root, + workspace_id, mux, "opencode-plugin", content_version, @@ -623,7 +621,7 @@ pub(crate) async fn register_workspace_tool( pub(crate) async fn unregister_workspace_tools( workspace_scope: &str, - workspace_root: &std::path::Path, + workspace_id: &str, names: &[String], generation_key: &str, ) { @@ -644,7 +642,7 @@ pub(crate) async fn unregister_workspace_tools( } if mux.routes_for_scope(workspace_scope).is_empty() { crate::external_tools::unregister_live_external_tool_candidate( - workspace_root, + workspace_id, name, "opencode-plugin", ) @@ -708,7 +706,7 @@ mod tests { crate::agentic::tools::tool_context_runtime::build_tool_description_context( runtime_agent_key, Some(&crate::agentic::WorkspaceBinding::new( - None, + Some("plugin-tool-workspace".into()), PathBuf::from(workspace_root), )), None, @@ -870,8 +868,7 @@ mod tests { use openbitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; let workspace = std::env::current_dir().expect("absolute workspace"); - let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) - .expect("canonical workspace scope"); + let scope = "plugin-tool-workspace".to_string(); let generation_a_agent = "external_subagent_runtime:opencode-plugin:generation-a-agent"; let generation_b_agent = "external_subagent_runtime:opencode-plugin:generation-b-agent"; let registry = openbitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); @@ -922,8 +919,7 @@ mod tests { use openbitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; let workspace = std::env::current_dir().expect("absolute workspace"); - let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) - .expect("canonical workspace scope"); + let scope = "plugin-tool-workspace".to_string(); let registry = openbitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); registry.set_source_activation_for_workspace( RuntimeHookSource::Plugin, @@ -947,8 +943,7 @@ mod tests { use openbitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; let workspace = std::env::current_dir().expect("absolute workspace"); - let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) - .expect("canonical workspace scope"); + let scope = "plugin-tool-workspace".to_string(); let registry = openbitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); registry.set_source_activation_for_workspace( RuntimeHookSource::Plugin, diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index c3fde4538e..5061dcc383 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -13,6 +13,7 @@ pub(crate) async fn record_successful_tool_call( context: &ToolUseContext, ) { crate::native_hooks::dispatch_successful_tool_post_call( + context.workspace_id(), context.workspace_root(), context.is_remote(), tool_name, diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index e0020559b9..8642a3cf55 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -241,10 +241,10 @@ impl ProductToolCatalogProvider { let agent_type = context.agent_type.as_deref().ok_or_else(|| { OpenBitFunError::Validation("GetToolSpec requires agent type context".to_string()) })?; - let workspace_root = context.workspace_root(); + let workspace_id = context.workspace_id(); let agent_registry = get_agent_registry(); let policy = agent_registry - .get_agent_tool_policy(agent_type, workspace_root) + .get_agent_tool_policy(agent_type, workspace_id) .await; let (allowed_tools, exposure_overrides) = Self::resolve_manifest_inputs( &policy.allowed_tools, @@ -263,10 +263,10 @@ impl ProductToolCatalogProvider { let agent_type = context.agent_type.as_deref().ok_or_else(|| { OpenBitFunError::Validation("GetToolSpec requires agent type context".to_string()) })?; - let workspace_root = context.workspace_root(); + let workspace_id = context.workspace_id(); let agent_registry = get_agent_registry(); let policy = agent_registry - .get_agent_tool_policy(agent_type, workspace_root) + .get_agent_tool_policy(agent_type, workspace_id) .await; let (allowed_tools, exposure_overrides) = Self::resolve_manifest_inputs( &policy.allowed_tools, @@ -367,7 +367,7 @@ async fn contextual_tool_snapshot(context: &ToolUseContext) -> Vec { { if !context.is_remote() { crate::external_sources::ensure_external_source_workspace_runtime( - context.workspace_root(), + context.workspace_id(), ) .await; } @@ -601,7 +601,10 @@ mod tests { fn remote_workspace_route_root_isolated_from_same_local_path() { let root = std::env::current_dir().expect("absolute test workspace root"); let mut local = tool_context(None); - local.workspace = Some(WorkspaceBinding::new(None, root.clone())); + local.workspace = Some(WorkspaceBinding::new( + Some("local-workspace".into()), + root.clone(), + )); let session_identity = crate::service::remote_ssh::workspace_state::workspace_session_identity( @@ -621,17 +624,17 @@ mod tests { assert_eq!( crate::external_tools::external_tool_route_root( - local.workspace_root(), + local.workspace_id(), local.is_remote(), ), - Some(root.as_path()) + Some("local-workspace") ); let remote_route_root = crate::external_tools::external_tool_route_root( - remote.workspace_root(), + remote.workspace_id(), remote.is_remote(), ); - assert_eq!(remote_route_root, Some(std::path::Path::new("\0"))); - assert!(dunce::canonicalize(remote_route_root.expect("remote sentinel")).is_err()); + assert_eq!(remote_route_root, Some("")); + assert_ne!(remote_route_root, local.workspace_id()); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs index dab8b86878..f7d90c51ec 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/mcp_catalog.rs @@ -5,17 +5,16 @@ use crate::agentic::tools::framework::ToolUseContext; use crate::agentic::tools::registry::{get_global_tool_registry, ToolRef}; use crate::agentic::tools::tool_context_runtime::build_tool_description_context; use crate::agentic::WorkspaceBinding; -#[cfg(feature = "remote-workspace")] -use crate::service::remote_ssh::workspace_state::lookup_remote_connection; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChatMcpCatalogRequest { pub mode_id: String, #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub workspace_path: Option, #[serde(default)] pub remote_connection_id: Option, @@ -41,37 +40,52 @@ pub struct ChatMcpCatalog { pub async fn build_chat_mcp_catalog( request: ChatMcpCatalogRequest, ) -> Result { - // Never interpret a remote POSIX path on the controller. Remote workspace - // MCP discovery needs a target-owned catalog before this entry can support it. - if request - .remote_connection_id + let id = if let Some(id) = request.workspace_id { + Some(id) + } else if let Some(path) = request + .workspace_path .as_deref() - .is_some_and(|id| !id.trim().is_empty()) + .filter(|path| !path.is_empty()) { + // The single upgrade adapter handles old client payloads before routing. + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + Some( + service + .resolve_legacy_workspace_reference( + None, + path, + request.remote_connection_id.as_deref(), + None, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Legacy workspace reference is unavailable".to_string())? + .id, + ) + } else { + None + }; + let workspace = match id { + Some(id) => Some( + WorkspaceBinding::resolve(&id) + .await + .map_err(|error| error.to_string())?, + ), + None => None, + }; + if workspace.as_ref().is_some_and(WorkspaceBinding::is_remote) { return Err("MCP chat discovery is unsupported for remote workspaces".into()); } - let workspace_path = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()); - if let Some(path) = workspace_path { - #[cfg(feature = "remote-workspace")] - if lookup_remote_connection(path).await.is_some() { - return Err("MCP chat discovery is unsupported for remote workspaces".into()); - } - if !std::path::Path::new(path).is_absolute() { - return Err("MCP chat discovery requires an absolute workspace path".into()); - } - } - let workspace = workspace_path.map(|path| WorkspaceBinding::new(None, PathBuf::from(path))); let mode_id = request.mode_id.trim(); let registry = get_agent_registry(); - let root = workspace.as_ref().map(WorkspaceBinding::root_path); - if registry.get_agent(mode_id, root).is_none() { + let workspace_id = workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()); + if registry.get_agent(mode_id, workspace_id).is_none() { return Err(format!("Agent mode is unavailable: {mode_id}")); } - let policy = registry.get_agent_tool_policy(mode_id, root).await; + let policy = registry.get_agent_tool_policy(mode_id, workspace_id).await; let context = build_tool_description_context( mode_id, workspace.as_ref(), @@ -270,8 +284,14 @@ mod tests { #[tokio::test] async fn remote_requests_are_rejected_before_reading_local_registries() { + let record = crate::service::workspace::legacy_compat::register_remote_fixture( + "/srv/mcp-project", + "mcp-ssh", + "mcp-host", + ) + .await; let request: ChatMcpCatalogRequest = serde_json::from_value(json!({ - "modeId": "Standard", "workspacePath": "/srv/project", "remoteConnectionId": "ssh-peer", + "modeId": "Standard", "workspaceId": record.id, })) .unwrap(); assert!(build_chat_mcp_catalog(request) diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index a27ef68707..f66c76d93e 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -94,6 +94,12 @@ impl ToolUseContext { delegation_policy_from_custom_data(&self.custom_data) } + pub fn workspace_id(&self) -> Option<&str> { + self.workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()) + } + pub fn workspace_root(&self) -> Option<&Path> { self.workspace.as_ref().map(|binding| binding.root_path()) } diff --git a/src/crates/assembly/core/src/agentic/workspace.rs b/src/crates/assembly/core/src/agentic/workspace.rs index f230b92ec3..c5bfd1722f 100644 --- a/src/crates/assembly/core/src/agentic/workspace.rs +++ b/src/crates/assembly/core/src/agentic/workspace.rs @@ -9,7 +9,7 @@ pub use openbitfun_services_core::workspace::{ local_workspace_services, LocalWorkspaceFs, LocalWorkspaceShell, }; use openbitfun_services_core::workspace_identity::{ - workspace_session_identity, WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, + WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, }; #[cfg(feature = "remote-workspace")] pub use openbitfun_services_integrations::remote_ssh::{ @@ -17,42 +17,14 @@ pub use openbitfun_services_integrations::remote_ssh::{ }; use std::path::{Path, PathBuf}; -/// Return the stable identity path used by local workspace-scoped registries. -/// -/// Callers may arrive through lexical aliases (`.` / `..`) or filesystem -/// aliases. Keep normalization at the shared workspace boundary so producers -/// and consumers cannot publish and query the same workspace under different -/// keys. A missing path is retained for creation-safe discovery. -pub(crate) fn canonical_local_workspace_path(path: &Path) -> PathBuf { - dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - -/// Stable local workspace identity shared by external-source and MCP routers. +/// Workspace routing identity. Filesystem projections must never enter this key. #[cfg(any(feature = "external-sources", feature = "mcp-runtime"))] -pub(crate) fn workspace_route_key(workspace_root: Option<&Path>) -> String { - workspace_root - .map(|path| { - canonical_local_workspace_path(path) - .to_string_lossy() - .into_owned() - }) +pub(crate) fn workspace_route_key(workspace_id: Option<&str>) -> String { + workspace_id + .map(|id| format!("workspace:{id}")) .unwrap_or_else(|| "".to_string()) } -/// Return the Session root that owns execution-scoped discovery and routing. -/// -/// A managed worktree executes from `workspace_path` while Session persistence -/// remains anchored at `project_workspace_path`. Extension sources and agent -/// routes must follow the former so UI discovery and turn execution share one -/// workspace identity. -pub(crate) fn session_execution_workspace_root(config: &SessionConfig) -> Option<&Path> { - config - .workspace_path - .as_deref() - .or(config.project_workspace_path.as_deref()) - .map(Path::new) -} - /// Describes whether the workspace is local or remote via SSH. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum WorkspaceBackend { @@ -66,6 +38,7 @@ pub enum WorkspaceBackend { /// Session-bound workspace information used during agent execution. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct WorkspaceBinding { + pub project_workspace_id: Option, pub workspace_id: Option, /// For local workspaces this is a local path; for remote workspaces it is /// the path on the remote server (e.g. `/root/project`). @@ -76,21 +49,61 @@ pub struct WorkspaceBinding { /// Resolved execution target persisted with the session. pub execution_target: Option, pub backend: WorkspaceBackend, - /// Unified identity for session persistence. Local and remote workspaces - /// share the same model; the only semantic difference is hostname. + /// Resolved persistence projection. Workspace kind selects the backend; + /// hostname and paths describe IO locations, never workspace identity. pub session_identity: WorkspaceSessionIdentity, } impl WorkspaceBinding { + /// Construct an execution binding from the owning host's authoritative record. + pub async fn resolve(workspace_id: &str) -> crate::OpenBitFunResult { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| crate::OpenBitFunError::service("Workspace service is unavailable"))?; + let record = service.require_workspace(workspace_id).await?; + let mut config = SessionConfig::default(); + apply_workspace_record(&mut config, &record)?; + normalize_session_workspace(&mut config).await?; + if config.is_remote_workspace() { + let connection_id = config + .remote_connection_id + .expect("validated remote record"); + let host = config.remote_ssh_host.expect("validated remote record"); + let identity = WorkspaceSessionIdentity { + workspace_kind: record.workspace_kind, + hostname: host.clone(), + logical_workspace_path: record.root_path.to_string_lossy().into_owned(), + remote_connection_id: Some(connection_id.clone()), + }; + Ok(Self::new_remote( + Some(record.id), + record.root_path, + connection_id, + host, + identity, + )) + } else { + let project_id = config.project_workspace_id.ok_or_else(|| { + crate::OpenBitFunError::service("Project workspace ID is required") + })?; + let project = service.require_workspace(&project_id).await?; + let mut binding = Self::new(Some(record.id), record.root_path) + .with_project_root_path(project.root_path); + binding.project_workspace_id = Some(project_id); + binding.session_identity.workspace_kind = record.workspace_kind; + Ok(binding) + } + } + pub fn new(workspace_id: Option, root_path: PathBuf) -> Self { let logical_workspace_path = root_path.to_string_lossy().to_string(); - let session_identity = workspace_session_identity(&logical_workspace_path, None, None) - .unwrap_or(WorkspaceSessionIdentity { - hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), - logical_workspace_path, - remote_connection_id: None, - }); + let session_identity = WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Normal, + hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), + logical_workspace_path, + remote_connection_id: None, + }; Self { + project_workspace_id: workspace_id.clone(), workspace_id, project_root_path: root_path.clone(), execution_target: None, @@ -108,6 +121,7 @@ impl WorkspaceBinding { session_identity: WorkspaceSessionIdentity, ) -> Self { Self { + project_workspace_id: workspace_id.clone(), workspace_id, project_root_path: root_path.clone(), execution_target: None, @@ -206,8 +220,7 @@ impl WorkspaceBinding { #[cfg(test)] mod tests { - use super::{session_execution_workspace_root, WorkspaceBackend, WorkspaceBinding}; - use crate::agentic::core::SessionConfig; + use super::{WorkspaceBackend, WorkspaceBinding}; use crate::service::workspace_runtime::WorkspaceRuntimeService; use openbitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, @@ -217,29 +230,6 @@ mod tests { }; use std::path::PathBuf; - #[test] - fn session_execution_root_prefers_worktree_over_persistence_root() { - let config = SessionConfig { - workspace_path: Some("D:/worktrees/feature".to_string()), - project_workspace_path: Some("D:/projects/main".to_string()), - ..SessionConfig::default() - }; - - assert_eq!( - session_execution_workspace_root(&config), - Some(std::path::Path::new("D:/worktrees/feature")) - ); - - let legacy = SessionConfig { - project_workspace_path: Some("D:/projects/main".to_string()), - ..SessionConfig::default() - }; - assert_eq!( - session_execution_workspace_root(&legacy), - Some(std::path::Path::new("D:/projects/main")) - ); - } - #[test] fn remote_workspace_binding_uses_session_identity_storage_dir() { let session_identity = workspace_session_identity( @@ -299,3 +289,136 @@ mod tests { // Workspace-level I/O contracts are owned by openbitfun-runtime-ports and the // concrete providers are re-exported from their service owner crates above. + +/// Resolve old and new session configs against the owning host's workspace +/// registry before routing, ownership checks, extension discovery or persistence. +pub(crate) async fn normalize_session_workspace( + config: &mut SessionConfig, +) -> crate::OpenBitFunResult<()> { + use crate::service::workspace::{get_global_workspace_service, WorkspaceKind}; + if config.workspace_id.is_none() + && config.workspace_path.is_none() + && config.project_workspace_path.is_none() + { + return Ok(()); + } + let service = get_global_workspace_service() + .ok_or_else(|| crate::OpenBitFunError::service("Workspace service is unavailable"))?; + if config.workspace_id.is_none() || config.project_workspace_id.is_none() { + crate::service::workspace::legacy_compat::upgrade_session_workspace_reference( + config, &service, + ) + .await?; + } + let record = service + .require_workspace(config.workspace_id.as_deref().expect("workspace resolved")) + .await?; + let project_id = record + .project_workspace_id() + .map_err(crate::OpenBitFunError::service)?; + if config + .project_workspace_id + .as_deref() + .is_some_and(|id| id != project_id) + { + return Err(crate::OpenBitFunError::service( + "Session project workspace ID differs from its workspace object", + )); + } + let project = service.require_workspace(project_id).await?; + if (record.workspace_kind == WorkspaceKind::Remote + || project.workspace_kind == WorkspaceKind::Remote) + && record.id != project.id + { + return Err(crate::OpenBitFunError::service( + "Remote workspace cannot be bound to a different project workspace", + )); + } + apply_workspace_record(config, &record)?; + config.project_workspace_path = Some(project.root_path.to_string_lossy().into_owned()); + if !config.is_remote_workspace() { + let manager = crate::infrastructure::get_path_manager_arc(); + for path in [ + config.workspace_path.as_deref(), + config.project_workspace_path.as_deref(), + ] + .into_iter() + .flatten() + { + reject_session_storage_root( + Path::new(path), + &manager.projects_root(), + &manager.remote_ssh_mirror_root_dir(), + )?; + } + } + Ok(()) +} + +pub(crate) fn apply_workspace_record( + config: &mut SessionConfig, + record: &crate::service::workspace::WorkspaceInfo, +) -> crate::OpenBitFunResult<()> { + use crate::service::workspace::WorkspaceKind; + let (connection_id, host) = if record.workspace_kind == WorkspaceKind::Remote { + let id = record + .remote_ssh_connection_id() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + crate::OpenBitFunError::service( + "Remote workspace record is missing its saved SSH connection ID", + ) + })?; + let host = record + .metadata + .get("sshHost") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|host| !host.is_empty()) + .ok_or_else(|| { + crate::OpenBitFunError::service("Remote workspace record is missing its SSH host") + })?; + (Some(id.to_owned()), Some(host.to_owned())) + } else { + (None, None) + }; + let root = record.root_path.to_string_lossy().into_owned(); + config.project_workspace_id = Some( + record + .project_workspace_id() + .map_err(crate::OpenBitFunError::service)? + .to_owned(), + ); + config.project_workspace_path = + if config.project_workspace_id.as_deref() == Some(record.id.as_str()) { + Some(root.clone()) + } else { + None + }; + config.workspace_path = Some(root.clone()); + if let Some(target) = config.execution_target.as_mut() { + target.root_path = root; + } + config.workspace_kind = Some(record.workspace_kind.clone()); + config.workspace_id = Some(record.id.clone()); + config.remote_connection_id = connection_id; + config.remote_ssh_host = host; + Ok(()) +} + +/// Persistence roots never become execution roots, even if they exist locally. +pub(crate) fn reject_session_storage_root( + path: &Path, + projects_root: &Path, + ssh_mirror_root: &Path, +) -> crate::OpenBitFunResult<()> { + if [projects_root, ssh_mirror_root].iter().any(|root| { + path.starts_with(root) || dunce::canonicalize(path).is_ok_and(|path| path.starts_with(root)) + }) { + return Err(crate::OpenBitFunError::service( + "Session storage directories cannot be used as workspace execution roots", + )); + } + Ok(()) +} diff --git a/src/crates/assembly/core/src/external_hook_import.rs b/src/crates/assembly/core/src/external_hook_import.rs index 3152d5b85b..30b7e60146 100644 --- a/src/crates/assembly/core/src/external_hook_import.rs +++ b/src/crates/assembly/core/src/external_hook_import.rs @@ -1,7 +1,6 @@ //! Explicit local plan/apply lifecycle for imported command Hooks. use crate::external_hooks::service_for; -use crate::external_sources::normalize_workspace_root; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use futures::future::join_all; use openbitfun_product_domains::external_hook_import::{ @@ -23,7 +22,7 @@ use openbitfun_services_integrations::hook_import::{ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; @@ -99,14 +98,9 @@ async fn store_for( Ok(store) } -async fn stores_for(workspace: Option<&Path>) -> ExternalSourceOperationResult { - let workspace = normalize_workspace_root(workspace).map_err(|error| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::InvalidRequest, - error, - false, - ) - })?; +async fn stores_for(workspace: Option<&str>) -> ExternalSourceOperationResult { + let workspace_id = workspace; + let workspace = crate::external_hooks::local_workspace_root(workspace_id).await?; let path_manager = try_get_path_manager_arc().map_err(|error| { ExternalSourceOperationError::new( ExternalSourceOperationErrorCode::Internal, @@ -135,14 +129,14 @@ async fn stores_for(workspace: Option<&Path>) -> ExternalSourceOperationResult, + workspace: Option<&str>, refresh_updates: bool, ) -> ExternalSourceOperationResult { let catalog_service = service_for(workspace).await?; @@ -195,14 +189,14 @@ pub async fn external_hook_import_snapshot( } pub async fn plan_external_hook_import( - workspace: Option<&Path>, + workspace: Option<&str>, source: SourceKey, ) -> ExternalSourceOperationResult { Ok(build_plan(workspace, source).await?.plan) } pub async fn apply_external_hook_import( - workspace: Option<&Path>, + workspace: Option<&str>, request: ExternalHookImportApplyRequestV1, ) -> ExternalSourceOperationResult { if request.schema_version != EXTERNAL_HOOK_IMPORT_SCHEMA_V1 { @@ -243,7 +237,7 @@ pub async fn apply_external_hook_import( } pub async fn mutate_external_hook_import( - workspace: Option<&Path>, + workspace: Option<&str>, request: ExternalHookImportMutationRequestV1, ) -> ExternalSourceOperationResult { if request.schema_version != EXTERNAL_HOOK_IMPORT_SCHEMA_V1 { @@ -302,7 +296,7 @@ pub async fn mutate_external_hook_import( } pub(crate) async fn imported_hook_generation( - workspace: Option<&Path>, + workspace: Option<&str>, ) -> ExternalSourceOperationResult { let stores = stores_for(workspace).await?; let user_snapshot = stores.user.snapshot().await.map_err(map_store_error)?; @@ -318,7 +312,7 @@ pub(crate) async fn imported_hook_generation( } pub(crate) async fn enabled_imported_hook_layers( - workspace: Option<&Path>, + workspace: Option<&str>, ) -> ExternalSourceOperationResult< Vec, > { @@ -335,7 +329,7 @@ pub(crate) async fn enabled_imported_hook_layers( } async fn build_plan( - workspace: Option<&Path>, + workspace: Option<&str>, source_key: SourceKey, ) -> ExternalSourceOperationResult { let catalog_service = service_for(workspace).await?; diff --git a/src/crates/assembly/core/src/external_hooks.rs b/src/crates/assembly/core/src/external_hooks.rs index 1a20f4eeb2..f2dd8959b7 100644 --- a/src/crates/assembly/core/src/external_hooks.rs +++ b/src/crates/assembly/core/src/external_hooks.rs @@ -11,7 +11,7 @@ pub use openbitfun_product_domains::external_hook_catalog::{ }; pub use openbitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; -use crate::external_sources::{host_execution_domain_id, normalize_workspace_root}; +use crate::external_sources::host_execution_domain_id; #[cfg(feature = "git")] use crate::service::workspace::{global_worktree_topology_service, WorktreeTopologyFreshness}; use openbitfun_claude_code_adapter::{ClaudeCodeHookProvider, ClaudeCodeHookProviderOptions}; @@ -193,11 +193,10 @@ struct CachedHookCatalogService { last_used: u64, } -fn service_cache( -) -> &'static tokio::sync::Mutex, CachedHookCatalogService>> { - static CACHE: OnceLock< - tokio::sync::Mutex, CachedHookCatalogService>>, - > = OnceLock::new(); +fn service_cache() -> &'static tokio::sync::Mutex, CachedHookCatalogService>> +{ + static CACHE: OnceLock, CachedHookCatalogService>>> = + OnceLock::new(); CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) } @@ -207,18 +206,13 @@ fn next_access_tick() -> u64 { } pub(crate) async fn service_for( - workspace_root: Option<&std::path::Path>, + workspace_id: Option<&str>, ) -> ExternalSourceOperationResult> { - let workspace_root = normalize_workspace_root(workspace_root).map_err(|error| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::InvalidRequest, - error, - false, - ) - })?; + let workspace_root = local_workspace_root(workspace_id).await?; + let cache_key = workspace_id.map(str::to_owned); { let mut cache = service_cache().lock().await; - if let Some(cached) = cache.get_mut(&workspace_root) { + if let Some(cached) = cache.get_mut(&cache_key) { cached.last_used = next_access_tick(); return Ok(Arc::clone(&cached.service)); } @@ -269,7 +263,7 @@ pub(crate) async fn service_for( })?, ); let mut cache = service_cache().lock().await; - if let Some(cached) = cache.get_mut(&workspace_root) { + if let Some(cached) = cache.get_mut(&cache_key) { // Service construction runs without the cache lock. Another caller // may have inserted a newer access meanwhile, so allocate a fresh LRU // tick instead of moving that shared entry backwards in time. @@ -286,7 +280,7 @@ pub(crate) async fn service_for( } } cache.insert( - workspace_root, + cache_key, CachedHookCatalogService { service: Arc::clone(&service), last_used: next_access_tick(), @@ -350,6 +344,7 @@ pub(crate) fn resolve_hook_project_topology( /// before consulting the local filesystem so remote surfaces cannot /// accidentally display Hooks from the controller machine. pub async fn external_hook_catalog_snapshot( + workspace_id: Option<&str>, context: ExternalSourceContext, force_refresh: bool, ) -> ExternalSourceOperationResult { @@ -363,7 +358,7 @@ pub async fn external_hook_catalog_snapshot( false, )); } - let service = service_for(context.workspace_root.as_deref()).await?; + let service = service_for(workspace_id).await?; service.snapshot_or_refresh(force_refresh).await } @@ -371,18 +366,33 @@ pub async fn external_hook_catalog_snapshot( /// construction here prevents transport adapters from depending on the /// internal local-domain identifier. pub async fn local_external_hook_catalog_snapshot( - workspace_root: Option<&std::path::Path>, + workspace_id: Option<&str>, force_refresh: bool, ) -> ExternalSourceOperationResult { - let execution_domain_id = host_execution_domain_id().map_err(|error| { - ExternalSourceOperationError::new(ExternalSourceOperationErrorCode::Internal, error, false) + service_for(workspace_id) + .await? + .snapshot_or_refresh(force_refresh) + .await +} + +/// Resolve identity before deriving any local discovery or storage path. +pub(crate) async fn local_workspace_root( + workspace_id: Option<&str>, +) -> ExternalSourceOperationResult> { + let Some(id) = workspace_id else { + return Ok(None); + }; + let service = crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Workspace service is unavailable") })?; - external_hook_catalog_snapshot( - ExternalSourceContext { - workspace_root: workspace_root.map(std::path::Path::to_path_buf), - execution_domain_id, - }, - force_refresh, - ) - .await + let record = service + .require_workspace(id) + .await + .map_err(|error| ExternalSourceOperationError::invalid_request(error.to_string()))?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err(ExternalSourceOperationError::host_capability_unavailable( + "Static Hook inspection does not support remote workspaces", + )); + } + Ok(Some(record.root_path)) } diff --git a/src/crates/assembly/core/src/external_hooks_tests.rs b/src/crates/assembly/core/src/external_hooks_tests.rs index 5a6d77b9c8..abb567ac22 100644 --- a/src/crates/assembly/core/src/external_hooks_tests.rs +++ b/src/crates/assembly/core/src/external_hooks_tests.rs @@ -139,7 +139,7 @@ async fn initial_cache_only_request_starts_the_first_discovery() { #[tokio::test] async fn remote_execution_domain_fails_closed_before_local_discovery() { - let error = external_hook_catalog_snapshot(context("peer-machine"), true) + let error = external_hook_catalog_snapshot(None, context("peer-machine"), true) .await .unwrap_err(); assert_eq!(error.code, ExternalSourceOperationErrorCode::Unsupported); diff --git a/src/crates/assembly/core/src/external_mcp_import.rs b/src/crates/assembly/core/src/external_mcp_import.rs index e397576661..900a19c01f 100644 --- a/src/crates/assembly/core/src/external_mcp_import.rs +++ b/src/crates/assembly/core/src/external_mcp_import.rs @@ -14,7 +14,6 @@ use openbitfun_services_integrations::mcp::config::{ }; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ExternalMcpImportPreparation { @@ -41,16 +40,16 @@ const MAX_IMPORT_PLAN_ITEMS: usize = 256; const MAX_NATIVE_ID_BYTES: usize = 160; pub async fn plan_external_mcp_import( - workspace_root: Option, + workspace_id: Option, ) -> ExternalSourceOperationResult { let config_service = mcp_config_service().await?; - Ok(compute_current_plan(workspace_root, &config_service) + Ok(compute_current_plan(workspace_id, &config_service) .await? .public) } pub async fn apply_external_mcp_import( - workspace_root: Option, + workspace_id: Option, request: ExternalMcpImportApplyRequestV1, ) -> ExternalSourceOperationResult { request.validate().map_err(|_| { @@ -61,7 +60,7 @@ pub async fn apply_external_mcp_import( ) })?; let config_service = mcp_config_service().await?; - let computed = compute_current_plan(workspace_root.clone(), &config_service).await?; + let computed = compute_current_plan(workspace_id.clone(), &config_service).await?; if request.plan_fingerprint != computed.public.plan_fingerprint { return Ok(stale_result(computed.public)); } @@ -92,7 +91,7 @@ pub async fn apply_external_mcp_import( outcome: ExternalMcpImportApplyOutcomeV1::Applied { imported }, }), Err(MCPImportError::StaleConfiguration | MCPImportError::TargetConflict { .. }) => { - let refreshed = compute_current_plan(workspace_root, &config_service).await?; + let refreshed = compute_current_plan(workspace_id, &config_service).await?; Ok(stale_result(refreshed.public)) } Err(error) => Err(map_import_error(error)), @@ -120,12 +119,12 @@ async fn mcp_config_service( } async fn compute_current_plan( - workspace_root: Option, + workspace_id: Option, config_service: &crate::service::mcp::config::MCPConfigService, ) -> ExternalSourceOperationResult { - let workspace_root = ensure_local_workspace(workspace_root).await?; + let workspace_id = ensure_local_workspace(workspace_id).await?; let candidates = - crate::external_sources::collect_external_mcp_import_candidates(workspace_root.as_deref()) + crate::external_sources::collect_external_mcp_import_candidates(workspace_id.as_deref()) .await .map_err(|_| { operation_error( @@ -149,14 +148,25 @@ async fn compute_current_plan( } async fn ensure_local_workspace( - workspace_root: Option, -) -> ExternalSourceOperationResult> { - if let Some(root) = workspace_root.as_ref() { - if crate::service::remote_ssh::workspace_state::is_remote_path( - root.to_string_lossy().as_ref(), - ) - .await - { + workspace_id: Option, +) -> ExternalSourceOperationResult> { + if let Some(id) = workspace_id.as_deref() { + let service = + crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + operation_error( + ExternalSourceOperationErrorCode::Unavailable, + "Workspace service is unavailable", + true, + ) + })?; + let workspace = service.require_workspace(id).await.map_err(|_| { + operation_error( + ExternalSourceOperationErrorCode::InvalidRequest, + "Unknown workspace ID", + false, + ) + })?; + if workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { return Err(operation_error( ExternalSourceOperationErrorCode::Unsupported, "External MCP import is not available for a remote workspace", @@ -164,7 +174,7 @@ async fn ensure_local_workspace( )); } } - Ok(workspace_root) + Ok(workspace_id) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 31077af11b..5ee3697d2a 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -1244,6 +1244,38 @@ impl ExternalSourcePreferenceStore { )) } + async fn upgrade_workspace_policy(&self, workspace_id: &str) -> Result<(), String> { + let registry = crate::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let records = registry.list_workspace_infos().await; + let key = workspace_policy_key(Some(workspace_id)).ok_or("Missing workspace policy key")?; + let upgrade = |config: &mut ExternalSourcesConfig| -> Result { + let Some(document) = config.integration_policy.known_mut() else { + return Ok(false); + }; + crate::service::workspace::legacy_compat::upgrade_external_policy_key( + &mut document.workspace_overrides, + &records, + workspace_id, + &key, + ) + }; + let mut config = self.read().await?; + if !upgrade(&mut config)? { + return Ok(()); + } + let (result, _) = self + .update(|config| { + let changed = upgrade(config)?; + if changed { + config.preference_revision += 1; + } + Ok::<(), String>(()) + }) + .await?; + result + } + async fn read(&self) -> Result { JsonFileStore .read_locked_optional(&self.path) @@ -1330,8 +1362,8 @@ pub(crate) fn host_execution_domain_id() -> Result { ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).map_err(|error| error.to_string()) } -fn workspace_policy_key(workspace_root: Option<&Path>) -> Option { - let route = workspace_route_key(workspace_root); +fn workspace_policy_key(workspace_id: Option<&str>) -> Option { + let route = workspace_route_key(workspace_id); workspace_policy_key_from_route(&route) } @@ -1339,11 +1371,8 @@ fn workspace_policy_key_from_route(route: &str) -> Option { if route == "" { return None; } - let normalized = route.replace('\\', "/"); - #[cfg(windows)] - let normalized = normalized.to_ascii_lowercase(); let mut hasher = Sha256::new(); - hasher.update(normalized.as_bytes()); + hasher.update(route.as_bytes()); Some(format!( "workspace:{}", hex::encode(&hasher.finalize()[..16]) @@ -1376,13 +1405,13 @@ fn set_external_source_safe_mode_for( fn integration_policy_snapshot( preferences: &ExternalSourcesConfig, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result { let ecosystems = default_external_integration_ecosystems(); match preferences.integration_policy.known() { Some(document) => external_integration_policy_snapshot( document, - workspace_policy_key(workspace_root).as_deref(), + workspace_policy_key(workspace_id).as_deref(), ecosystems, ), None => incompatible_external_integration_policy_snapshot( @@ -1602,6 +1631,7 @@ enum ExternalSourceServiceProfile { struct WorkspaceExternalSourceService { profile: ExternalSourceServiceProfile, + workspace_id: Option, workspace_root: Option, execution_domain_id: ExecutionDomainId, mcp_revision_key: ExternalMcpRevisionKey, @@ -1633,16 +1663,16 @@ impl WorkspaceExternalSourceService { preferences: &ExternalSourcesConfig, ) -> Result { if self.profile != ExternalSourceServiceProfile::DiscoveryCatalog { - return integration_policy_snapshot(preferences, self.workspace_root.as_deref()); + return integration_policy_snapshot(preferences, self.workspace_id.as_deref()); } let Some(stored) = preferences.integration_policy.known() else { - return integration_policy_snapshot(preferences, self.workspace_root.as_deref()); + return integration_policy_snapshot(preferences, self.workspace_id.as_deref()); }; // This policy is used only by static discovery lanes. Runtime decisions // and the public policy snapshot continue to use the original document. let mut discovery = stored.clone(); discovery.user_defaults.enabled = true; - if let Some(key) = workspace_policy_key(self.workspace_root.as_deref()) { + if let Some(key) = workspace_policy_key(self.workspace_id.as_deref()) { discovery .workspace_overrides .entry(key) @@ -1651,7 +1681,7 @@ impl WorkspaceExternalSourceService { } external_integration_policy_snapshot( &discovery, - workspace_policy_key(self.workspace_root.as_deref()).as_deref(), + workspace_policy_key(self.workspace_id.as_deref()).as_deref(), default_external_integration_ecosystems(), ) .map_err(|error| format!("policy_unavailable: {error}")) @@ -1668,7 +1698,7 @@ impl WorkspaceExternalSourceService { .is_some_and(|document| { automatic_discovery_enabled( document, - workspace_policy_key(self.workspace_root.as_deref()).as_deref(), + workspace_policy_key(self.workspace_id.as_deref()).as_deref(), ) })) } @@ -1683,7 +1713,7 @@ impl WorkspaceExternalSourceService { .is_some_and(|document| { automatic_discovery_enabled( document, - workspace_policy_key(self.workspace_root.as_deref()).as_deref(), + workspace_policy_key(self.workspace_id.as_deref()).as_deref(), ) }) { @@ -1694,6 +1724,7 @@ impl WorkspaceExternalSourceService { } async fn create( + workspace_id: Option, workspace_root: Option, profile: ExternalSourceServiceProfile, ) -> Result, String> { @@ -1776,19 +1807,20 @@ impl WorkspaceExternalSourceService { control_plane.subagents(|coordinator| coordinator.snapshot().generation); initial_snapshot.preference_revision = preferences.preference_revision; initial_snapshot.integration_policy = - integration_policy_snapshot(&preferences, workspace_root.as_deref())?; + integration_policy_snapshot(&preferences, workspace_id.as_deref())?; let automatic_discovery = preferences .integration_policy .known() .is_some_and(|document| { automatic_discovery_enabled( document, - workspace_policy_key(workspace_root.as_deref()).as_deref(), + workspace_policy_key(workspace_id.as_deref()).as_deref(), ) }); let (updates, _) = broadcast::channel(32); let service = Arc::new(Self { profile, + workspace_id, workspace_root, execution_domain_id, mcp_revision_key, @@ -1977,14 +2009,14 @@ impl WorkspaceExternalSourceService { if self.profile == ExternalSourceServiceProfile::LocalExecution && matches!(recovery_policy, WorkerRecoveryPolicy::ResetAndAttempt) { - reset_external_tool_workspace_recovery_budget(self.workspace_root.as_deref()).await; + reset_external_tool_workspace_recovery_budget(self.workspace_id.as_deref()).await; } let recovery_targets = if self.profile == ExternalSourceServiceProfile::LocalExecution && matches!( recovery_policy, WorkerRecoveryPolicy::PendingOnce | WorkerRecoveryPolicy::ResetAndAttempt ) { - begin_external_tool_workspace_recovery(self.workspace_root.as_deref()).await + begin_external_tool_workspace_recovery(self.workspace_id.as_deref()).await } else { BTreeSet::new() }; @@ -2162,7 +2194,7 @@ impl WorkspaceExternalSourceService { let _rebuild_guard = self.product_rebuild_gate.lock().await; let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); let mut preferences = read_external_sources_config().await?; - let mut policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + let mut policy = integration_policy_snapshot(&preferences, self.workspace_id.as_deref())?; if self.profile != ExternalSourceServiceProfile::LocalExecution { return self .rebuild_read_only_projection(command_snapshot, preferences, policy) @@ -2197,7 +2229,7 @@ impl WorkspaceExternalSourceService { ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP) }; let mut state = reconcile_external_tools( - self.workspace_root.as_deref(), + self.workspace_id.as_deref(), self.execution_domain_id.as_str(), &self.control_plane, ExternalToolDecisions { @@ -2223,7 +2255,7 @@ impl WorkspaceExternalSourceService { let tool_snapshot = lock_tool_coordinator(&self.control_plane).snapshot(); let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); let mcp_snapshot = lock_mcp_coordinator(&self.control_plane).snapshot(); - let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); + let mcp_workspace_key = workspace_route_key(self.workspace_id.as_deref()); let native_mcp_candidates = if !mcp_active_ecosystems.is_empty() { load_native_mcp_candidates(&self.mcp_revision_key).await } else { @@ -2267,6 +2299,7 @@ impl WorkspaceExternalSourceService { merge_mcp_state(&mut snapshot, &mcp_snapshot, mcp_state); let subagent_snapshot = lock_subagent_coordinator(&self.control_plane).snapshot(); let mut subagent_state = reconcile_external_subagents( + self.workspace_id.as_deref(), self.workspace_root.as_deref(), self.execution_domain_id.as_str(), &subagent_snapshot, @@ -2300,6 +2333,7 @@ impl WorkspaceExternalSourceService { preferences = authoritative; if decisions_changed { subagent_state = reconcile_external_subagents( + self.workspace_id.as_deref(), self.workspace_root.as_deref(), self.execution_domain_id.as_str(), &subagent_snapshot, @@ -2348,9 +2382,9 @@ impl WorkspaceExternalSourceService { &mut snapshot.command_conflicts, &active_subagents, ); - if let Some(workspace_root) = self.workspace_root.as_deref() { + if let Some(workspace_id) = self.workspace_id.as_deref() { crate::agentic::agents::get_agent_registry().install_external_subagent_routes( - workspace_root, + workspace_id, subagent_state.registrations, subagent_state.routes, ); @@ -2412,7 +2446,7 @@ impl WorkspaceExternalSourceService { snapshot.sources.clear(); snapshot.diagnostics.clear(); } - policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + policy = integration_policy_snapshot(&preferences, self.workspace_id.as_deref())?; snapshot.integration_policy = policy; assign_external_source_presentation_groups(&mut snapshot); sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); @@ -2474,7 +2508,7 @@ impl WorkspaceExternalSourceService { let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, tool_state); let mcp_snapshot = lock_mcp_coordinator(&self.control_plane).snapshot(); - let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); + let mcp_workspace_key = workspace_route_key(self.workspace_id.as_deref()); let mut mcp_state = reconcile_external_mcp_catalog( self.execution_domain_id.as_str(), &mcp_workspace_key, @@ -2503,6 +2537,7 @@ impl WorkspaceExternalSourceService { let subagent_snapshot = lock_subagent_coordinator(&self.control_plane).snapshot(); let subagent_state = project_external_subagents_read_only( + self.workspace_id.as_deref(), self.workspace_root.as_deref(), self.execution_domain_id.as_str(), &subagent_snapshot, @@ -2577,7 +2612,7 @@ impl WorkspaceExternalSourceService { .collect::>(); let desired_ids = desired.keys().cloned().collect::>(); let mut managed = self.active_mcp_runtime_ids.lock().await.clone(); - let workspace_key = workspace_route_key(self.workspace_root.as_deref()); + let workspace_key = workspace_route_key(self.workspace_id.as_deref()); if let Err(reason) = self .mcp_runtime .replace_workspace_route( @@ -3096,7 +3131,7 @@ impl WorkspaceExternalSourceService { if Arc::strong_count(&service) > 1 { continue; } - let key = service.workspace_root.clone(); + let key = service.workspace_id.clone(); let services = workspace_services_for_profile(service.profile); if let Some(entry) = services.get(&key) { let should_remove = entry @@ -3130,9 +3165,9 @@ impl WorkspaceExternalSourceService { services.remove(&key); if service.profile == ExternalSourceServiceProfile::LocalExecution { release_external_tool_workspace(key.as_deref()).await; - if let Some(workspace_root) = key.as_deref() { + if let Some(workspace_id) = service.workspace_id.as_deref() { crate::agentic::agents::get_agent_registry() - .release_external_subagent_workspace(workspace_root); + .release_external_subagent_workspace(workspace_id); } } } @@ -3150,7 +3185,7 @@ impl WorkspaceExternalSourceService { #[cfg(feature = "opencode-plugin-host")] if self.profile != ExternalSourceServiceProfile::DiscoveryCatalog { for message in crate::plugin_host::configured_plugin_activation_failures( - self.workspace_root.as_deref(), + self.workspace_id.as_deref(), ) { if !snapshot.diagnostics.iter().any(|diagnostic| { diagnostic.code == "plugin.activation_failed" && diagnostic.message == message @@ -3208,14 +3243,14 @@ impl WorkspaceExternalSourceService { fn safe_mode_enabled(&self) -> bool { external_source_safe_mode_enabled_for( self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), + &workspace_route_key(self.workspace_id.as_deref()), ) } fn write_safe_mode(&self, enabled: bool) { set_external_source_safe_mode_for( self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), + &workspace_route_key(self.workspace_id.as_deref()), enabled, ); } @@ -3443,7 +3478,7 @@ impl WorkspaceExternalSourceService { mutation: ExternalIntegrationPolicyMutation, ) -> Result { let preferences = - persist_integration_policy_mutation(self.workspace_root.as_deref(), mutation).await?; + persist_integration_policy_mutation(self.workspace_id.as_deref(), mutation).await?; propagate_integration_policy_preferences(&preferences, self); self.refresh_preserving_worker_recovery().await } @@ -4453,11 +4488,10 @@ fn lock_snapshot( } } -static WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, -> = OnceLock::new(); +static WORKSPACE_SERVICES: OnceLock, Weak>> = + OnceLock::new(); static READ_ONLY_WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, + DashMap, Weak>, > = OnceLock::new(); static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); @@ -4467,25 +4501,25 @@ fn safe_mode_workspaces() -> &'static DashMap { static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); -fn workspace_services() -> &'static DashMap, Weak> { +fn workspace_services() -> &'static DashMap, Weak> { WORKSPACE_SERVICES.get_or_init(DashMap::new) } fn read_only_workspace_services( -) -> &'static DashMap, Weak> { +) -> &'static DashMap, Weak> { READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) } fn discovery_workspace_services( -) -> &'static DashMap, Weak> { - static SERVICES: OnceLock, Weak>> = +) -> &'static DashMap, Weak> { + static SERVICES: OnceLock, Weak>> = OnceLock::new(); SERVICES.get_or_init(DashMap::new) } fn workspace_services_for_profile( profile: ExternalSourceServiceProfile, -) -> &'static DashMap, Weak> { +) -> &'static DashMap, Weak> { match profile { ExternalSourceServiceProfile::LocalExecution => workspace_services(), ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), @@ -4498,20 +4532,6 @@ fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { GATE.get_or_init(|| tokio::sync::Mutex::new(())) } -pub(crate) fn normalize_workspace_root( - workspace_root: Option<&Path>, -) -> Result, String> { - let Some(workspace_root) = workspace_root else { - return Ok(None); - }; - if !workspace_root.is_absolute() { - return Err("external source workspace root must be absolute".to_string()); - } - Ok(Some( - crate::agentic::workspace::canonical_local_workspace_path(workspace_root), - )) -} - fn relative_display_path(location: &str, root: &Path) -> Option { let normalized_location = location.replace('\\', "/"); let normalized_root = root.to_string_lossy().replace('\\', "/"); @@ -5004,9 +5024,9 @@ fn merge_mcp_state( } async fn service_for( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result, String> { - service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution).await + service_for_profile(workspace_id, ExternalSourceServiceProfile::LocalExecution).await } fn mcp_import_discovery_complete( @@ -5020,13 +5040,10 @@ fn mcp_import_discovery_complete( } pub(crate) async fn collect_external_mcp_import_candidates( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result, String> { - let service = service_for_profile( - workspace_root, - ExternalSourceServiceProfile::DiscoveryCatalog, - ) - .await?; + let service = + service_for_profile(workspace_id, ExternalSourceServiceProfile::DiscoveryCatalog).await?; service.refresh_mcp_import_sources().await?; // Provider preparation performs bounded synchronous file reads. Keep it off // the async workers serving UI requests and connection lifecycle. @@ -5088,27 +5105,48 @@ pub(crate) async fn collect_external_mcp_import_candidates( } async fn read_only_service_for( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result, String> { service_for_profile( - workspace_root, + workspace_id, ExternalSourceServiceProfile::ReadOnlyProjection, ) .await } async fn service_for_profile( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, profile: ExternalSourceServiceProfile, ) -> Result, String> { - let workspace_root = normalize_workspace_root(workspace_root)?; + let workspace = match workspace_id { + Some(id) => { + let registry = crate::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let record = registry + .require_workspace(id) + .await + .map_err(|error| error.to_string())?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err("External source discovery does not support remote workspaces".into()); + } + Some(record) + } + None => None, + }; + if let Some(id) = workspace_id { + ExternalSourcePreferenceStore::global()? + .upgrade_workspace_policy(id) + .await?; + } + let workspace_root = workspace.as_ref().map(|record| record.root_path.clone()); + let workspace_id = workspace_id.map(str::to_owned); // Serialize cache acquisition with idle retirement. Without this lease // gate, a caller could upgrade the weak entry after the retirement count // check and have its newly acquired routes removed underneath it. let _service_gate = workspace_service_gate().lock().await; let services = workspace_services_for_profile(profile); if let Some(service) = services - .get(&workspace_root) + .get(&workspace_id) .and_then(|service| service.value().upgrade()) { service.touch(); @@ -5132,8 +5170,13 @@ async fn service_for_profile( } return Ok(service); } - let created = WorkspaceExternalSourceService::create(workspace_root.clone(), profile).await?; - let service = match services.entry(workspace_root) { + let created = WorkspaceExternalSourceService::create( + workspace_id.clone(), + workspace_root.clone(), + profile, + ) + .await?; + let service = match services.entry(workspace_id) { Entry::Occupied(mut entry) => match entry.get().upgrade() { Some(existing) => existing, None => { @@ -5175,9 +5218,9 @@ fn acknowledged_ecosystem_key(execution_domain_id: &str, ecosystem_id: &str) -> /// drift apart. An ecosystem only qualifies once discovery actually found a /// source for it: a registered adapter with nothing to offer is not news. pub async fn unacknowledged_external_ecosystems( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result, String> { - let service = read_only_service_for(workspace_root).await?; + let service = read_only_service_for(workspace_id).await?; let execution_domain_id = service.execution_domain_id.clone(); let discovered = service .snapshot() @@ -5214,13 +5257,13 @@ pub async fn unacknowledged_external_ecosystems( /// The execution domain is resolved from the workspace's own service so hosts /// never pass an identity that disagrees with the one discovery recorded. pub async fn acknowledge_external_ecosystems( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ecosystem_ids: Vec, ) -> Result<(), String> { if ecosystem_ids.is_empty() { return Ok(()); } - let execution_domain_id = read_only_service_for(workspace_root) + let execution_domain_id = read_only_service_for(workspace_id) .await? .execution_domain_id .clone(); @@ -6128,14 +6171,14 @@ fn apply_workspace_policy_operation( } async fn persist_integration_policy_mutation( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, mutation: ExternalIntegrationPolicyMutation, ) -> Result { validate_integration_policy_operation(mutation.scope, &mutation.change)?; let workspace_key = match mutation.scope { ExternalIntegrationPolicyScope::User => None, ExternalIntegrationPolicyScope::Workspace => { - Some(workspace_policy_key(workspace_root).ok_or_else(|| { + Some(workspace_policy_key(workspace_id).ok_or_else(|| { invalid_operation_error("Workspace policy scope requires a workspace") })?) } @@ -6418,7 +6461,7 @@ pub(crate) fn notify_external_tool_registry_changed() { async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> Result<(), String> { let preferences = read_external_sources_config().await?; - let policy = integration_policy_snapshot(&preferences, service.workspace_root.as_deref())?; + let policy = integration_policy_snapshot(&preferences, service.workspace_id.as_deref())?; let suppressed_sources = preferences .suppressed_source_keys .iter() @@ -6674,10 +6717,10 @@ fn project_native_prompt_command_conflicts( } pub async fn native_prompt_command_conflicts( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, native_commands: Vec, ) -> Result { - let snapshot = external_source_snapshot(workspace_root, false).await?; + let snapshot = external_source_snapshot(workspace_id, false).await?; let preferences = read_external_sources_config().await?; project_native_prompt_command_conflicts( &snapshot, @@ -6689,12 +6732,12 @@ pub async fn native_prompt_command_conflicts( } pub async fn set_native_prompt_command_conflict_choice( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, native_commands: Vec, selected_candidate_id: &str, expected_preference_revision: u64, ) -> Result { - let snapshot = external_source_snapshot(workspace_root, false).await?; + let snapshot = external_source_snapshot(workspace_id, false).await?; let preferences = read_external_sources_config().await?; if preferences.preference_revision != expected_preference_revision { return Err(stale_operation_error( @@ -7045,26 +7088,26 @@ fn validate_native_prompt_command_expansion_guard( } pub async fn set_external_prompt_command_conflict_choice( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, conflict_key: &str, candidate_id: &str, expected_preference_revision: u64, ) -> Result { validate_conflict_preference(conflict_key, candidate_id)?; - service_for(workspace_root) + service_for(workspace_id) .await? .set_conflict_choice(conflict_key, candidate_id, expected_preference_revision) .await } pub async fn set_external_tool_target_decision( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, approval_key: &str, decision_key: &str, approved: bool, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_tool_target_decision( approval_key, @@ -7076,13 +7119,13 @@ pub async fn set_external_tool_target_decision( } pub async fn set_external_tool_targets_enabled( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, decisions: Vec<(String, String)>, enabled: bool, expected_catalog_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_tool_targets_enabled( decisions, @@ -7094,26 +7137,26 @@ pub async fn set_external_tool_targets_enabled( } pub async fn set_external_tool_conflict_choice( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, conflict_key: &str, candidate_id: &str, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_tool_conflict_choice(conflict_key, candidate_id, expected_preference_revision) .await } pub async fn set_external_mcp_server_decision( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, candidate_id: &str, decision_key: &str, approved: bool, expected_mcp_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_mcp_server_decision( candidate_id, @@ -7126,13 +7169,13 @@ pub async fn set_external_mcp_server_decision( } pub async fn set_external_mcp_servers_enabled( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, decisions: Vec<(String, String)>, enabled: bool, expected_mcp_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_mcp_servers_enabled( decisions, @@ -7144,14 +7187,14 @@ pub async fn set_external_mcp_servers_enabled( } pub async fn choose_external_mcp_conflict( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, conflict_key: &str, candidate_id: &str, approve_external: bool, expected_mcp_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .choose_mcp_conflict( conflict_key, @@ -7164,14 +7207,14 @@ pub async fn choose_external_mcp_conflict( } pub async fn set_external_subagent_activation( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, candidate_id: &str, approved: bool, expected_subagent_generation: u64, expected_preference_revision: u64, decision_key: &str, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_subagent_activation( candidate_id, @@ -7184,13 +7227,13 @@ pub async fn set_external_subagent_activation( } pub async fn set_external_subagents_enabled( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, decisions: Vec<(String, String)>, enabled: bool, expected_subagent_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_subagents_enabled( decisions, @@ -7202,13 +7245,13 @@ pub async fn set_external_subagents_enabled( } pub async fn set_external_subagent_model_binding( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, binding_key: &str, target: Option, expected_subagent_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_subagent_model_binding( binding_key, @@ -7220,14 +7263,14 @@ pub async fn set_external_subagent_model_binding( } pub async fn choose_external_subagent_conflict( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, conflict_key: &str, candidate_id: &str, approve_external: bool, expected_subagent_generation: u64, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .choose_subagent_conflict( conflict_key, @@ -7240,10 +7283,10 @@ pub async fn choose_external_subagent_conflict( } pub async fn external_source_snapshot( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, force_refresh: bool, ) -> Result { - let service = service_for(workspace_root).await?; + let service = service_for(workspace_id).await?; if force_refresh { service.refresh_with_runtime_invalidation().await } else { @@ -7253,11 +7296,11 @@ pub async fn external_source_snapshot( } pub async fn workspace_reference_snapshot( - workspace_root: &Path, + workspace_id: &str, native_related_paths: &[crate::service::workspace::RelatedPath], force_refresh: bool, ) -> Result { - let service = service_for(Some(workspace_root)).await?; + let service = service_for(Some(workspace_id)).await?; sync_service_preferences(&service).await?; if force_refresh { service.refresh_workspace_references(true).await?; @@ -7334,25 +7377,23 @@ fn native_workspace_reference_key(related_path: &crate::service::workspace::Rela /// Resolves an opaque source identity to its host-local location for a host /// action. The raw path must not be serialized into the public snapshot. pub async fn external_source_location_for_host_action( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, stable_key: &str, ) -> Result { - service_for(workspace_root) - .await? - .source_location(stable_key) + service_for(workspace_id).await?.source_location(stable_key) } pub async fn get_external_source_control_snapshot( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, force_refresh: bool, host_capabilities: ExternalSourceHostCapabilities, ) -> ExternalSourceOperationResult { use openbitfun_product_domains::external_source_control::ExternalSourceOperationStage; let service = if host_capabilities.can_execute_external_assets { - service_for(workspace_root).await + service_for(workspace_id).await } else { - read_only_service_for(workspace_root).await + read_only_service_for(workspace_id).await } .map_err(|error| { sanitize_external_source_operation_error(error) @@ -7373,13 +7414,13 @@ pub async fn get_external_source_control_snapshot( } pub async fn apply_external_source_control_action( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, request: ExternalSourceControlRequestV1, ) -> ExternalSourceOperationResult { use openbitfun_product_domains::external_source_control::ExternalSourceOperationStage; let operation_id = request.operation_id.clone(); - let service = service_for(workspace_root).await.map_err(|error| { + let service = service_for(workspace_id).await.map_err(|error| { typed_control_operation_error( error, &operation_id, @@ -7392,10 +7433,10 @@ pub async fn apply_external_source_control_action( /// Returns a static, sanitized projection for Hosts that may inspect external /// configuration but must never load external code or alter runtime routes. pub async fn external_source_read_only_snapshot( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, force_refresh: bool, ) -> Result { - let service = read_only_service_for(workspace_root).await?; + let service = read_only_service_for(workspace_id).await?; let snapshot = if force_refresh { service.refresh().await? } else { @@ -7408,15 +7449,12 @@ pub async fn external_source_read_only_snapshot( } pub async fn external_source_discovery_snapshot( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, force_refresh: bool, host_capabilities: ExternalSourceHostCapabilities, ) -> Result { - let service = service_for_profile( - workspace_root, - ExternalSourceServiceProfile::DiscoveryCatalog, - ) - .await?; + let service = + service_for_profile(workspace_id, ExternalSourceServiceProfile::DiscoveryCatalog).await?; if force_refresh { service.refresh().await?; } else { @@ -7431,7 +7469,7 @@ pub async fn external_source_discovery_snapshot( .is_some_and(|document| { automatic_discovery_enabled( document, - workspace_policy_key(service.workspace_root.as_deref()).as_deref(), + workspace_policy_key(service.workspace_id.as_deref()).as_deref(), ) }); let has_scanned = service.initial_refresh_completed.load(Ordering::Acquire); @@ -7463,7 +7501,7 @@ pub async fn external_source_discovery_snapshot( let mut catalog = ExternalSourcePublicSnapshot::from(service.snapshot()); catalog.host_capabilities = ExternalSourceHostCapabilities::read_only_projection(); catalog.integration_policy = - integration_policy_snapshot(&preferences, service.workspace_root.as_deref())?; + integration_policy_snapshot(&preferences, service.workspace_id.as_deref())?; catalog.preference_revision = preferences.preference_revision; catalog.discovery_pending = service.background_refresh_scheduled.load(Ordering::Acquire) || service.refresh_gate.try_lock().is_err() @@ -7485,7 +7523,7 @@ pub async fn external_source_discovery_snapshot( } pub async fn update_external_integration_policy( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, mutation: ExternalIntegrationPolicyMutation, ) -> Result { let expected_revision = mutation.expected_preference_revision; @@ -7495,17 +7533,15 @@ pub async fn update_external_integration_policy( ExternalIntegrationPolicyOperation::SetAutomaticDiscovery { .. } ) { async { - persist_integration_policy_mutation(workspace_root, mutation).await?; - let service = service_for_profile( - workspace_root, - ExternalSourceServiceProfile::DiscoveryCatalog, - ) - .await?; + persist_integration_policy_mutation(workspace_id, mutation).await?; + let service = + service_for_profile(workspace_id, ExternalSourceServiceProfile::DiscoveryCatalog) + .await?; service.ensure_initial_refresh().await } .await } else { - match service_for(workspace_root).await { + match service_for(workspace_id).await { Ok(service) => service.update_integration_policy(mutation).await, Err(error) => Err(error), } @@ -7591,7 +7627,7 @@ fn safe_external_log_token(value: &str) -> String { .collect() } -fn external_log_scope(workspace_root: Option<&Path>) -> &'static str { +fn external_log_scope(workspace_root: Option) -> &'static str { if workspace_root.is_some() { "workspace" } else { @@ -7712,17 +7748,17 @@ pub(crate) fn external_integration_error_code(error: &str) -> String { /// This is the shared discovery gate for selectors and execution routing; it /// does not start or recover any executable extension runtime. pub async fn ensure_external_source_workspace_snapshot( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result<(), String> { - ensure_initial_external_source_workspace_service(workspace_root) + ensure_initial_external_source_workspace_service(workspace_id) .await .map(|_| ()) } async fn ensure_initial_external_source_workspace_service( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result, String> { - let service = service_for(workspace_root).await?; + let service = service_for(workspace_id).await?; service.ensure_initial_refresh().await?; Ok(service) } @@ -7732,23 +7768,23 @@ async fn ensure_initial_external_source_workspace_service( /// idle-retired workspace can restore approved routes before the catalog is /// exposed to the model. Existing services are only touched; file watchers and /// explicit refreshes remain responsible for later source changes. -pub(crate) async fn ensure_external_source_workspace_runtime(workspace_root: Option<&Path>) { - let service = match ensure_initial_external_source_workspace_service(workspace_root).await { +pub(crate) async fn ensure_external_source_workspace_runtime(workspace_id: Option<&str>) { + let service = match ensure_initial_external_source_workspace_service(workspace_id).await { Ok(service) => service, Err(error) => { log::warn!( "Could not initialize external source workspace runtime scope={} error_category={}", - external_log_scope(workspace_root), + external_log_scope(workspace_id), external_log_error_category(&error), ); return; } }; - if external_tool_workspace_requires_recovery(workspace_root).await { + if external_tool_workspace_requires_recovery(workspace_id).await { if let Err(error) = service.refresh_worker_loss_once().await { log::warn!( "Could not recover external source tool runtime scope={} error_category={}", - external_log_scope(workspace_root), + external_log_scope(workspace_id), external_log_error_category(&error), ); } @@ -7756,19 +7792,19 @@ pub(crate) async fn ensure_external_source_workspace_runtime(workspace_root: Opt } pub async fn set_external_source_enabled( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, source_key: &str, enabled: bool, expected_preference_revision: u64, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .set_source_enabled(source_key, enabled, expected_preference_revision) .await } pub async fn expand_external_prompt_command( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, name: &str, arguments: &str, native_commands: Vec, @@ -7778,7 +7814,7 @@ pub async fn expand_external_prompt_command( expected_preference_revision: Option, shell_review_decision: Option<&PromptCommandShellReviewDecision>, ) -> Result { - service_for(workspace_root) + service_for(workspace_id) .await? .expand_command( name, @@ -7794,9 +7830,9 @@ pub async fn expand_external_prompt_command( } pub async fn subscribe_external_source_updates( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Result { - let service = service_for(workspace_root).await?; + let service = service_for(workspace_id).await?; let receiver = service.updates.subscribe(); service.ensure_background_refresh(); Ok(ExternalSourceSubscription { @@ -9012,7 +9048,7 @@ mod tests { assert!(!category.contains("opencode")); } assert_eq!(external_log_scope(Some(Path::new("C:/repo"))), "workspace"); - assert_eq!(external_log_scope(None), "user-global"); + assert_eq!(external_log_scope::<&str>(None), "user-global"); } #[test] @@ -9359,6 +9395,7 @@ mod tests { integration_policy_snapshot(&ExternalSourcesConfig::default(), None) .expect("built-in integration policy is valid"); Arc::new(WorkspaceExternalSourceService { + workspace_id: None, profile: ExternalSourceServiceProfile::LocalExecution, workspace_root: None, execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).unwrap(), @@ -9629,6 +9666,7 @@ mod tests { discovery_calls, )]); let service_inner = Arc::get_mut(&mut service).expect("test owns the service"); + service_inner.workspace_id = Some(uuid::Uuid::new_v4().to_string()); service_inner.workspace_root = Some(workspace.path().to_path_buf()); service_inner.mcp_runtime = runtime.clone(); @@ -9684,6 +9722,7 @@ mod tests { Arc::get_mut(&mut service) .expect("test owns the service") .workspace_root = Some(workspace.path().to_path_buf()); + Arc::get_mut(&mut service).unwrap().workspace_id = Some(uuid::Uuid::new_v4().to_string()); let revision = read_external_sources_config() .await .unwrap() @@ -10203,7 +10242,7 @@ mod tests { #[test] fn integration_policy_mutations_share_revision_and_keep_workspace_paths_private() { let temp = tempfile::tempdir().unwrap(); - let workspace_key = workspace_policy_key(Some(temp.path())).expect("workspace has a key"); + let workspace_key = workspace_policy_key(Some("workspace-1")).expect("workspace has a key"); assert!(workspace_key.starts_with("workspace:")); assert!(!workspace_key.contains(&temp.path().to_string_lossy().to_string())); diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index a24acc3631..05ddd98d61 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -142,13 +142,15 @@ struct ResolvedModelRequest { } pub(super) async fn reconcile_external_subagents( + workspace_id: Option<&str>, workspace_root: Option<&Path>, execution_domain_id: &str, snapshot: &ExternalSubagentCoordinatorSnapshot, decisions: ExternalSubagentDecisions<'_>, ) -> ExternalSubagentProductState { - let facts = gather_product_facts(workspace_root, &snapshot.definitions).await; + let facts = gather_product_facts(workspace_id, &snapshot.definitions).await; reconcile_with_facts( + workspace_id, workspace_root, execution_domain_id, snapshot, @@ -161,6 +163,7 @@ pub(super) async fn reconcile_external_subagents( /// the tool registry, or the agent registry, and it never produces routes or /// runtime registrations. pub(super) fn project_external_subagents_read_only( + workspace_id: Option<&str>, workspace_root: Option<&Path>, execution_domain_id: &str, snapshot: &ExternalSubagentCoordinatorSnapshot, @@ -175,6 +178,7 @@ pub(super) fn project_external_subagents_read_only( let mut state = ExternalSubagentProductState::default(); for definition in &snapshot.definitions { let resolved = resolve_external_candidate( + workspace_id, workspace_root, execution_domain_id, definition, @@ -218,7 +222,7 @@ pub(super) fn project_external_subagents_read_only( } async fn gather_product_facts( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, definitions: &[ExternalSubagentDefinition], ) -> ProductFacts { let ai_config = match GlobalConfigManager::get_service().await { @@ -262,7 +266,7 @@ async fn gather_product_facts( if !requested_names.contains(&name) { continue; } - let Some(selected) = resolve_external_tool_for_workspace(registered, workspace_root) else { + let Some(selected) = resolve_external_tool_for_workspace(registered, workspace_id) else { continue; }; if !selected.is_enabled().await { @@ -295,7 +299,7 @@ async fn gather_product_facts( let registry = get_agent_registry(); let mut locals = BTreeMap::new(); for info in registry - .get_local_agents_for_external_resolution(workspace_root) + .get_local_agents_for_external_resolution(workspace_id) .await { let logical_key = normalize_logical_id(&info.id); @@ -312,7 +316,7 @@ async fn gather_product_facts( (None, _) => "main-agent-profile".to_string(), (Some(_), Some(ai_config)) => { let model_selection = registry - .get_explicit_subagent_model_selection(&info.id, workspace_root) + .get_explicit_subagent_model_selection(&info.id, workspace_id) .unwrap_or_else(|| { ai_config .agent_model_defaults @@ -714,13 +718,14 @@ fn resolve_model_request( } fn reconcile_with_facts( + workspace_id: Option<&str>, workspace_root: Option<&Path>, execution_domain_id: &str, snapshot: &ExternalSubagentCoordinatorSnapshot, decisions: ExternalSubagentDecisions<'_>, facts: &ProductFacts, ) -> ExternalSubagentProductState { - let workspace_scope = workspace_scope_key(workspace_root); + let workspace_scope = workspace_scope_key(workspace_id); let source_map = snapshot .sources .iter() @@ -732,6 +737,7 @@ fn reconcile_with_facts( for definition in &snapshot.definitions { let resolved = resolve_external_candidate( + workspace_id, workspace_root, execution_domain_id, definition, @@ -953,6 +959,7 @@ fn finalized_model_binding_groups( } fn resolve_external_candidate( + workspace_id: Option<&str>, workspace_root: Option<&Path>, execution_domain_id: &str, definition: &ExternalSubagentDefinition, @@ -1008,7 +1015,7 @@ fn resolve_external_candidate( definition, ecosystem_id.as_ref(), scope, - &workspace_scope_key(workspace_root), + &workspace_scope_key(workspace_id), execution_domain_id, facts.ai_config.as_ref(), model_bindings, @@ -1427,13 +1434,8 @@ fn has_runtime_unavailable_diagnostic(candidate: &ResolvedExternalCandidate) -> }) } -fn workspace_scope_key(workspace_root: Option<&Path>) -> String { - let normalized = workspace_route_key(workspace_root).replace('\\', "/"); - if cfg!(windows) { - normalized.to_ascii_lowercase() - } else { - normalized - } +fn workspace_scope_key(workspace_id: Option<&str>) -> String { + workspace_route_key(workspace_id) } fn normalize_logical_id(value: &str) -> String { @@ -1736,6 +1738,7 @@ mod tests { let empty_bindings = BTreeMap::new(); let unbound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1763,6 +1766,7 @@ mod tests { ExternalSubagentModelBindingTarget::Fast, )]); let bound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1789,6 +1793,7 @@ mod tests { definition_snapshot.definitions[0].requested_model = ExternalSubagentModelRequest::Default; let default_unbound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1862,6 +1867,7 @@ mod tests { let product_facts = facts(); let unbound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1885,6 +1891,7 @@ mod tests { ExternalSubagentModelBindingTarget::Fast, )]); let bound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1918,6 +1925,7 @@ mod tests { let empty_map = BTreeMap::new(); let state = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -1959,6 +1967,7 @@ mod tests { let empty_bindings = BTreeMap::new(); let unbound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -1995,6 +2004,7 @@ mod tests { let inactive_ecosystems = BTreeSet::new(); let inactive = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2016,6 +2026,7 @@ mod tests { ExternalSubagentModelBindingTarget::Primary, )]); let bound = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2056,6 +2067,7 @@ mod tests { }, )]); let unavailable = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2109,6 +2121,7 @@ mod tests { }; let first = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo-a")), "local-user", &definition_snapshot, @@ -2122,7 +2135,8 @@ mod tests { ); let second = reconcile_with_facts( - Some(Path::new("C:/repo-b")), + Some("workspace-2"), + Some(Path::new("C:/repo-a")), "local-user", &definition_snapshot, ExternalSubagentDecisions { @@ -2149,6 +2163,7 @@ mod tests { let empty_decisions = BTreeMap::new(); let empty_bindings = BTreeMap::new(); let preview = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2172,6 +2187,7 @@ mod tests { let approved = BTreeSet::from([preview.summaries[0].decision_key.clone()]); let active = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2243,6 +2259,7 @@ mod tests { let definition_snapshot = snapshot("behavior-v1", "catalog-v1"); let healthy_facts = facts(); let preview = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2261,6 +2278,7 @@ mod tests { unavailable_facts.ai_config = None; let state = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2295,6 +2313,7 @@ mod tests { assert!(state.registrations.is_empty()); let recovered = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2328,6 +2347,7 @@ mod tests { }; let state = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &definition_snapshot, @@ -2391,6 +2411,7 @@ mod tests { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let preview = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &first, @@ -2408,6 +2429,7 @@ mod tests { let approved = BTreeSet::from([approval]); let updated = snapshot("behavior-v1", "catalog-v2"); let state = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &updated, @@ -2437,6 +2459,7 @@ mod tests { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let first = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2452,6 +2475,7 @@ mod tests { ); let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); let updated = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v2", "catalog-v2"), @@ -2477,6 +2501,7 @@ mod tests { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let first = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2497,6 +2522,7 @@ mod tests { ); let updated = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &tightened_snapshot, @@ -2519,6 +2545,7 @@ mod tests { let reapproved = BTreeSet::from([updated.summaries[0].decision_key.clone()]); let activated = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &tightened_snapshot, @@ -2550,6 +2577,7 @@ mod tests { let empty_map = BTreeMap::new(); let first_facts = facts(); let first = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2575,6 +2603,7 @@ mod tests { )]; updated_config.default_models.fast = Some("model_new".to_string()); let updated = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2610,6 +2639,7 @@ mod tests { let empty_map = BTreeMap::new(); let first_facts = facts(); let first = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2631,6 +2661,7 @@ mod tests { updated_config.models[0].model_name = "replacement-model".to_string(); updated_config.models[0].base_url = "https://models.example/v2".to_string(); let updated = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2674,6 +2705,7 @@ mod tests { .default_selection = SubagentModelSelection::Inherit; let state = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2712,6 +2744,7 @@ mod tests { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let preview = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2745,6 +2778,7 @@ mod tests { let choices = BTreeMap::from([(preview.conflicts[0].conflict_key.clone(), external_id)]); let approved = BTreeSet::from([approval]); let active = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2787,6 +2821,7 @@ mod tests { let healthy_ai_config = product_facts.ai_config.take(); let unavailable = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2819,6 +2854,7 @@ mod tests { product_facts.ai_config = healthy_ai_config; let recovered = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2872,6 +2908,7 @@ mod tests { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let preview = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2900,6 +2937,7 @@ mod tests { let empty_map = BTreeMap::new(); let shrunk = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &snapshot("behavior-v1", "catalog-v1"), @@ -2940,6 +2978,7 @@ mod tests { without_external.definitions.clear(); let shrunk = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &without_external, @@ -2975,6 +3014,7 @@ mod tests { without_external.definitions.clear(); let shrunk = reconcile_with_facts( + Some("workspace-1"), Some(Path::new("C:/repo")), "local-user", &without_external, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index b1f842c11f..bab744d4f0 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -25,6 +25,7 @@ use openbitfun_services_integrations::script_tool::NodeScriptToolRuntime; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap}; +#[cfg(all(test, feature = "opencode-plugin-host"))] use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}; @@ -514,7 +515,7 @@ impl ExternalToolMux { // a local route solely because the remote path text matches. return self.original(); } - let workspace_key = workspace_route_key(context.and_then(ToolUseContext::workspace_root)); + let workspace_key = workspace_route_key(context.and_then(ToolUseContext::workspace_id)); match self .routes .read() @@ -545,8 +546,8 @@ impl ExternalToolMux { } } - fn selected_for_workspace(&self, workspace_root: Option<&Path>) -> Option> { - let workspace_key = workspace_route_key(workspace_root); + fn selected_for_workspace(&self, workspace_id: Option<&str>) -> Option> { + let workspace_key = workspace_route_key(workspace_id); match self .routes .read() @@ -708,7 +709,7 @@ impl Tool for ExternalToolMux { .call(input, context) .await; } - let workspace_key = workspace_route_key(context.workspace_root()); + let workspace_key = workspace_route_key(context.workspace_id()); let route = self .routes .read() @@ -1137,7 +1138,7 @@ impl ExternalToolRouter { fn resolve_registered_tool( &self, tool: Arc, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option> { let mux = self .muxes @@ -1146,7 +1147,7 @@ impl ExternalToolRouter { .get(tool.name()) .cloned(); match mux { - Some(mux) => mux.selected_for_workspace(workspace_root), + Some(mux) => mux.selected_for_workspace(workspace_id), None => Some(tool), } } @@ -1190,13 +1191,13 @@ pub(crate) fn intercept_external_tool_registry_registration(tool: Arc) } pub(crate) async fn register_live_external_tool_candidate( - workspace_root: &Path, + workspace_id: &str, tool: Arc, provider_id: &str, content_version: String, native_agent_visible: bool, ) { - let workspace_key = workspace_route_key(Some(workspace_root)); + let workspace_key = workspace_route_key(Some(workspace_id)); router().register_live_candidate( &workspace_key, tool.clone(), @@ -1212,11 +1213,11 @@ pub(crate) async fn register_live_external_tool_candidate( } pub(crate) async fn unregister_live_external_tool_candidate( - workspace_root: &Path, + workspace_id: &str, name: &str, provider_id: &str, ) { - let workspace_key = workspace_route_key(Some(workspace_root)); + let workspace_key = workspace_route_key(Some(workspace_id)); router().unregister_live_candidate(&workspace_key, name, provider_id); router() .withdraw_live_candidate_route(&workspace_key, name) @@ -1234,9 +1235,9 @@ pub(crate) fn retain_external_tool_muxes_for_prefix(prefix: &str) -> Vec, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> Option> { - router().resolve_registered_tool(tool, workspace_root) + router().resolve_registered_tool(tool, workspace_id) } pub(crate) fn resolve_external_tool_for_context( @@ -1248,16 +1249,16 @@ pub(crate) fn resolve_external_tool_for_context( #[cfg(test)] pub(crate) fn external_tool_route_root( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, is_remote: bool, -) -> Option<&Path> { +) -> Option<&str> { if is_remote { // External-source workspace roots must be absolute. This deliberately // non-routable key selects only the displaced local implementation // while remote Tool Runtime ownership is unavailable. - Some(Path::new("\0")) + Some("") } else { - workspace_root + workspace_id } } @@ -1602,25 +1603,23 @@ async fn report_external_tool_worker_lost( crate::external_sources::notify_external_tool_registry_changed(); } -pub(super) async fn external_tool_workspace_requires_recovery( - workspace_root: Option<&Path>, -) -> bool { +pub(super) async fn external_tool_workspace_requires_recovery(workspace_id: Option<&str>) -> bool { runtime_manager() - .workspace_requires_recovery(&workspace_route_key(workspace_root)) + .workspace_requires_recovery(&workspace_route_key(workspace_id)) .await } pub(super) async fn begin_external_tool_workspace_recovery( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, ) -> BTreeSet { runtime_manager() - .begin_workspace_recovery(&workspace_route_key(workspace_root)) + .begin_workspace_recovery(&workspace_route_key(workspace_id)) .await } -pub(super) async fn reset_external_tool_workspace_recovery_budget(workspace_root: Option<&Path>) { +pub(super) async fn reset_external_tool_workspace_recovery_budget(workspace_id: Option<&str>) { runtime_manager() - .reset_workspace_recovery_budget(&workspace_route_key(workspace_root)) + .reset_workspace_recovery_budget(&workspace_route_key(workspace_id)) .await; } @@ -1628,20 +1627,20 @@ pub(super) async fn invalidate_external_tool_runtime_availability() { runtime_manager().invalidate_availability().await; } -pub(super) async fn release_external_tool_workspace(workspace_root: Option<&Path>) { - let workspace_key = workspace_route_key(workspace_root); +pub(super) async fn release_external_tool_workspace(workspace_id: Option<&str>) { + let workspace_key = workspace_route_key(workspace_id); router().apply_routes(&workspace_key, BTreeMap::new()).await; runtime_manager().release_workspace(&workspace_key).await; } pub(super) async fn reconcile_external_tools( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, execution_domain_id: &str, control_plane: &Arc, decisions: ExternalToolDecisions<'_>, worker_recovery_targets: &BTreeSet, ) -> ExternalToolProductState { - let workspace_key = workspace_route_key(workspace_root); + let workspace_key = workspace_route_key(workspace_id); let snapshot = control_plane.tools(|coordinator| coordinator.snapshot()); let mut state = ExternalToolProductState::default(); let mut live_candidates_by_name = router().live_candidates(&workspace_key); @@ -2530,7 +2529,7 @@ mod tests { crate::agentic::tools::tool_context_runtime::build_tool_description_context( runtime_agent_key, Some(&crate::agentic::WorkspaceBinding::new( - None, + Some("test-workspace".into()), workspace_root.to_path_buf(), )), None, @@ -2680,7 +2679,7 @@ mod tests { }); let mux = ExternalToolMux::new(tool_name, Some(original.clone())); mux.set_route( - workspace_route_key(Some(&workspace)), + workspace_route_key(Some("test-workspace")), WorkspaceRoute::Live { tool: plugin.clone(), native_agent_visible: false, @@ -2731,7 +2730,7 @@ mod tests { }); let mux = Arc::new(ExternalToolMux::new(tool_name, None)); mux.set_route( - workspace_route_key(Some(&workspace)), + workspace_route_key(Some("test-workspace")), WorkspaceRoute::Live { tool: plugin.clone(), native_agent_visible: true, @@ -2768,7 +2767,7 @@ mod tests { }); let mux = ExternalToolMux::new(tool_name, Some(original.clone())); mux.set_route( - workspace_route_key(Some(&workspace)), + workspace_route_key(Some("test-workspace")), WorkspaceRoute::Live { tool: plugin.clone(), native_agent_visible: true, @@ -2795,7 +2794,7 @@ mod tests { #[tokio::test] async fn initial_tool_only_plugin_collision_keeps_native_tool() { let workspace = std::env::current_dir().expect("absolute workspace"); - let workspace_key = workspace_route_key(Some(&workspace)); + let workspace_key = workspace_route_key(Some("test-workspace")); let tool_name = "initial_plugin_tool_only_conflict_contract".to_string(); let original: Arc = Arc::new(TestTool { name: tool_name.clone(), @@ -2870,7 +2869,7 @@ mod tests { }; let directory = tempfile::tempdir().expect("temporary workspace"); - let workspace_key = workspace_route_key(Some(directory.path())); + let workspace_key = workspace_route_key(Some("test-workspace")); let tool_name = format!("live_plugin_conflict_{}", std::process::id()); let original: Arc = Arc::new(TestTool { name: tool_name.clone(), @@ -2910,7 +2909,7 @@ mod tests { let empty_map = BTreeMap::new(); let unresolved = reconcile_external_tools( - Some(directory.path()), + Some("test-workspace"), "test-domain", &control_plane, ExternalToolDecisions { @@ -2935,14 +2934,14 @@ mod tests { .cloned() .expect("installed mux"); assert!(Arc::ptr_eq( - &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &mux.selected_for_workspace(Some("test-workspace")).unwrap(), &original )); let choices = BTreeMap::from([(unresolved.conflicts[0].conflict_key.clone(), candidate_id)]); let selected = reconcile_external_tools( - Some(directory.path()), + Some("test-workspace"), "test-domain", &control_plane, ExternalToolDecisions { @@ -2959,13 +2958,13 @@ mod tests { Some(format!("external:live:opencode-plugin:{tool_name}")) ); assert!(Arc::ptr_eq( - &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &mux.selected_for_workspace(Some("test-workspace")).unwrap(), &live )); router().unregister_live_candidate(&workspace_key, &tool_name, "opencode-plugin"); let withdrawn = reconcile_external_tools( - Some(directory.path()), + Some("test-workspace"), "test-domain", &control_plane, ExternalToolDecisions { @@ -2979,7 +2978,7 @@ mod tests { .await; assert_eq!(withdrawn.conflicts.len(), 1); assert_eq!(withdrawn.conflicts[0].selected_candidate_id, None); - assert!(mux.selected_for_workspace(Some(directory.path())).is_none()); + assert!(mux.selected_for_workspace(Some("test-workspace")).is_none()); router().apply_routes(&workspace_key, BTreeMap::new()).await; router() @@ -3308,7 +3307,7 @@ mod tests { revision: "v1".to_string(), approval_key: "approval".to_string(), source_preference_key: "test:source".to_string(), - workspace_key: workspace_route_key(Some(&root)), + workspace_key: workspace_route_key(Some("test-workspace")), target_tool_names: Arc::new(vec![tool_name.clone()]), worktree_root: None, runtime: Arc::new(NodeScriptToolRuntime::discover()), @@ -3317,7 +3316,7 @@ mod tests { let mux = ExternalToolMux::new(tool_name, Some(original)); assert!(!mux.manages_own_execution_timeout()); mux.set_route( - workspace_route_key(Some(&root)), + workspace_route_key(Some("test-workspace")), WorkspaceRoute::External { tool: external, conflict: None, diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index f620b96943..ea05e40d82 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -573,7 +573,7 @@ impl Default for PathManager { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] impl PathManager { pub(crate) fn with_user_root_for_tests(user_root: PathBuf) -> Self { let base = user_root diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index fb4678e15a..5672b6bb3d 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -317,6 +317,7 @@ pub(crate) fn clear_plugin_hook_workspace(workspace_scope: &str) { /// Everything a dispatch site knows about the running session. #[derive(Debug, Clone, Copy)] pub struct NativeHookSessionFacts<'a> { + pub workspace_id: Option<&'a str>, pub session_id: &'a str, /// Present for turn-scoped events. pub turn_id: Option<&'a str>, @@ -677,18 +678,6 @@ fn deep_review_builtin_registration() -> RuntimeHookRegistration { ) } -fn canonical_hook_workspace_scope(path: &Path) -> Option { - if !path.is_absolute() { - return None; - } - let mut scope = crate::agentic::workspace::canonical_local_workspace_path(path) - .to_string_lossy() - .replace('\\', "/"); - #[cfg(windows)] - scope.make_ascii_lowercase(); - Some(scope) -} - fn command_registration_id( source: RuntimeHookSource, workspace_scope: Option<&str>, @@ -797,6 +786,7 @@ fn publish_command_registrations( /// shared-context measurement) for one successful tool call. Command hooks /// are not dispatched here because the tool pipeline owns post-call context. pub async fn dispatch_successful_tool_post_call( + workspace_id: Option<&str>, workspace_root: Option<&Path>, is_remote: bool, tool_name: &str, @@ -805,7 +795,8 @@ pub async fn dispatch_successful_tool_post_call( agent_type: Option<&str>, ) { let config = hooks_config().await; - let Some(engine) = engine_for(workspace_root, config.project_hooks_enabled).await else { + let Some(engine) = engine_for(workspace_id, workspace_root, config.project_hooks_enabled).await + else { return; }; let call = HookCall { @@ -824,14 +815,7 @@ pub async fn dispatch_successful_tool_post_call( agent_type: agent_type.map(str::to_string), }, }; - let _ = engine - .dispatch_call( - workspace_root - .and_then(canonical_hook_workspace_scope) - .as_deref(), - &call, - ) - .await; + let _ = engine.dispatch_call(workspace_id, &call).await; } struct PreparedDispatch<'a> { @@ -887,10 +871,13 @@ async fn prepare<'a>( report_remote_workspace_skip(&facts, event).await; return None; } - let engine = engine_for(facts.workspace_root, config.project_hooks_enabled).await?; - let workspace_scope = facts - .workspace_root - .and_then(canonical_hook_workspace_scope); + let engine = engine_for( + facts.workspace_id, + facts.workspace_root, + config.project_hooks_enabled, + ) + .await?; + let workspace_scope = facts.workspace_id.map(str::to_owned); if !engine.has_rules_for_workspace(event, workspace_scope.as_deref()) { return None; } @@ -922,7 +909,7 @@ pub(crate) const HOOKS_CONFIG_PATH: &str = "app.hooks"; /// the remote root to look for it would be exactly the local read this skip /// exists to prevent. async fn report_remote_workspace_skip(facts: &NativeHookSessionFacts<'_>, event: AgentHookEvent) { - let configured = match engine_for(None, false).await { + let configured = match engine_for(None, None, false).await { Some(engine) => engine.has_rules_for_workspace(event, None), None => false, }; @@ -1007,12 +994,13 @@ fn fingerprint(path: PathBuf) -> HookFileFingerprint { } struct CachedHookSourceState { + workspace_scope: Option, fingerprints: Vec, project_hooks_enabled: bool, imported_generation: u64, } -type HookSourceCache = tokio::sync::Mutex, CachedHookSourceState>>; +type HookSourceCache = tokio::sync::Mutex, CachedHookSourceState>>; fn hook_source_cache() -> &'static HookSourceCache { static CACHE: OnceLock = OnceLock::new(); @@ -1097,10 +1085,11 @@ pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngi } async fn engine_for( + workspace_id: Option<&str>, workspace_root: Option<&Path>, project_hooks_enabled: bool, ) -> Option { - let key = workspace_root.map(Path::to_path_buf); + let key = workspace_id.map(str::to_owned); let paths = hook_settings_paths(workspace_root, project_hooks_enabled); let fingerprints = paths .iter() @@ -1109,7 +1098,7 @@ async fn engine_for( let imported_generation = { #[cfg(feature = "external-sources")] { - match crate::external_hook_import::imported_hook_generation(workspace_root).await { + match crate::external_hook_import::imported_hook_generation(workspace_id).await { Ok(generation) => generation, Err(error) => { warn!("Imported Hook state is unavailable: {error}"); @@ -1139,7 +1128,7 @@ async fn engine_for( let imported_layers = { #[cfg(feature = "external-sources")] { - match crate::external_hook_import::enabled_imported_hook_layers(workspace_root).await { + match crate::external_hook_import::enabled_imported_hook_layers(workspace_id).await { Ok(layers) => layers, Err(error) => { warn!("Imported Hook layers are unavailable: {error}"); @@ -1161,7 +1150,7 @@ async fn engine_for( for issue in manual_issues.iter().chain(imported_issues.iter()) { warn!("Agent hook configuration issue: {issue}"); } - let workspace_scope = workspace_root.and_then(canonical_hook_workspace_scope); + let workspace_scope = workspace_id.map(str::to_owned); if let Err(error) = publish_command_registrations( &runtime_hook_registry(), workspace_scope.as_deref(), @@ -1175,8 +1164,10 @@ async fn engine_for( if cache.len() >= MAX_CACHED_WORKSPACE_HOOK_SOURCES && !cache.contains_key(&key) { let oldest = cache.keys().next().cloned(); if let Some(oldest) = oldest { - cache.remove(&oldest); - if let Some(scope) = oldest.as_deref().and_then(canonical_hook_workspace_scope) { + if let Some(scope) = cache + .remove(&oldest) + .and_then(|entry| entry.workspace_scope) + { let registry = runtime_hook_registry(); registry.clear_source_workspace(RuntimeHookSource::ProjectCommand, &scope); registry.clear_source_workspace(RuntimeHookSource::ImportedCommand, &scope); @@ -1186,6 +1177,7 @@ async fn engine_for( cache.insert( key, CachedHookSourceState { + workspace_scope, fingerprints, project_hooks_enabled, imported_generation, @@ -1232,6 +1224,7 @@ mod cache_tests { #[test] fn imported_generation_invalidates_the_cached_hook_source_state() { let cached = CachedHookSourceState { + workspace_scope: None, fingerprints: Vec::new(), project_hooks_enabled: false, imported_generation: 7, @@ -1298,15 +1291,33 @@ pub struct NativeHookOverview { /// This is the read-only view behind the CLI `/hooks` command and any other /// surface that needs to show what is configured. It re-reads the files rather /// than consulting the dispatch cache, so it always reflects what is on disk. -pub async fn overview(workspace_root: Option<&Path>) -> NativeHookOverview { - overview_with_facts(workspace_root, false).await +pub async fn overview(workspace_id: Option<&str>) -> Result { + let record = match workspace_id { + Some(id) => Some( + crate::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")? + .require_workspace(id) + .await + .map_err(|error| error.to_string())?, + ), + None => None, + }; + Ok(overview_with_facts( + workspace_id, + record.as_ref().map(|record| record.root_path.as_path()), + record.as_ref().is_some_and(|record| { + record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote + }), + ) + .await) } /// [`overview`] with the session's remote fact. For a remote workspace only /// the host-owned user layer is inspected (the project layer lives on the /// remote host and no controller-local path is derived from the remote root), /// and the result is flagged so surfaces can say the hooks will not run. -pub async fn overview_with_facts( +pub(crate) async fn overview_with_facts( + workspace_id: Option<&str>, workspace_root: Option<&Path>, is_remote_workspace: bool, ) -> NativeHookOverview { @@ -1321,12 +1332,17 @@ pub async fn overview_with_facts( let imported_layers = if config.enabled { #[cfg(feature = "external-sources")] { - crate::external_hook_import::enabled_imported_hook_layers(local_root) - .await - .unwrap_or_default() + crate::external_hook_import::enabled_imported_hook_layers(if is_remote_workspace { + None + } else { + workspace_id + }) + .await + .unwrap_or_default() } #[cfg(not(feature = "external-sources"))] { + let _ = workspace_id; Vec::new() } } else { diff --git a/src/crates/assembly/core/src/native_hooks_tests.rs b/src/crates/assembly/core/src/native_hooks_tests.rs index 584c1f9751..5db1aaafe7 100644 --- a/src/crates/assembly/core/src/native_hooks_tests.rs +++ b/src/crates/assembly/core/src/native_hooks_tests.rs @@ -177,6 +177,7 @@ async fn remote_workspaces_skip_hook_dispatch() { // silent. let decision = dispatch_pre_tool_use( NativeHookSessionFacts { + workspace_id: Some("remote-workspace"), session_id: "session-remote", turn_id: Some("turn-1"), workspace_root: Some(Path::new("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/remote/workspace")), @@ -196,14 +197,19 @@ async fn remote_workspaces_skip_hook_dispatch() { // The read-only view carries the same fact, and it never derives a // controller-local project path from the remote root. - let remote = overview_with_facts(Some(Path::new("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/remote/workspace")), true).await; + let remote = overview_with_facts( + Some("remote-workspace"), + Some(Path::new("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/remote/workspace")), + true, + ) + .await; assert!(remote.remote_workspace_unsupported); assert!(remote .files .iter() .all(|file| !file.path.starts_with("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/remote/workspace"))); - let local = overview(Some(Path::new("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/remote/workspace"))).await; + let local = overview(None).await.unwrap(); assert!(!local.remote_workspace_unsupported); } diff --git a/src/crates/assembly/core/src/plugin_capability_publication.rs b/src/crates/assembly/core/src/plugin_capability_publication.rs index eeb6565b69..1720ef62c4 100644 --- a/src/crates/assembly/core/src/plugin_capability_publication.rs +++ b/src/crates/assembly/core/src/plugin_capability_publication.rs @@ -8,7 +8,7 @@ use openbitfun_product_domains::external_subagents::ExternalSubagentMode; use openbitfun_product_domains::plugin_capabilities::{PluginCapabilityProjection, PluginToolRef}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; pub(crate) fn is_agent_runtime_key_for_namespace( @@ -51,14 +51,14 @@ struct PublishedSkillGeneration { workspace_roots: Vec, } -fn skill_generations() -> &'static RwLock> { - static GENERATIONS: OnceLock>> = +fn skill_generations() -> &'static RwLock> { + static GENERATIONS: OnceLock>> = OnceLock::new(); GENERATIONS.get_or_init(|| RwLock::new(HashMap::new())) } pub(crate) struct PluginCapabilityPublicationPlan { - workspace_root: PathBuf, + workspace_id: String, generation_key: String, publication: PluginPublicationIdentity, registrations: Vec, @@ -70,14 +70,12 @@ pub(crate) struct PluginCapabilityPublicationPlan { impl PluginCapabilityPublicationPlan { pub(crate) fn empty( - workspace_root: &Path, + workspace_id: &str, generation_key: &str, publication: PluginPublicationIdentity, ) -> Self { Self { - workspace_root: crate::agentic::workspace::canonical_local_workspace_path( - workspace_root, - ), + workspace_id: workspace_id.to_owned(), generation_key: generation_key.to_string(), publication, registrations: Vec::new(), @@ -104,7 +102,7 @@ impl PluginCapabilityPublicationPlan { pub(crate) fn commit(self) { get_agent_registry().replace_external_subagent_route_overlay( - &self.workspace_root, + &self.workspace_id, &self.publication.route_owner, self.registrations, self.routes, @@ -113,7 +111,7 @@ impl PluginCapabilityPublicationPlan { .write() .expect("plugin skill generation lock poisoned"); generations.insert( - (self.workspace_root, self.publication.route_owner), + (self.workspace_id, self.publication.route_owner), PublishedSkillGeneration { generation_key: self.generation_key, workspace_roots: self.workspace_skill_roots, @@ -122,49 +120,49 @@ impl PluginCapabilityPublicationPlan { } } -pub(crate) fn release_workspace(workspace_root: &Path, route_owner: &str) { - let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); - get_agent_registry().release_external_subagent_route_overlay(&workspace_root, route_owner); +pub(crate) fn release_workspace(workspace_id: &str, route_owner: &str) { + let workspace_id = workspace_id.to_owned(); + get_agent_registry().release_external_subagent_route_overlay(&workspace_id, route_owner); skill_generations() .write() .expect("plugin skill generation lock poisoned") - .remove(&(workspace_root, route_owner.to_string())); + .remove(&(workspace_id, route_owner.to_string())); } pub(crate) fn release_workspace_generation( - workspace_root: &Path, + workspace_id: &str, route_owner: &str, expected_generation_key: &str, ) -> bool { - let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let mut generations = skill_generations() .write() .expect("plugin skill generation lock poisoned"); if generations - .get(&(workspace_root.clone(), route_owner.to_string())) + .get(&(workspace_id.clone(), route_owner.to_string())) .is_none_or(|generation| generation.generation_key != expected_generation_key) { return false; } - get_agent_registry().release_external_subagent_route_overlay(&workspace_root, route_owner); - generations.remove(&(workspace_root, route_owner.to_string())); + get_agent_registry().release_external_subagent_route_overlay(&workspace_id, route_owner); + generations.remove(&(workspace_id, route_owner.to_string())); true } pub(crate) fn skill_roots_for_agent( - workspace_root: Option<&Path>, + workspace_id: Option<&str>, _runtime_agent_key: Option<&str>, ) -> Vec { - let Some(workspace_root) = workspace_root else { + let Some(workspace_id) = workspace_id else { return Vec::new(); }; - let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); let generations = skill_generations() .read() .expect("plugin skill generation lock poisoned"); let mut publications = generations .iter() - .filter(|((root, _), _)| root == &workspace_root) + .filter(|((root, _), _)| root == &workspace_id) .collect::>(); publications.sort_by(|((_, left), _), ((_, right), _)| left.cmp(right)); publications @@ -179,15 +177,15 @@ pub(crate) fn skill_roots_for_agent( } pub(crate) fn prepare( - workspace_root: &Path, + workspace_id: &str, generation_key: &str, publication: PluginPublicationIdentity, projection: PluginCapabilityProjection, ) -> crate::OpenBitFunResult { - let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + let workspace_id = workspace_id.to_owned(); if projection.agents.is_empty() && projection.skill_roots.is_empty() { return Ok(PluginCapabilityPublicationPlan::empty( - &workspace_root, + &workspace_id, generation_key, publication, )); @@ -205,8 +203,7 @@ pub(crate) fn prepare( let mut runtime_agent_keys = BTreeSet::new(); let mut tool_runtime_agent_keys = BTreeMap::>::new(); for projected in projection.agents { - let mut tools = - native_tool_baseline(&projected.logical_id, projected.mode, &workspace_root); + let mut tools = native_tool_baseline(&projected.logical_id, projected.mode, &workspace_id); let permitted_plugin_tools = projected .plugin_tools .iter() @@ -292,7 +289,7 @@ pub(crate) fn prepare( .collect::>(); workspace_skill_roots.sort_by_key(|root| root.precedence); Ok(PluginCapabilityPublicationPlan { - workspace_root, + workspace_id, generation_key: generation_key.to_string(), publication, registrations, @@ -306,10 +303,9 @@ pub(crate) fn prepare( fn native_tool_baseline( logical_id: &str, mode: ExternalSubagentMode, - workspace_root: &Path, + workspace_id: &str, ) -> Vec { - if let Some(local_agent) = - get_agent_registry().get_local_agent(logical_id, Some(workspace_root)) + if let Some(local_agent) = get_agent_registry().get_local_agent(logical_id, Some(workspace_id)) { return local_agent.default_tools(); } @@ -321,8 +317,8 @@ fn native_tool_baseline( } } -pub(crate) fn active_generation_key(workspace_root: &Path, route_owner: &str) -> Option { - let root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); +pub(crate) fn active_generation_key(workspace_id: &str, route_owner: &str) -> Option { + let root = workspace_id.to_owned(); skill_generations() .read() .ok()? @@ -357,42 +353,42 @@ mod tests { #[test] fn generation_scoped_release_never_withdraws_a_replacement() { - let workspace = tempfile::tempdir().expect("workspace"); + let workspace = uuid::Uuid::new_v4().to_string(); PluginCapabilityPublicationPlan::empty( - workspace.path(), + workspace.as_str(), "generation-a", publication("opencode"), ) .commit(); assert!(!release_workspace_generation( - workspace.path(), + workspace.as_str(), OPENCODE_ROUTE_OWNER, "generation-b" )); assert_eq!( - active_generation_key(workspace.path(), OPENCODE_ROUTE_OWNER).as_deref(), + active_generation_key(workspace.as_str(), OPENCODE_ROUTE_OWNER).as_deref(), Some("generation-a") ); assert!(release_workspace_generation( - workspace.path(), + workspace.as_str(), OPENCODE_ROUTE_OWNER, "generation-a" )); assert_eq!( - active_generation_key(workspace.path(), OPENCODE_ROUTE_OWNER), + active_generation_key(workspace.as_str(), OPENCODE_ROUTE_OWNER), None ); } #[test] fn keeps_skill_generations_isolated_by_publication_owner() { - let workspace = tempfile::tempdir().expect("workspace"); + let workspace = uuid::Uuid::new_v4().to_string(); let first_root = tempfile::tempdir().expect("first skill root"); let second_root = tempfile::tempdir().expect("second skill root"); for (ecosystem, root) in [("ecosystem-a", &first_root), ("ecosystem-b", &second_root)] { prepare( - workspace.path(), + workspace.as_str(), &format!("{ecosystem}-generation"), publication(ecosystem), PluginCapabilityProjection { @@ -407,13 +403,13 @@ mod tests { .commit(); } - let roots = skill_roots_for_agent(Some(workspace.path()), None); + let roots = skill_roots_for_agent(Some(workspace.as_str()), None); assert_eq!(roots.len(), 2); - release_workspace(workspace.path(), "ecosystem-a-plugin-config"); - let roots = skill_roots_for_agent(Some(workspace.path()), None); + release_workspace(workspace.as_str(), "ecosystem-a-plugin-config"); + let roots = skill_roots_for_agent(Some(workspace.as_str()), None); assert_eq!(roots.len(), 1); assert_eq!(roots[0].path, second_root.path()); - release_workspace(workspace.path(), "ecosystem-b-plugin-config"); + release_workspace(workspace.as_str(), "ecosystem-b-plugin-config"); } #[test] @@ -435,7 +431,7 @@ mod tests { skill_roots: Vec::new(), }; let plan = prepare( - Path::new("C:/workspace"), + "workspace-publication", "generation-1", publication("opencode"), projection, @@ -479,7 +475,7 @@ mod tests { native_tool_baseline( "cowork", ExternalSubagentMode::Primary, - Path::new("C:/workspace") + "workspace-publication" ), CoworkMode::new().default_tools() ); diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs index 8e41b85575..98d9a6951e 100644 --- a/src/crates/assembly/core/src/plugin_host.rs +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -152,6 +152,7 @@ async fn plugin_host_workspace_lock(scope: &str) -> Arc> { #[derive(Debug, Clone)] pub(crate) struct PluginHostInstance { + pub(crate) workspace_id: String, pub(crate) canonical_directory: String, pub(crate) directory: PathBuf, pub(crate) worktree: PathBuf, @@ -395,15 +396,26 @@ pub(crate) async fn fault_configured_plugin_host_generation( pub async fn ensure_configured_plugin_instance( launch_policy: PluginHostLaunchPolicy, - directory: PathBuf, - worktree: PathBuf, - project_id: Option, + workspace_id: &str, ) -> crate::OpenBitFunResult<()> { use crate::service::config::{get_global_config_service, GlobalConfig}; + let service = crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + crate::OpenBitFunError::Validation("Workspace service is unavailable".into()) + })?; + let workspace = service.require_workspace(workspace_id).await?; + if workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err(crate::OpenBitFunError::NotImplemented( + "Plugin Host is unavailable for remote workspaces".into(), + )); + } + let directory = workspace.root_path; + let worktree = directory.clone(); + let project_id = workspace.id.clone(); + if launch_policy == PluginHostLaunchPolicy::Disabled { - withdraw_configured_plugin_workspace(&directory).await; - clear_configured_plugin_activation_failure(Some(&directory)); + withdraw_configured_plugin_workspace(workspace_id).await; + clear_configured_plugin_activation_failure(Some(workspace_id)); return Ok(()); } if directory.as_os_str().is_empty() || !directory.is_dir() { @@ -419,7 +431,7 @@ pub async fn ensure_configured_plugin_instance( ))) })?; let canonical_directory_string = canonical_directory.to_string_lossy().into_owned(); - let comparable_directory = comparable_instance_directory(&canonical_directory_string); + let comparable_directory = workspace_id.to_owned(); // All ensure/withdraw operations for a workspace use this same lock. The // workspace snapshot, generation replacement, and publication therefore // form one serialized transition for this directory. @@ -432,9 +444,8 @@ pub async fn ensure_configured_plugin_instance( let config_service = get_global_config_service().await?; let global_config: GlobalConfig = config_service.get_config(None).await?; if !global_config.has_configured_plugins() { - withdraw_configured_plugin_workspace_locked(&canonical_directory, &comparable_directory) - .await; - clear_configured_plugin_activation_failure(Some(&canonical_directory)); + withdraw_configured_plugin_workspace_locked(&comparable_directory).await; + clear_configured_plugin_activation_failure(Some(workspace_id)); return Ok(()); } initialize_configured_plugin_host_from_config(launch_policy, None, &global_config).await?; @@ -529,14 +540,6 @@ pub async fn ensure_configured_plugin_instance( "Failed to fingerprint workspace plugin config: {error}" )) })?; - let project_id = project_id - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| { - format!( - "openbitfun-project-{}", - hex::encode(Sha256::digest(canonical_directory_string.as_bytes())) - ) - }); let instance_key = format!( "{comparable_directory}\n{project_id}\n{config_fingerprint}\n{workspace_config_fingerprint}\n{prepared_fingerprint}" ); @@ -552,7 +555,7 @@ pub async fn ensure_configured_plugin_instance( }; if let Some(instance) = reusable_instance { if crate::plugin_capability_publication::active_generation_key( - &canonical_directory, + workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, ) .as_deref() @@ -570,7 +573,7 @@ pub async fn ensure_configured_plugin_instance( ) .map_err(|error| crate::OpenBitFunError::Validation(error.to_string()))?; let publication = crate::plugin_capability_publication::prepare( - &canonical_directory, + workspace_id, &instance.generation_key, opencode_plugin_publication_identity(), projection, @@ -600,7 +603,7 @@ pub async fn ensure_configured_plugin_instance( "Configured plugin Host faulted while retiring a superseded generation".to_string(), )); } - clear_configured_plugin_activation_failure(Some(&canonical_directory)); + clear_configured_plugin_activation_failure(Some(workspace_id)); return Ok(()); } @@ -634,7 +637,8 @@ pub async fn ensure_configured_plugin_instance( ); let now_ms = chrono::Utc::now().timestamp_millis(); let opening_context = PluginHostInstance { - canonical_directory: comparable_directory.clone(), + workspace_id: workspace_id.to_owned(), + canonical_directory: canonical_directory_string.clone(), directory: canonical_directory.clone(), worktree: worktree.clone(), project_id: project_id.clone(), @@ -742,7 +746,7 @@ pub async fn ensure_configured_plugin_instance( .map_err(|error| crate::OpenBitFunError::Validation(error.to_string())); let config_publication = match projected_config.and_then(|projection| { crate::plugin_capability_publication::prepare( - &canonical_directory, + workspace_id, &generation_key, opencode_plugin_publication_identity(), projection, @@ -798,7 +802,7 @@ pub async fn ensure_configured_plugin_instance( client.generation(), &instance_id, &comparable_directory, - &canonical_directory, + workspace_id, &generation_key, &revision, &config_fingerprint, @@ -844,7 +848,7 @@ pub async fn ensure_configured_plugin_instance( } crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( &comparable_directory, - &canonical_directory, + workspace_id, &tool_names, &generation_key, ) @@ -891,7 +895,7 @@ pub async fn ensure_configured_plugin_instance( "Configured plugin Host faulted while retiring a superseded generation".to_string(), )); } - clear_configured_plugin_activation_failure(Some(&canonical_directory)); + clear_configured_plugin_activation_failure(Some(workspace_id)); Ok(()) } @@ -928,7 +932,7 @@ async fn retire_workspace_instances_before_open( let state = instances.lock().await; let keys = state .iter() - .filter(|(_, instance)| instance.canonical_directory == workspace_scope) + .filter(|(_, instance)| instance.workspace_id == workspace_scope) .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) .collect::>(); keys.into_iter() @@ -962,20 +966,19 @@ async fn retire_workspace_instances_before_open( all_closed } -async fn withdraw_configured_plugin_workspace(directory: &Path) { - let Ok(canonical) = dunce::canonicalize(directory) else { - return; - }; - let workspace_scope = comparable_instance_directory(&canonical.to_string_lossy()); +async fn withdraw_configured_plugin_workspace(workspace_scope: &str) { let workspace_lock = plugin_host_workspace_lock(&workspace_scope).await; let _workspace_guard = workspace_lock.lock().await; - withdraw_configured_plugin_workspace_locked(&canonical, &workspace_scope).await; + withdraw_configured_plugin_workspace_locked(&workspace_scope).await; } -async fn withdraw_configured_plugin_workspace_locked(canonical: &Path, workspace_scope: &str) { +async fn withdraw_configured_plugin_workspace_locked(workspace_scope: &str) { let registry = crate::native_hooks::plugin_hook_registry(&workspace_scope); crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); - crate::plugin_capability_publication::release_workspace(canonical, OPENCODE_PLUGIN_ROUTE_OWNER); + crate::plugin_capability_publication::release_workspace( + workspace_scope, + OPENCODE_PLUGIN_ROUTE_OWNER, + ); let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); return; @@ -984,7 +987,7 @@ async fn withdraw_configured_plugin_workspace_locked(canonical: &Path, workspace .lock() .await .iter() - .filter(|(_, instance)| instance.canonical_directory == workspace_scope) + .filter(|(_, instance)| instance.workspace_id == workspace_scope) .map(|(key, instance)| (key.clone(), instance.clone())) .collect::>(); let host_runtime = if let Some(state) = PLUGIN_HOST.get() { @@ -1002,7 +1005,7 @@ async fn withdraw_configured_plugin_workspace_locked(canonical: &Path, workspace } crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( &workspace_scope, - &instance.directory, + &instance.workspace_id, &instance.tool_names, &instance.generation_key, ) @@ -1044,11 +1047,7 @@ async fn withdraw_configured_plugin_workspace_locked(canonical: &Path, workspace crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); } -async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_generation: u64) { - let Ok(canonical) = dunce::canonicalize(directory) else { - return; - }; - let workspace_scope = comparable_instance_directory(&canonical.to_string_lossy()); +async fn withdraw_faulted_plugin_host_generation(workspace_scope: &str, expected_generation: u64) { let workspace_lock = plugin_host_workspace_lock(&workspace_scope).await; let _workspace_guard = workspace_lock.lock().await; let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { @@ -1059,7 +1058,7 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene .await .iter() .filter(|(_, instance)| { - instance.canonical_directory == workspace_scope + instance.workspace_id == workspace_scope && instance.host_generation == expected_generation }) .map(|(key, instance)| (key.clone(), instance.clone())) @@ -1071,20 +1070,20 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene } crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( &workspace_scope, - &instance.directory, + &instance.workspace_id, &instance.tool_names, &instance.generation_key, ) .await; if crate::plugin_capability_publication::active_generation_key( - &instance.directory, + &instance.workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, ) .as_deref() == Some(instance.generation_key.as_str()) { crate::plugin_capability_publication::release_workspace( - &instance.directory, + &instance.workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, ); } @@ -1101,13 +1100,12 @@ async fn withdraw_faulted_plugin_host_generation(directory: &Path, expected_gene } } let has_replacement = instances.lock().await.values().any(|instance| { - instance.canonical_directory == workspace_scope - && instance.host_generation != expected_generation + instance.workspace_id == workspace_scope && instance.host_generation != expected_generation }); if !has_replacement { crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); crate::plugin_capability_publication::release_workspace( - &canonical, + workspace_scope, OPENCODE_PLUGIN_ROUTE_OWNER, ); crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); @@ -1165,7 +1163,7 @@ fn start_plugin_host_health_monitor(client: openbitfun_opencode_plugin_host::Plu .await .values() .filter(|instance| instance.host_generation == client.generation()) - .map(|instance| instance.directory.clone()) + .map(|instance| instance.workspace_id.clone()) .collect::>() }); let Some(workspaces) = workspaces else { return }; @@ -1190,7 +1188,7 @@ async fn retire_superseded_plugin_instances( .await .iter() .filter(|(key, instance)| { - key.as_str() != active_key && instance.canonical_directory == workspace_scope + key.as_str() != active_key && instance.workspace_id == workspace_scope }) .map(|(key, instance)| (key.clone(), instance.clone())) .collect::>(); @@ -1254,7 +1252,7 @@ fn schedule_plugin_instance_retirement( instance.clone() }; if crate::plugin_capability_publication::active_generation_key( - &snapshot.directory, + &snapshot.workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, ) .as_deref() @@ -1280,7 +1278,7 @@ fn schedule_plugin_instance_retirement( matches.then(|| state.remove(&instance_key)).flatten() }; if let Some(instance) = removed { - let workspace_scope = instance.canonical_directory.clone(); + let workspace_scope = instance.workspace_id.clone(); let _ = retire_plugin_instance(&client, runtime.clone(), instance, &workspace_scope) .await; @@ -1305,7 +1303,7 @@ async fn retire_plugin_instance( } crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( workspace_scope, - &instance.directory, + &instance.workspace_id, &instance.tool_names, &instance.generation_key, ) @@ -1330,7 +1328,7 @@ async fn retire_plugin_instance( close_plugin_host_ptys(&instance.instance_id).await; if closed { crate::plugin_capability_publication::release_workspace_generation( - &instance.directory, + &instance.workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, &instance.generation_key, ); @@ -1397,7 +1395,7 @@ pub(crate) async fn plugin_hook_generation_for_agent( .values() .find(|instance| { instance.ready - && instance.canonical_directory == workspace_scope + && instance.workspace_id == workspace_scope && instance .agent_runtime_keys .iter() @@ -1457,7 +1455,7 @@ pub(crate) async fn publish_plugin_host_diagnostic( fn plugin_activation_diagnostic( operation: &str, - workspace: Option<&Path>, + workspace: Option<&str>, error: &str, ) -> BackendDiagnosticEvent { BackendDiagnosticEvent { @@ -1468,8 +1466,7 @@ fn plugin_activation_diagnostic( message: error.to_string(), plugin: None, method: Some(operation.to_string()), - data: workspace - .map(|workspace| serde_json::json!({"workspace": workspace.to_string_lossy()})), + data: workspace.map(|workspace| serde_json::json!({"workspaceId": workspace})), }, } } @@ -1478,7 +1475,7 @@ fn plugin_activation_diagnostic( /// the outcome of the native session operation that triggered it. pub async fn report_configured_plugin_activation_failure( operation: &str, - workspace: Option<&Path>, + workspace: Option<&str>, error: impl std::fmt::Display, ) { let error = error.to_string(); @@ -1487,7 +1484,7 @@ pub async fn report_configured_plugin_activation_failure( "Configured plugin activation failed; continuing with native capabilities: operation={}, workspace={}, error={}", operation, workspace - .map(|path| path.to_string_lossy().into_owned()) + .map(str::to_owned) .unwrap_or_else(|| "".to_string()), error ); @@ -1502,13 +1499,8 @@ pub async fn report_configured_plugin_activation_failure( } } -fn plugin_activation_workspace_key(workspace: Option<&Path>) -> String { - workspace - .map(|path| { - canonical_plugin_workspace_scope(path) - .unwrap_or_else(|| comparable_instance_directory(&path.to_string_lossy())) - }) - .unwrap_or_else(|| "".to_string()) +fn plugin_activation_workspace_key(workspace: Option<&str>) -> String { + crate::agentic::workspace::workspace_route_key(workspace) } fn activation_failure_store() -> &'static std::sync::RwLock> { @@ -1517,7 +1509,7 @@ fn activation_failure_store() -> &'static std::sync::RwLock, + workspace: Option<&str>, error: &str, ) { let mut message = format!("Configured plugin activation failed during {operation}: {error}"); @@ -1537,14 +1529,14 @@ fn record_configured_plugin_activation_failure( .insert(plugin_activation_workspace_key(workspace), message); } -fn clear_configured_plugin_activation_failure(workspace: Option<&Path>) { +fn clear_configured_plugin_activation_failure(workspace: Option<&str>) { activation_failure_store() .write() .expect("plugin activation failure lock poisoned") .remove(&plugin_activation_workspace_key(workspace)); } -pub(crate) fn configured_plugin_activation_failures(workspace: Option<&Path>) -> Vec { +pub(crate) fn configured_plugin_activation_failures(workspace: Option<&str>) -> Vec { let failures = activation_failure_store() .read() .expect("plugin activation failure lock poisoned"); @@ -1742,26 +1734,26 @@ pub async fn shutdown_configured_plugin_host( for instance in instances.values() { if let Some(token) = instance.hook_commit_token.clone() { crate::plugin_hook_bridge::unregister_plugin_hooks( - &crate::native_hooks::plugin_hook_registry(&instance.canonical_directory), - &instance.canonical_directory, + &crate::native_hooks::plugin_hook_registry(&instance.workspace_id), + &instance.workspace_id, token, ); } crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( - &instance.canonical_directory, - &instance.directory, + &instance.workspace_id, + &instance.workspace_id, &instance.tool_names, &instance.generation_key, ) .await; crate::plugin_capability_publication::release_workspace( - &instance.directory, + &instance.workspace_id, OPENCODE_PLUGIN_ROUTE_OWNER, ); } let workspaces = instances .values() - .map(|instance| instance.canonical_directory.clone()) + .map(|instance| instance.workspace_id.clone()) .collect::>(); for workspace in workspaces { let registry = crate::native_hooks::plugin_hook_registry(&workspace); @@ -1795,7 +1787,7 @@ async fn register_plugin_tools( host_generation: u64, instance_id: &str, workspace_scope: &str, - workspace_root: &Path, + workspace_id: &str, generation_key: &str, revision: &str, config_fingerprint: &str, @@ -1846,7 +1838,7 @@ async fn register_plugin_tools( for (registration_id, id, description, parameters, allowed_runtime_agent_keys) in prepared { crate::agentic::tools::plugin_host_tool::register_workspace_tool( workspace_scope, - workspace_root, + workspace_id, runtime.clone(), host_generation, instance_id, @@ -1977,18 +1969,12 @@ fn plugin_config_fingerprint( } fn comparable_instance_directory(directory: &str) -> String { - let mut comparable = directory.replace('\\', "/"); + let comparable = directory.replace('\\', "/"); #[cfg(windows)] - comparable.make_ascii_lowercase(); + let comparable = comparable.to_ascii_lowercase(); comparable } -pub(crate) fn canonical_plugin_workspace_scope(path: &Path) -> Option { - dunce::canonicalize(path) - .ok() - .map(|path| comparable_instance_directory(&path.to_string_lossy())) -} - fn absolutize_existing_entry( entry: PathBuf, spec: PluginHostLaunchSpec, @@ -2121,7 +2107,7 @@ mod tests { fn activation_failure_diagnostic_is_stable_and_workspace_scoped() { let event = plugin_activation_diagnostic( "session creation", - Some(Path::new("C:/workspace/project")), + Some("workspace-project"), "Bun executable was not found", ); @@ -2129,27 +2115,27 @@ mod tests { assert_eq!(event.diagnostic.severity.as_str(), "warning"); assert_eq!(event.diagnostic.method.as_deref(), Some("session creation")); assert_eq!( - event.diagnostic.data.as_ref().unwrap()["workspace"], - "C:/workspace/project" + event.diagnostic.data.as_ref().unwrap()["workspaceId"], + "workspace-project" ); } #[test] fn activation_failure_status_is_workspace_scoped_and_clearable() { - let first = tempfile::tempdir().expect("first workspace"); - let second = tempfile::tempdir().expect("second workspace"); + let first = uuid::Uuid::new_v4().to_string(); + let second = uuid::Uuid::new_v4().to_string(); super::record_configured_plugin_activation_failure( "session creation", - Some(first.path()), + Some(first.as_str()), "Bun executable was not found", ); - let first_status = super::configured_plugin_activation_failures(Some(first.path())); + let first_status = super::configured_plugin_activation_failures(Some(first.as_str())); assert_eq!(first_status.len(), 1); assert!(first_status[0].contains("session creation")); - assert!(super::configured_plugin_activation_failures(Some(second.path())).is_empty()); + assert!(super::configured_plugin_activation_failures(Some(second.as_str())).is_empty()); - super::clear_configured_plugin_activation_failure(Some(first.path())); - assert!(super::configured_plugin_activation_failures(Some(first.path())).is_empty()); + super::clear_configured_plugin_activation_failure(Some(first.as_str())); + assert!(super::configured_plugin_activation_failures(Some(first.as_str())).is_empty()); } } diff --git a/src/crates/assembly/core/src/plugin_host_http_routes.rs b/src/crates/assembly/core/src/plugin_host_http_routes.rs index 016d862f6c..f5a45f1632 100644 --- a/src/crates/assembly/core/src/plugin_host_http_routes.rs +++ b/src/crates/assembly/core/src/plugin_host_http_routes.rs @@ -474,14 +474,14 @@ fn app_log(context: &PluginHostInstance, body: &[u8]) -> RouteResult { async fn agent_list(context: &PluginHostInstance) -> RouteResult { let registry = crate::agentic::agents::get_agent_registry(); let mut entries = registry - .get_modes_info_for_workspace(Some(&context.directory), true) + .get_modes_info_for_workspace(Some(&context.workspace_id), true) .await .into_iter() .map(|agent| (agent, "primary")) .collect::>(); entries.extend( registry - .get_subagents_info(Some(&context.directory)) + .get_subagents_info(Some(&context.workspace_id)) .await .into_iter() .filter(|agent| agent.effective_enabled) @@ -511,7 +511,7 @@ async fn agent_list(context: &PluginHostInstance) -> RouteResult { async fn command_list(context: &PluginHostInstance) -> RouteResult { let snapshot = - crate::external_sources::external_source_snapshot(Some(&context.directory), false) + crate::external_sources::external_source_snapshot(Some(&context.workspace_id), false) .await .map_err(Failure::backend)?; Ok(Value::Array( @@ -558,6 +558,7 @@ mod tests { fn instance(directory: PathBuf, instance_id: &str, project_id: &str) -> PluginHostInstance { let directory = dunce::canonicalize(directory).expect("canonical temporary workspace"); PluginHostInstance { + workspace_id: project_id.to_owned(), canonical_directory: directory.to_string_lossy().into_owned(), directory: directory.clone(), worktree: directory, @@ -826,6 +827,7 @@ mod tests { fn pty_running_states_are_projected_as_running() { for status in ["Starting", "Active", "Orphaned", "Restoring", "Terminating"] { let value = pty_value(&SessionResponse { + workspace_id: Some("workspace-pty".into()), id: "pty-a".to_string(), name: "PTY A".to_string(), shell_type: ShellType::Bash, diff --git a/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs index d148b62917..b745dc12b1 100644 --- a/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs +++ b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs @@ -63,6 +63,17 @@ fn coordinator() -> Result Result { + use openbitfun_runtime_ports::SessionStorePort; + crate::agentic::session::session_store_port::CoreSessionStorePort::default() + .resolve_workspace_storage(&context.workspace_id) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| Failure::backend(error.to_string())) +} + async fn session_metadata( context: &PluginHostInstance, session_id: &str, @@ -72,7 +83,7 @@ async fn session_metadata( let coordinator = coordinator()?; coordinator .get_session_manager() - .load_session_metadata(&context.directory, session_id) + .load_session_metadata(&session_storage_root(context).await?, session_id) .await .map_err(|error| Failure::backend(error.to_string()))? .ok_or_else(|| Failure::not_found("Session was not found in this workspace")) @@ -81,12 +92,12 @@ async fn session_metadata( async fn session_list(context: &PluginHostInstance) -> RouteResult { let coordinator = coordinator()?; let summaries = coordinator - .list_sessions(&context.directory) + .list_sessions(&session_storage_root(context).await?) .await .map_err(|error| Failure::backend(error.to_string()))?; let persistence = coordinator.get_session_manager().persistence_manager(); let metadata = persistence - .list_session_metadata(&context.directory) + .list_session_metadata(&session_storage_root(context).await?) .await .map_err(|error| Failure::backend(error.to_string()))?; let values = metadata @@ -124,6 +135,7 @@ async fn session_create(context: &PluginHostInstance, body: &[u8]) -> RouteResul .unwrap_or_else(|| "OpenCode Plugin Session".to_string()), "Standard".to_string(), crate::agentic::core::SessionConfig { + workspace_id: Some(context.workspace_id.clone()), workspace_path: Some(context.directory.to_string_lossy().into_owned()), project_workspace_path: Some(context.directory.to_string_lossy().into_owned()), ..Default::default() @@ -134,7 +146,7 @@ async fn session_create(context: &PluginHostInstance, body: &[u8]) -> RouteResul .map_err(|error| Failure::backend(error.to_string()))?; let metadata = coordinator .get_session_manager() - .load_session_metadata(&context.directory, &session.session_id) + .load_session_metadata(&session_storage_root(context).await?, &session.session_id) .await .map_err(|error| Failure::backend(error.to_string()))? .ok_or_else(|| Failure::backend("Created session metadata is unavailable"))?; @@ -144,7 +156,7 @@ async fn session_create(context: &PluginHostInstance, body: &[u8]) -> RouteResul async fn session_status(context: &PluginHostInstance) -> RouteResult { let coordinator = coordinator()?; let sessions = coordinator - .list_sessions(&context.directory) + .list_sessions(&session_storage_root(context).await?) .await .map_err(|error| Failure::backend(error.to_string()))?; let statuses = sessions @@ -177,7 +189,7 @@ async fn session_delete(context: &PluginHostInstance, session_id: &str) -> Route .cancel_active_turn_for_session(session_id, std::time::Duration::from_secs(2)) .await; coordinator - .delete_session(&context.directory, session_id) + .delete_session(&session_storage_root(context).await?, session_id) .await .map_err(|error| Failure::backend(error.to_string()))?; Ok(json!(true)) @@ -211,7 +223,7 @@ async fn session_children(context: &PluginHostInstance, session_id: &str) -> Rou session_metadata(context, session_id).await?; let persistence = coordinator()?.get_session_manager().persistence_manager(); let metadata = persistence - .list_session_metadata_including_internal(&context.directory) + .list_session_metadata_including_internal(&session_storage_root(context).await?) .await .map_err(|error| Failure::backend(error.to_string()))?; Ok(Value::Array( @@ -244,7 +256,7 @@ struct SessionForkBody { async fn session_fork(context: &PluginHostInstance, session_id: &str, body: &[u8]) -> RouteResult { let input: SessionForkBody = body_as(body)?; let result = crate::product_runtime::fork_session_for_plugin( - context.directory.clone(), + context.workspace_id.clone(), session_id.to_string(), input.message_id, ) @@ -272,11 +284,11 @@ async fn session_diff( let Some(message_id) = query_first(query, "messageID") else { return Ok(json!([])); }; - let manager = crate::service::snapshot::open_snapshot_manager_for_view(&context.directory) + let manager = crate::service::snapshot::open_snapshot_manager_for_view(&context.workspace_id) .await .map_err(|error| Failure::backend(error.to_string()))?; let turns = coordinator()? - .load_visible_persisted_session_turns(&context.directory, session_id) + .load_visible_persisted_session_turns(&session_storage_root(context).await?, session_id) .await .map_err(|error| Failure::backend(error.to_string()))?; let turn = turns @@ -578,7 +590,7 @@ async fn session_messages( .unwrap_or(100) .clamp(1, 1000); let turns = coordinator()? - .load_visible_persisted_session_turns(&context.directory, session_id) + .load_visible_persisted_session_turns(&session_storage_root(context).await?, session_id) .await .map_err(|error| Failure::backend(error.to_string()))?; let (models, _, catalog) = load_models().await?; @@ -611,7 +623,7 @@ async fn session_message( ) -> RouteResult { let metadata = session_metadata(context, session_id).await?; let turns = coordinator()? - .load_visible_persisted_session_turns(&context.directory, session_id) + .load_visible_persisted_session_turns(&session_storage_root(context).await?, session_id) .await .map_err(|error| Failure::backend(error.to_string()))?; let (models, _, catalog) = load_models().await?; diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 050cc68dc3..357de3d7d3 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -388,65 +388,25 @@ fn validate_session_workspace_identity( Ok(()) } -fn fork_workspace_identity( +async fn fork_workspace_binding( session: &Session, - request: &SessionStoragePathRequest, -) -> OpenBitFunResult> { - fn nonempty(value: Option<&str>) -> Option<&str> { - value.map(str::trim).filter(|value| !value.is_empty()) - } - fn declares_remote(connection_id: Option<&str>, hostname: Option<&str>) -> bool { - connection_id.is_some() - || hostname.is_some_and(|host| { - host != openbitfun_services_core::workspace_identity::LOCAL_WORKSPACE_SSH_HOST - }) - } - let connection_id = nonempty(request.remote_connection_id.as_deref()); - let hostname = nonempty(request.remote_ssh_host.as_deref()); - let config = &session.config; - let request_is_remote = declares_remote(connection_id, hostname); - let (workspace_path, connection_id, hostname) = if request_is_remote { - ( - request.workspace_path.to_string_lossy().into_owned(), - connection_id.or_else(|| nonempty(config.remote_connection_id.as_deref())), - hostname.or_else(|| nonempty(config.remote_ssh_host.as_deref())), - ) - } else { - // Legacy/plugin callers may provide only a resolved Session store or a - // project root (different from a worktree). Its durable execution - // identity remains authoritative; do not reinterpret that path locally. - let Some(workspace_path) = config.workspace_path.clone() else { - if declares_remote( - nonempty(config.remote_connection_id.as_deref()), - nonempty(config.remote_ssh_host.as_deref()), - ) { - return Err(OpenBitFunError::Validation( - "Remote Session fork requires a persisted workspace path".to_string(), - )); - } - return Ok(None); - }; - ( - workspace_path, - nonempty(config.remote_connection_id.as_deref()), - nonempty(config.remote_ssh_host.as_deref()), - ) - }; - let identity = workspace_session_identity(&workspace_path, connection_id, hostname); - if (request_is_remote || declares_remote(connection_id, hostname)) - && identity - .as_ref() - .is_none_or(|identity| identity.remote_connection_id.is_none()) + requested_workspace_id: &str, +) -> OpenBitFunResult { + let mut config = session.config.clone(); + crate::agentic::workspace::normalize_session_workspace(&mut config).await?; + let id = config + .workspace_id + .as_deref() + .ok_or_else(|| OpenBitFunError::Validation("Session workspace ID is required".into()))?; + if requested_workspace_id != id + && config.project_workspace_id.as_deref() != Some(requested_workspace_id) { - return Err(OpenBitFunError::Validation( - "Remote Session fork requires a complete persisted connection and host identity" - .to_string(), - )); - } - if let Some(identity) = identity.as_ref() { - validate_session_workspace_identity(session, identity)?; + return Err(OpenBitFunError::Validation(format!( + "Session workspace identity does not match the requested workspace ID: {}", + session.session_id + ))); } - Ok(identity) + crate::agentic::WorkspaceBinding::resolve(id).await } async fn begin_consistent_persisted_session_read( @@ -585,6 +545,7 @@ fn runtime_lineage_snapshot( .as_ref() .and_then(|value| value.subagent_type.clone()), agent_id: None, + workspace_id: metadata.workspace_id, workspace_path: metadata.workspace_path, remote_connection_id: remote_connection_id.map(str::to_string), remote_ssh_host: remote_ssh_host.map(str::to_string), @@ -635,15 +596,16 @@ fn validate_local_snapshot_workspace(workspace_path: &Path) -> PortResult<()> { Ok(()) } -async fn ensure_local_snapshot_manager(workspace_path: &Path) -> PortResult> { - validate_local_snapshot_workspace(workspace_path)?; - if let Some(manager) = get_snapshot_manager_for_workspace(workspace_path) { +async fn ensure_local_snapshot_manager(workspace_id: &str) -> PortResult> { + let workspace_path = local_snapshot_root(workspace_id).await?; + validate_local_snapshot_workspace(&workspace_path)?; + if let Some(manager) = get_snapshot_manager_for_workspace(workspace_id) { return Ok(manager); } - initialize_snapshot_manager_for_workspace(workspace_path.to_path_buf(), None) + initialize_snapshot_manager_for_workspace(workspace_id, None) .await .map_err(snapshot_initialization_port_error)?; - get_snapshot_manager_for_workspace(workspace_path).ok_or_else(|| { + get_snapshot_manager_for_workspace(workspace_id).ok_or_else(|| { PortError::new( PortErrorKind::Backend, format!( @@ -654,11 +616,10 @@ async fn ensure_local_snapshot_manager(workspace_path: &Path) -> PortResult PortResult> { - validate_local_snapshot_workspace(workspace_path)?; - open_snapshot_manager_for_view(workspace_path) +async fn local_snapshot_manager_for_view(workspace_id: &str) -> PortResult> { + let workspace_path = local_snapshot_root(workspace_id).await?; + validate_local_snapshot_workspace(&workspace_path)?; + open_snapshot_manager_for_view(workspace_id) .await .map_err(snapshot_port_error) } @@ -675,10 +636,30 @@ impl CoreLocalWorkspaceSnapshot { } } +async fn local_snapshot_root(workspace_id: &str) -> PortResult { + let service = crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "Workspace service is unavailable", + ) + })?; + let record = service + .require_workspace(workspace_id) + .await + .map_err(|e| PortError::new(PortErrorKind::InvalidRequest, e.to_string()))?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Snapshot system not supported for remote workspace", + )); + } + Ok(record.root_path) +} + #[async_trait::async_trait] impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { - async fn prepare_local_workspace(&self, workspace_path: PathBuf) -> PortResult<()> { - ensure_local_snapshot_manager(&workspace_path).await?; + async fn prepare_local_workspace(&self, workspace_id: String) -> PortResult<()> { + ensure_local_snapshot_manager(&workspace_id).await?; Ok(()) } @@ -687,7 +668,7 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { request: LocalWorkspaceSnapshotSessionRequest, ) -> PortResult> { validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; - let manager = local_snapshot_manager_for_view(&request.workspace_path).await?; + let manager = local_snapshot_manager_for_view(&request.workspace_id).await?; manager .get_session_files_before(&request.session_id, request.max_turn_exclusive) .await @@ -699,7 +680,7 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { request: LocalWorkspaceSnapshotSessionRequest, ) -> PortResult { validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; - let manager = local_snapshot_manager_for_view(&request.workspace_path).await?; + let manager = local_snapshot_manager_for_view(&request.workspace_id).await?; let stats = manager .get_session_stats_fact_before(&request.session_id, request.max_turn_exclusive) .await @@ -717,7 +698,7 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { request: LocalWorkspaceSnapshotTurnRequest, ) -> PortResult> { validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; - ensure_local_snapshot_manager(&request.workspace_path) + ensure_local_snapshot_manager(&request.workspace_id) .await? .rollback_workspace_files_to_boundary(&request.session_id, request.turn_index) .await @@ -733,7 +714,7 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { pub struct CoreProductAgentRuntime; pub(crate) async fn fork_session_for_plugin( - workspace_path: PathBuf, + workspace_id: String, source_session_id: String, source_message_id: Option, ) -> Result { @@ -763,7 +744,8 @@ pub(crate) async fn fork_session_for_plugin( AgentSessionForkPort::fork_session_at_turn( &operations, AgentSessionForkAtTurnRequest { - workspace_path: workspace_path.to_string_lossy().into_owned(), + workspace_id: Some(workspace_id.clone()), + workspace_path: String::new(), source_session_id, source_turn_id, remote_connection_id: None, @@ -776,7 +758,8 @@ pub(crate) async fn fork_session_for_plugin( None => AgentSessionForkPort::fork_session( &operations, AgentSessionForkRequest { - workspace_path: workspace_path.to_string_lossy().into_owned(), + workspace_id: Some(workspace_id), + workspace_path: String::new(), source_session_id, remote_connection_id: None, remote_ssh_host: None, @@ -2038,21 +2021,33 @@ impl ProductSearchPort for CoreAgentRuntimeCompatibility { &self, request: SessionContentSearchRequest, ) -> PortResult { - if request.workspace_path.trim().is_empty() { - return Err(PortError::new( - PortErrorKind::InvalidRequest, - "Session content search requires a workspace path", - )); - } let limit = request.normalized_limit(); - let storage_path = self - .resolve_persisted_session_storage_path(SessionStoragePathRequest { + let workspace_id = request + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let storage_path = if let Some(workspace_id) = workspace_id { + CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) + .resolve_workspace_storage(workspace_id) + .await? + .effective_storage_path + } else { + // Legacy peers identify the workspace only by path. + if request.workspace_path.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Session content search requires a workspace ID", + )); + } + self.resolve_persisted_session_storage_path(SessionStoragePathRequest { workspace_path: PathBuf::from(request.workspace_path), remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, }) .await - .map_err(runtime_port_error)?; + .map_err(runtime_port_error)? + }; self.search_persisted_session_content( &storage_path, &request.query, @@ -2087,22 +2082,57 @@ impl CoreSessionOperationsPort { } } - async fn resolve_session_storage_path( + /// Session storage for a request that names its workspace by ID, falling + /// back to the legacy path selector only for pre-ID producers. + async fn resolve_session_storage_for_reference( &self, - workspace_path: String, + workspace_id: Option<&str>, + workspace_path: &str, remote_connection_id: Option, remote_ssh_host: Option, ) -> PortResult { CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: PathBuf::from(workspace_path), + .resolve_storage_for_reference( + workspace_id, + workspace_path, remote_connection_id, remote_ssh_host, - }) + ) .await .map(|resolution| resolution.effective_storage_path) } + async fn resolve_fork_workspace_id( + &self, + workspace_id: Option<&str>, + legacy_path: &str, + legacy_connection: Option<&str>, + legacy_host: Option<&str>, + source_session_id: &str, + ) -> PortResult { + let service = + crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + runtime_port_error(OpenBitFunError::service("Workspace service is unavailable")) + })?; + let record = match workspace_id { + Some(id) => service + .require_workspace(id) + .await + .map_err(runtime_port_error)?, + None => service + .resolve_legacy_fork_workspace_reference( + self.coordinator.get_session_manager().as_ref(), + legacy_path, + legacy_connection, + legacy_host, + source_session_id, + ) + .await + .map_err(runtime_port_error)?, + }; + Ok(record.id) + } + async fn validate_lineage_descendant( &self, storage_path: &Path, @@ -2124,7 +2154,7 @@ impl CoreSessionOperationsPort { source_session_id: String, source_turn_id: Option, boundary: SessionBranchBoundary, - workspace_scope: &SessionStoragePathRequest, + workspace_id: &str, ) -> PortResult { if source_turn_id .as_deref() @@ -2149,24 +2179,27 @@ impl CoreSessionOperationsPort { .load_session_header(storage_path, &source_session_id) .await .map_err(runtime_port_error)?; - let identity = - fork_workspace_identity(&persisted, workspace_scope).map_err(runtime_port_error)?; - if let Some(identity) = identity.as_ref() { - if let Some(loaded) = session_manager.get_session(&source_session_id) { - validate_session_workspace_identity(&loaded, identity) - .map_err(runtime_port_error)?; - } - if identity.remote_connection_id.is_some() { - self.coordinator - .ensure_workspace_runtime_ownership( - Path::new(&identity.logical_workspace_path), - identity.remote_connection_id.as_deref(), - Some(&identity.hostname), - ) - .map_err(runtime_port_error)?; - } + let binding = fork_workspace_binding(&persisted, workspace_id) + .await + .map_err(runtime_port_error)?; + let execution_id = binding.workspace_id.as_deref().expect("resolved binding"); + if let Some(loaded) = session_manager.get_session(&source_session_id) { + fork_workspace_binding(&loaded, execution_id) + .await + .map_err(runtime_port_error)?; } - identity + self.coordinator + .ensure_workspace_runtime_ownership( + binding.root_path(), + binding.session_identity.remote_connection_id.as_deref(), + if binding.is_remote() { + Some(binding.session_identity.hostname.as_str()) + } else { + None + }, + ) + .map_err(runtime_port_error)?; + execution_id.to_owned() }; if !session_manager .is_session_loaded_from_storage_path(storage_path, &source_session_id) @@ -2187,19 +2220,20 @@ impl CoreSessionOperationsPort { session_manager .validate_session_storage_path_binding(&source_session_id, storage_path) .map_err(runtime_port_error)?; - if let Some(expected) = expected_workspace.as_ref() { - if let Some(loaded) = session_manager.get_session(&source_session_id) { - validate_session_workspace_identity(&loaded, expected) - .map_err(runtime_port_error)?; - } - let persisted = self - .persistence - .load_session_header(storage_path, &source_session_id) + if let Some(loaded) = session_manager.get_session(&source_session_id) { + fork_workspace_binding(&loaded, &expected_workspace) .await .map_err(runtime_port_error)?; - validate_session_workspace_identity(&persisted, expected) - .map_err(runtime_port_error)?; } + let persisted = self + .persistence + .load_session_header(storage_path, &source_session_id) + .await + .map_err(runtime_port_error)?; + fork_workspace_binding(&persisted, &expected_workspace) + .await + .map_err(runtime_port_error)?; + self.coordinator .reconcile_session_revert_locked(storage_path, &source_session_id) .await @@ -2379,13 +2413,31 @@ impl AgentSessionLineagePort for CoreSessionOperationsPort { request: AgentSessionLineageRequest, ) -> PortResult> { validate_persisted_session_id(&request.anchor_session_id).map_err(runtime_port_error)?; - let storage_path = self - .resolve_session_storage_path( - request.workspace_path, - request.remote_connection_id.clone(), - request.remote_ssh_host.clone(), + let service = + crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "Workspace service is unavailable", + ) + })?; + // Upgrade-only ingress: convert old wire payloads once, then route by ID. + let workspace = service + .resolve_legacy_workspace_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), ) - .await?; + .await + .map_err(runtime_port_error)? + .ok_or_else(|| { + PortError::new(PortErrorKind::InvalidRequest, "Workspace ID is unavailable") + })?; + let resolution = + CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) + .resolve_workspace_storage(&workspace.id) + .await?; + let storage_path = resolution.effective_storage_path; let metadata = self .persistence .list_session_metadata_including_internal(&storage_path) @@ -2397,8 +2449,8 @@ impl AgentSessionLineagePort for CoreSessionOperationsPort { }; let mut snapshot = runtime_lineage_snapshot( snapshot, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), + resolution.remote_connection_id.as_deref(), + resolution.remote_ssh_host.as_deref(), ); let session_manager = self.coordinator.get_session_manager(); for entry in &mut snapshot.sessions { @@ -2424,8 +2476,9 @@ impl AgentSessionLineagePort for CoreSessionOperationsPort { validate_persisted_session_id(&request.root_session_id).map_err(runtime_port_error)?; validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; let storage_path = self - .resolve_session_storage_path( - request.workspace_path, + .resolve_session_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, request.remote_connection_id, request.remote_ssh_host, ) @@ -2475,6 +2528,7 @@ impl AgentSessionLineagePort for CoreSessionOperationsPort { request: AgentSessionLineageCancellationRequest, ) -> PortResult { let AgentSessionLineageCancellationRequest { + workspace_id, workspace_path, root_session_id, session_id, @@ -2490,7 +2544,12 @@ impl AgentSessionLineagePort for CoreSessionOperationsPort { let deadline = Instant::now() + wait_timeout; let storage_path = match tokio::time::timeout(wait_timeout, async { let storage_path = self - .resolve_session_storage_path(workspace_path, remote_connection_id, remote_ssh_host) + .resolve_session_storage_for_reference( + workspace_id.as_deref(), + &workspace_path, + remote_connection_id, + remote_ssh_host, + ) .await?; self.validate_lineage_descendant(&storage_path, &root_session_id, &session_id) .await?; @@ -2530,99 +2589,80 @@ impl AgentSessionForkPort for CoreSessionOperationsPort { &self, request: AgentSessionForkRequest, ) -> PortResult { - let AgentSessionForkRequest { - workspace_path, - source_session_id, - remote_connection_id, - remote_ssh_host, - } = request; - let workspace_scope = SessionStoragePathRequest { - workspace_path: PathBuf::from(&workspace_path), - remote_connection_id: remote_connection_id.clone(), - remote_ssh_host: remote_ssh_host.clone(), - }; - self.coordinator - .ensure_workspace_runtime_ownership( - Path::new(&workspace_path), - remote_connection_id.as_deref(), - remote_ssh_host.as_deref(), + let workspace_id = self + .resolve_fork_workspace_id( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + &request.source_session_id, ) - .map_err(runtime_port_error)?; - let storage_path = self - .resolve_session_storage_path(workspace_path, remote_connection_id, remote_ssh_host) .await?; + let storage_path = + CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) + .resolve_workspace_storage(&workspace_id) + .await? + .effective_storage_path; self.fork_at_persisted_turn( &storage_path, - source_session_id, + request.source_session_id, None, SessionBranchBoundary::ThroughTurn, - &workspace_scope, + &workspace_id, ) .await } - async fn fork_session_at_turn( &self, request: AgentSessionForkAtTurnRequest, ) -> PortResult { - let workspace_scope = SessionStoragePathRequest { - workspace_path: PathBuf::from(&request.workspace_path), - remote_connection_id: request.remote_connection_id.clone(), - remote_ssh_host: request.remote_ssh_host.clone(), - }; - self.coordinator - .ensure_workspace_runtime_ownership( - Path::new(&request.workspace_path), + let workspace_id = self + .resolve_fork_workspace_id( + request.workspace_id.as_deref(), + &request.workspace_path, request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), - ) - .map_err(runtime_port_error)?; - let storage_path = self - .resolve_session_storage_path( - request.workspace_path, - request.remote_connection_id, - request.remote_ssh_host, + &request.source_session_id, ) .await?; + let storage_path = + CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) + .resolve_workspace_storage(&workspace_id) + .await? + .effective_storage_path; self.fork_at_persisted_turn( &storage_path, request.source_session_id, Some(request.source_turn_id), SessionBranchBoundary::ThroughTurn, - &workspace_scope, + &workspace_id, ) .await } - async fn fork_session_before_turn( &self, request: AgentSessionForkBeforeTurnRequest, ) -> PortResult { - let workspace_scope = SessionStoragePathRequest { - workspace_path: PathBuf::from(&request.workspace_path), - remote_connection_id: request.remote_connection_id.clone(), - remote_ssh_host: request.remote_ssh_host.clone(), - }; - self.coordinator - .ensure_workspace_runtime_ownership( - Path::new(&request.workspace_path), + let workspace_id = self + .resolve_fork_workspace_id( + request.workspace_id.as_deref(), + &request.workspace_path, request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), - ) - .map_err(runtime_port_error)?; - let storage_path = self - .resolve_session_storage_path( - request.workspace_path, - request.remote_connection_id, - request.remote_ssh_host, + &request.source_session_id, ) .await?; + let storage_path = + CoreSessionStorePort::with_path_manager(self.persistence.path_manager().clone()) + .resolve_workspace_storage(&workspace_id) + .await? + .effective_storage_path; self.fork_at_persisted_turn( &storage_path, request.source_session_id, Some(request.source_turn_id), SessionBranchBoundary::BeforeTurn, - &workspace_scope, + &workspace_id, ) .await } @@ -2634,15 +2674,16 @@ impl AgentSessionUsagePort for CoreSessionOperationsPort { &self, request: AgentSessionUsageRequest, ) -> PortResult { - let workspace_path = request.workspace_path.clone().ok_or_else(|| { - PortError::new( + if request.workspace_id.is_none() && request.workspace_path.is_none() { + return Err(PortError::new( PortErrorKind::InvalidRequest, - "Workspace path is required for usage reports", - ) - })?; + "Workspace ID is required for usage reports", + )); + } let storage_path = self - .resolve_session_storage_path( - workspace_path, + .resolve_session_storage_for_reference( + request.workspace_id.as_deref(), + request.workspace_path.as_deref().unwrap_or_default(), request.remote_connection_id.clone(), request.remote_ssh_host.clone(), ) @@ -2870,6 +2911,7 @@ mod tests { } struct TestWorkspace { + snapshot_id: std::sync::OnceLock, path: PathBuf, } @@ -2880,7 +2922,15 @@ mod tests { Uuid::new_v4() )); std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } + // Sessions only exist inside registered workspaces; register the + // fixture directory like a host that opened this folder. Keep the + // canonical path so record-derived IO projections compare equal. + let path = dunce::canonicalize(&path).expect("test workspace should canonicalize"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&path); + Self { + path, + snapshot_id: std::sync::OnceLock::new(), + } } fn path(&self) -> &Path { @@ -2896,7 +2946,9 @@ mod tests { impl Drop for TestWorkspace { fn drop(&mut self) { - clear_snapshot_manager_for_test(&self.path); + if let Some(id) = self.snapshot_id.get() { + clear_snapshot_manager_for_test(id); + } let _ = std::fs::remove_dir_all(&self.path); } } @@ -3419,16 +3471,44 @@ mod tests { .and_then(|source| source.split("impl AgentSessionUsagePort").next()) .expect("session fork implementation"); + // The three port entry points resolve the workspace record and hand + // off to one shared fork path; that path owns the single Coordinator + // ownership gate, evaluated on the resolved binding before restore. assert_eq!( - fork_impl - .matches("ensure_workspace_runtime_ownership") - .count(), + fork_impl.matches("self.fork_at_persisted_turn(").count(), 3, "latest-turn, explicit-turn, and before-turn forks must share the Coordinator ownership gate" ); assert!(fork_impl.contains("fork_session_before_turn")); + assert!( + !fork_impl.contains("ensure_workspace_runtime_ownership"), + "port entry points must not duplicate the shared ownership gate" + ); + let shared_fork = source + .split("async fn fork_at_persisted_turn(") + .nth(1) + .and_then(|source| source.split("fn runtime_port_error(").next()) + .expect("shared fork implementation"); + let ownership_gate = shared_fork + .find("ensure_workspace_runtime_ownership") + .expect("shared fork path must pass through the Coordinator ownership gate"); + let restore = shared_fork + .find("restore_session_from_storage_path") + .expect("shared fork path restores the source session"); + assert!( + ownership_gate < restore, + "ownership must be checked before the fork touches session state" + ); + assert_eq!( + shared_fork + .matches("ensure_workspace_runtime_ownership") + .count(), + 1 + ); assert!(!fork_impl.contains("RuntimeOwnershipKey")); assert!(!fork_impl.contains("try_acquire")); + assert!(!shared_fork.contains("RuntimeOwnershipKey")); + assert!(!shared_fork.contains("try_acquire")); } #[test] @@ -3552,19 +3632,24 @@ mod tests { #[tokio::test] async fn local_workspace_snapshot_port_concurrently_prepares_and_returns_typed_empty_facts() { let workspace = TestWorkspace::new(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(workspace.path_manager()), )); let port = CoreLocalWorkspaceSnapshot::build(); - let first = port.prepare_local_workspace(workspace.path().to_path_buf()); - let second = port.prepare_local_workspace(workspace.path().to_path_buf()); + let first = port.prepare_local_workspace(record.id.clone()); + let second = port.prepare_local_workspace(record.id.clone()); let (first, second) = tokio::join!(first, second); first.expect("first local snapshot preparation should succeed"); second.expect("concurrent local snapshot preparation should reuse the owner"); let request = LocalWorkspaceSnapshotSessionRequest { - workspace_path: workspace.path().to_path_buf(), + workspace_id: record.id.clone(), session_id: "session-empty".to_string(), max_turn_exclusive: None, }; @@ -3583,7 +3668,7 @@ mod tests { assert_eq!(stats.total_changes, 0); assert!(port .rollback_workspace_files_to_turn(LocalWorkspaceSnapshotTurnRequest { - workspace_path: workspace.path().to_path_buf(), + workspace_id: record.id.clone(), session_id: "session-empty".to_string(), turn_index: 0, }) @@ -3595,14 +3680,20 @@ mod tests { #[tokio::test] async fn local_workspace_snapshot_views_do_not_initialize_a_writer() { let workspace = TestWorkspace::new(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; + workspace.snapshot_id.set(record.id.clone()).unwrap(); let port = CoreLocalWorkspaceSnapshot::build(); let request = LocalWorkspaceSnapshotSessionRequest { - workspace_path: workspace.path().to_path_buf(), + workspace_id: record.id.clone(), session_id: "session-view-only".to_string(), max_turn_exclusive: None, }; - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(get_snapshot_manager_for_workspace(&record.id).is_none()); assert!(port .get_session_files(request.clone()) .await @@ -3615,17 +3706,23 @@ mod tests { .total_changes, 0 ); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(get_snapshot_manager_for_workspace(&record.id).is_none()); } #[tokio::test] async fn local_workspace_snapshot_port_rejects_non_local_inputs_before_backend_access() { let workspace = TestWorkspace::new(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; + workspace.snapshot_id.set(record.id.clone()).unwrap(); let port = CoreLocalWorkspaceSnapshot::build(); let invalid_session = port .get_session_files(LocalWorkspaceSnapshotSessionRequest { - workspace_path: workspace.path().to_path_buf(), + workspace_id: record.id.clone(), session_id: "../other-session".to_string(), max_turn_exclusive: None, }) @@ -3633,7 +3730,25 @@ mod tests { .expect_err("path-like session ids must be rejected"); assert_eq!(invalid_session.kind, PortErrorKind::InvalidRequest); - let missing_workspace = workspace.path().join("missing"); + let remote = crate::service::workspace::legacy_compat::register_remote_fixture( + &record.root_path.to_string_lossy(), + "snapshot-test-connection", + "snapshot.example", + ) + .await; + let remote_error = port + .get_session_files(LocalWorkspaceSnapshotSessionRequest { + workspace_id: remote.id, + session_id: "session-empty".into(), + max_turn_exclusive: None, + }) + .await + .expect_err("remote ID must not open a same-named local directory"); + assert!(remote_error + .message + .contains("not supported for remote workspace")); + + let missing_workspace = "missing-workspace-id".to_string(); let invalid_workspace = port .prepare_local_workspace(missing_workspace) .await @@ -3768,6 +3883,12 @@ mod tests { // Two SSH hosts may use the same POSIX path. Forking is a Session-store // operation: no checkout, Git command, or controller path may be used. for (connection_id, host) in [("ssh-a", "host-a"), ("ssh-b", "host-b")] { + let record = crate::service::workspace::legacy_compat::register_remote_fixture( + &remote_path, + connection_id, + host, + ) + .await; let storage_path = runtime_service .context_for_remote_workspace(host, &remote_path) .sessions_dir; @@ -3842,6 +3963,7 @@ mod tests { let unverified_error = port .fork_session(AgentSessionForkRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), remote_connection_id: Some(connection_id.to_string()), @@ -3863,6 +3985,7 @@ mod tests { .expect("Workspace owner has verified this remote binding"); let wrong_host_error = port .fork_session_at_turn(AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), source_turn_id: "turn-0".to_string(), @@ -3873,9 +3996,17 @@ mod tests { .expect_err("a mismatched host cannot use a verified connection"); assert!(wrong_host_error .message - .contains("unverified_remote_workspace_scope")); + .contains("Legacy fork workspace reference is unavailable")); let other_connection_id = format!("{connection_id}-other-profile"); + let other_record = + crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + &remote_path, + &other_connection_id, + host, + Some(&Uuid::new_v4().to_string()), + ) + .await; coordinator .ensure_verified_remote_workspace_runtime_ownership( Path::new(&remote_path), @@ -3897,6 +4028,7 @@ mod tests { let result = match variant { 0 => { port.fork_session(AgentSessionForkRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), remote_connection_id: Some(other_connection_id.clone()), @@ -3906,6 +4038,7 @@ mod tests { } 1 => { port.fork_session_at_turn(AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), source_turn_id: "turn-0".to_string(), @@ -3916,6 +4049,7 @@ mod tests { } _ => { port.fork_session_before_turn(AgentSessionForkBeforeTurnRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), source_turn_id: "turn-0".to_string(), @@ -3950,13 +4084,15 @@ mod tests { .await .unwrap(); let mut changed_source = persisted_source.clone(); - changed_source.config.remote_connection_id = Some(other_connection_id.clone()); + changed_source.config.workspace_id = Some(other_record.id.clone()); + changed_source.config.project_workspace_id = Some(other_record.id.clone()); persistence .save_session(&storage_path, &changed_source) .await .unwrap(); let error = port .fork_session(AgentSessionForkRequest { + workspace_id: None, workspace_path: remote_path.clone(), source_session_id: source_session_id.clone(), remote_connection_id: Some(other_connection_id), @@ -3974,30 +4110,33 @@ mod tests { let result = match variant { 0 => { port.fork_session_at_turn(AgentSessionForkAtTurnRequest { - workspace_path: remote_path.clone(), + workspace_id: Some(record.id.clone()), + workspace_path: String::new(), source_session_id: source_session_id.clone(), source_turn_id: "turn-0".to_string(), - remote_connection_id: Some(connection_id.to_string()), - remote_ssh_host: Some(host.to_string()), + remote_connection_id: None, + remote_ssh_host: None, }) .await } 1 => { port.fork_session(AgentSessionForkRequest { - workspace_path: remote_path.clone(), + workspace_id: Some(record.id.clone()), + workspace_path: String::new(), source_session_id: source_session_id.clone(), - remote_connection_id: Some(connection_id.to_string()), - remote_ssh_host: Some(host.to_string()), + remote_connection_id: None, + remote_ssh_host: None, }) .await } _ => { port.fork_session_before_turn(AgentSessionForkBeforeTurnRequest { - workspace_path: remote_path.clone(), + workspace_id: Some(record.id.clone()), + workspace_path: String::new(), source_session_id: source_session_id.clone(), source_turn_id: "turn-0".to_string(), - remote_connection_id: Some(connection_id.to_string()), - remote_ssh_host: Some(host.to_string()), + remote_connection_id: None, + remote_ssh_host: None, }) .await } @@ -4033,6 +4172,7 @@ mod tests { } let legacy_fork = port .fork_session(AgentSessionForkRequest { + workspace_id: None, workspace_path: storage_path.to_string_lossy().into_owned(), source_session_id: source_session_id.clone(), remote_connection_id: None, @@ -4061,60 +4201,60 @@ mod tests { assert!(!Path::new(&remote_path).exists()); } - #[test] - fn session_fork_legacy_scope_uses_durable_identity_without_local_downgrade() { - let source = crate::agentic::core::Session::new_with_id( - "remote-source".into(), - "Remote source".into(), + #[tokio::test] + async fn session_fork_binding_uses_record_ids_and_ignores_stale_io_projections() { + let directory = TestWorkspace::new(); + let local = crate::service::workspace::legacy_compat::register_local_fixture( + directory.path(), + None, + ) + .await; + // Remote roots are POSIX on every client OS; a host temp path (Windows + // drive letters) is not a valid remote workspace path. + let remote = crate::service::workspace::legacy_compat::register_remote_fixture( + "/srv/fork/remote-project", + "fork-ssh", + "fork-host", + ) + .await; + let mut source = crate::agentic::core::Session::new_with_id( + "source".into(), + "Source".into(), "Standard".into(), crate::agentic::core::SessionConfig { - workspace_path: Some("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/workspace/repo".into()), - remote_connection_id: Some("ssh-source".into()), - remote_ssh_host: Some("source-host".into()), + workspace_id: Some(local.id.clone()), + workspace_path: Some("/stale/controller/mirror/sessions".into()), + remote_connection_id: Some("stale-ssh".into()), + remote_ssh_host: Some("stale-host".into()), ..Default::default() }, ); - let expected = super::workspace_session_identity( - "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/workspace/repo", - Some("ssh-source"), - Some("source-host"), - ); - let mut request = openbitfun_runtime_ports::SessionStoragePathRequest { - workspace_path: PathBuf::from("/controller/mirror/sessions"), - remote_connection_id: None, - remote_ssh_host: None, - }; - assert_eq!( - super::fork_workspace_identity(&source, &request).unwrap(), - expected - ); - request.workspace_path = PathBuf::from("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/workspace/repo"); - request.remote_connection_id = Some("ssh-source".into()); - assert_eq!( - super::fork_workspace_identity(&source, &request).unwrap(), - expected - ); - request.remote_connection_id = None; - request.remote_ssh_host = Some("source-host".into()); + let binding = super::fork_workspace_binding(&source, &local.id) + .await + .unwrap(); + assert!(!binding.is_remote()); + assert_eq!(binding.root_path, local.root_path); + assert!(super::fork_workspace_binding(&source, &remote.id) + .await + .is_err()); + source.config.workspace_id = Some(remote.id.clone()); + source.config.remote_connection_id = None; + let binding = super::fork_workspace_binding(&source, &remote.id) + .await + .unwrap(); + assert!(binding.is_remote()); assert_eq!( - super::fork_workspace_identity(&source, &request).unwrap(), - expected + binding.session_identity.remote_connection_id.as_deref(), + Some("fork-ssh") ); - - let mut incomplete = source.clone(); - incomplete.config.remote_connection_id = None; - assert!(super::fork_workspace_identity(&incomplete, &request).is_err()); - request.remote_ssh_host = None; - assert!(super::fork_workspace_identity(&incomplete, &request).is_err()); - - let local = TestWorkspace::new(); - incomplete.config.workspace_path = Some(local.path().to_string_lossy().into_owned()); - incomplete.config.remote_ssh_host = Some("localhost".into()); - request.workspace_path = local.path().to_path_buf(); - request.remote_ssh_host = Some("localhost".into()); - assert!(super::fork_workspace_identity(&incomplete, &request) - .unwrap() - .is_some_and(|identity| identity.remote_connection_id.is_none())); + assert!(super::fork_workspace_binding(&source, &local.id) + .await + .is_err()); + source.config.workspace_id = Some("missing-id".into()); + source.config.workspace_path = Some(directory.path().to_string_lossy().into_owned()); + assert!(super::fork_workspace_binding(&source, &local.id) + .await + .is_err()); } #[tokio::test] @@ -4122,6 +4262,9 @@ mod tests { let workspace = TestWorkspace::new(); let workspace_root = workspace.path().join("project"); std::fs::create_dir_all(&workspace_root).expect("workspace root"); + let record = + crate::service::workspace::legacy_compat::register_local_fixture(&workspace_root, None) + .await; let path_manager = workspace.path_manager(); let storage_path = WorkspaceRuntimeService::new(path_manager.clone()) .context_for_local_workspace(&workspace_root) @@ -4255,7 +4398,8 @@ mod tests { let result = port .fork_session(AgentSessionForkRequest { - workspace_path: workspace_root.to_string_lossy().into_owned(), + workspace_id: Some(record.id.clone()), + workspace_path: String::new(), source_session_id: session_id.to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -4430,7 +4574,8 @@ mod tests { let hidden_error = port .fork_session_at_turn(AgentSessionForkAtTurnRequest { - workspace_path: workspace_root.to_string_lossy().into_owned(), + workspace_id: Some(record.id.clone()), + workspace_path: String::new(), source_session_id: session_id.to_string(), source_turn_id: "turn-hidden".to_string(), remote_connection_id: None, @@ -4482,6 +4627,7 @@ mod tests { let usage_task = tokio::spawn(async move { usage_port .generate_session_usage(AgentSessionUsageRequest { + workspace_id: None, session_id: session_id.to_string(), workspace_path: Some(usage_workspace_path), remote_connection_id: None, @@ -4532,6 +4678,7 @@ mod tests { workspace.path(), AgentSessionUsageRequest { session_id: "../other-session".to_string(), + workspace_id: None, workspace_path: Some(workspace.path().to_string_lossy().into_owned()), remote_connection_id: None, remote_ssh_host: None, @@ -4594,6 +4741,7 @@ mod tests { &token_usage_service, workspace.path(), AgentSessionUsageRequest { + workspace_id: None, session_id: session_id.to_string(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), remote_connection_id: None, diff --git a/src/crates/assembly/core/src/service/cron/service.rs b/src/crates/assembly/core/src/service/cron/service.rs index c3fbb6c57d..bd2d71cb30 100644 --- a/src/crates/assembly/core/src/service/cron/service.rs +++ b/src/crates/assembly/core/src/service/cron/service.rs @@ -62,6 +62,21 @@ impl CronService { ))); } + // Upgrade persisted pre-ID targets once. Unavailable/ambiguous records + // stay on disk and fail explicitly when executed, never select a folder. + let old_target = job.target.clone(); + match crate::service::workspace::legacy_compat::upgrade_legacy_cron_target( + job.target.clone(), + ) + .await + { + Ok(target) => job.target = target, + Err(error) => warn!( + "Unable to upgrade scheduled job workspace: job_id={}, error={}", + job.id, error + ), + } + needs_save |= job.target != old_target; needs_save |= reconcile_loaded_job(&mut job, current_ms)?; jobs.insert(job.id.clone(), job); } @@ -111,21 +126,14 @@ impl CronService { pub async fn list_jobs_filtered( &self, - workspace_path: Option<&str>, workspace_id: Option<&str>, - remote_connection_id: Option<&str>, session_id: Option<&str>, target_kind: Option, ) -> Vec { let jobs = self.jobs.read().await; jobs.values() .filter(|job| { - let workspace_matches = matches_workspace_filter( - job.workspace(), - workspace_path, - workspace_id, - remote_connection_id, - ); + let workspace_matches = matches_workspace_filter(job.workspace(), workspace_id); let session_matches = session_id .map(|session_id| job.session_id() == Some(session_id)) .unwrap_or(true); @@ -574,6 +582,7 @@ impl CronService { execution: Default::default(), agent_type: resolved.agent_type, workspace_path: Some(resolved.workspace_path), + workspace_id: resolved.workspace_id, remote_connection_id: resolved.remote_connection_id, remote_ssh_host: resolved.remote_ssh_host, policy: scheduled_job_policy(), @@ -591,7 +600,11 @@ impl CronService { &self, enqueue_input: &EnqueueInput, ) -> Result { - match &enqueue_input.target { + let target = self + .canonicalize_target(enqueue_input.target.clone()) + .await + .map_err(|error| error.to_string())?; + match &target { CronJobTarget::Session { session_id, workspace, @@ -604,6 +617,7 @@ impl CronService { .unwrap_or_default(); Ok(ResolvedEnqueueSubmission { session_id: session_id.clone(), + workspace_id: workspace.workspace_id.clone(), workspace_path: workspace.workspace_path.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), @@ -639,6 +653,7 @@ impl CronService { Ok(ResolvedEnqueueSubmission { session_id: created.session_id, + workspace_id: workspace.workspace_id.clone(), workspace_path: workspace.workspace_path.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), @@ -658,6 +673,16 @@ impl CronService { { *workspace = Self::resolve_session_target_workspace_ref(&self.coordinator, session_id).await?; + } else { + // The only path reader is the temporary persisted/wire upgrade adapter. + target = crate::service::workspace::legacy_compat::upgrade_legacy_cron_target(target) + .await?; + if let CronJobTarget::Workspace { workspace, .. } = &mut target { + let id = workspace.workspace_id.as_deref().ok_or_else(|| { + OpenBitFunError::validation("Scheduled job workspace ID is required") + })?; + *workspace = workspace_ref_from_binding(&WorkspaceBinding::resolve(id).await?); + } } Ok(target) @@ -808,10 +833,10 @@ fn materialize_workspace_ref(workspace: CronWorkspaceRef) -> CronWorkspaceRef { .workspace_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), - workspace_path: normalize_workspace_path_for_matching(&workspace.workspace_path), + workspace_path: normalize_workspace_io_path(&workspace.workspace_path), project_workspace_path: workspace .project_workspace_path - .map(|value| normalize_workspace_path_for_matching(&value)) + .map(|value| normalize_workspace_io_path(&value)) .filter(|value| !value.is_empty()), execution_target: workspace.execution_target, remote_connection_id: workspace @@ -828,8 +853,8 @@ fn materialize_workspace_ref(workspace: CronWorkspaceRef) -> CronWorkspaceRef { fn workspace_ref_from_binding(binding: &WorkspaceBinding) -> CronWorkspaceRef { CronWorkspaceRef { workspace_id: binding.workspace_id.clone(), - workspace_path: normalize_workspace_path_for_matching(&binding.root_path_string()), - project_workspace_path: Some(normalize_workspace_path_for_matching( + workspace_path: normalize_workspace_io_path(&binding.root_path_string()), + project_workspace_path: Some(normalize_workspace_io_path( &binding.project_root_path_string(), )), execution_target: binding.execution_target.clone(), @@ -892,30 +917,13 @@ fn validate_workspace_ref(workspace: &CronWorkspaceRef) -> OpenBitFunResult<()> Ok(()) } -fn matches_workspace_filter( - workspace: &CronWorkspaceRef, - workspace_path: Option<&str>, - workspace_id: Option<&str>, - remote_connection_id: Option<&str>, -) -> bool { - let normalized_job_workspace_path = - normalize_workspace_path_for_matching(&workspace.workspace_path); - let workspace_path_matches = workspace_path - .map(|value| normalized_job_workspace_path == normalize_workspace_path_for_matching(value)) - .unwrap_or(true); - let workspace_id_matches = workspace_id - .map(|value| { - workspace.workspace_id.as_deref() == Some(value) || workspace.workspace_id.is_none() - }) - .unwrap_or(true); - let remote_connection_matches = remote_connection_id - .map(|value| workspace.remote_connection_id.as_deref() == Some(value)) - .unwrap_or(true); - - workspace_path_matches && workspace_id_matches && remote_connection_matches +fn matches_workspace_filter(workspace: &CronWorkspaceRef, workspace_id: Option<&str>) -> bool { + workspace_id + .map(|id| workspace.workspace_id.as_deref() == Some(id)) + .unwrap_or(true) } -fn normalize_workspace_path_for_matching(path: &str) -> String { +fn normalize_workspace_io_path(path: &str) -> String { let mut normalized = path.trim().replace('\\', "/"); if normalized.starts_with("file://") { @@ -1014,6 +1022,7 @@ struct EnqueueInput { struct ResolvedEnqueueSubmission { session_id: String, + workspace_id: Option, workspace_path: String, remote_connection_id: Option, remote_ssh_host: Option, @@ -1094,7 +1103,7 @@ mod tests { } #[test] - fn matches_workspace_filter_tolerates_separator_differences() { + fn workspace_filter_uses_id_even_when_paths_differ() { let workspace = CronWorkspaceRef { workspace_id: Some("local_workspace".to_string()), workspace_path: r"C:\Users\wsp\.openbitfun\personal_assistant\workspace".to_string(), @@ -1106,14 +1115,16 @@ mod tests { assert!(matches_workspace_filter( &workspace, - Some("C:/Users/wsp/.openbitfun/personal_assistant/workspace"), - Some("local_workspace"), - None, + Some("local_workspace") + )); + assert!(!matches_workspace_filter( + &workspace, + Some("another-workspace") )); } #[test] - fn matches_workspace_filter_normalizes_remote_like_paths() { + fn workspace_filter_never_matches_an_unmigrated_record_by_path() { let workspace = CronWorkspaceRef { workspace_id: None, workspace_path: "/home/wsp/projects/test/".to_string(), @@ -1123,12 +1134,11 @@ mod tests { remote_ssh_host: Some("host-1".to_string()), }; - assert!(matches_workspace_filter( + assert!(!matches_workspace_filter( &workspace, - Some(r"\home\wsp\projects\test"), - None, - Some("ssh-1"), + Some("remote-workspace") )); + assert!(matches_workspace_filter(&workspace, None)); } #[test] diff --git a/src/crates/assembly/core/src/service/cron/types.rs b/src/crates/assembly/core/src/service/cron/types.rs index 5d4fb92f59..8891809afc 100644 --- a/src/crates/assembly/core/src/service/cron/types.rs +++ b/src/crates/assembly/core/src/service/cron/types.rs @@ -77,6 +77,8 @@ pub enum CronJobTargetKind { pub struct CronWorkspaceRef { #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, + /// IO projection retained for old readers; never a workspace key. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_workspace_path: Option, diff --git a/src/crates/assembly/core/src/service/dispatch/baseline.rs b/src/crates/assembly/core/src/service/dispatch/baseline.rs index 92818cda65..5cc72eeb89 100644 --- a/src/crates/assembly/core/src/service/dispatch/baseline.rs +++ b/src/crates/assembly/core/src/service/dispatch/baseline.rs @@ -73,6 +73,7 @@ pub(super) async fn prepare_baseline( let created = WorktreeService::create(WorktreeCreateRequest { request_id: job_id.to_string(), + project_workspace_id: None, project_workspace_path: project.to_string(), source_workspace_path: Some(project.to_string()), base_ref: base_ref @@ -99,6 +100,7 @@ pub(super) async fn prepare_baseline( if created.worktree.branch.as_deref() != Some(branch.as_str()) { WorktreeService::create_branch(WorktreeCreateBranchRequest { request_id: format!("{job_id}::branch"), + project_workspace_id: None, project_workspace_path: project_workspace_path.clone(), worktree_id: worktree_id.clone(), branch: branch.clone(), diff --git a/src/crates/assembly/core/src/service/filesystem/path_operations.rs b/src/crates/assembly/core/src/service/filesystem/path_operations.rs index a4bf1c4022..b81f838cd5 100644 --- a/src/crates/assembly/core/src/service/filesystem/path_operations.rs +++ b/src/crates/assembly/core/src/service/filesystem/path_operations.rs @@ -2,9 +2,7 @@ //! Concrete IO stays in the existing local and SSH filesystem services. use super::FileSystemService; -use crate::service::remote_ssh::{ - get_remote_workspace_manager, lookup_remote_connection_with_hint, RemoteFileService, -}; +use crate::service::remote_ssh::{get_remote_workspace_manager, RemoteFileService}; /// Resolve an explicit file-operation connection without registering a workspace. /// Existing workspace providers remain supported; saved SSH profiles also own @@ -48,8 +46,6 @@ async fn remote( let hint = hint.map(str::trim).filter(|value| !value.is_empty()); let connection_id = if let Some(id) = hint { resolve_explicit_path_connection(path, id).await? - } else if let Some(entry) = lookup_remote_connection_with_hint(path, None).await { - entry.connection_id } else { return Ok(None); }; diff --git a/src/crates/assembly/core/src/service/filesystem/service.rs b/src/crates/assembly/core/src/service/filesystem/service.rs index 1db01df173..eed1638318 100644 --- a/src/crates/assembly/core/src/service/filesystem/service.rs +++ b/src/crates/assembly/core/src/service/filesystem/service.rs @@ -1,12 +1,11 @@ use crate::infrastructure::{ FileInfo, FileOperationOptions, FileReadResult, FileSearchOutcome, FileSearchProgressSink, - FileSearchResult, FileTreeNode, FileTreeStatistics, FileWriteResult, + FileSearchResult, FileTreeNode, FileWriteResult, }; use crate::util::elapsed_ms_u64; use crate::util::errors::*; use log::debug; use openbitfun_services_core::filesystem::FileSystemService as BaseFileSystemService; -use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -34,49 +33,27 @@ async fn read_remote_directory_contents( if let Some(connection_id) = explicit_connection_id { return Some(read_saved_directory_contents(path, connection_id).await); } - let remote_entry = if let Some(connection_id) = explicit_connection_id { - crate::service::remote_ssh::workspace_state::lookup_remote_connection_scoped( - path, - connection_id, - ) - .await - } else { - crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint(path, None) - .await - }; - let entry = match remote_entry { - Some(entry) => entry, - None if explicit_connection_id.is_some() => { - return Some(Err(OpenBitFunError::service(format!( - "Remote workspace connection '{}' is unavailable or does not own path '{}'; local filesystem fallback was not attempted", - explicit_connection_id.unwrap_or_default(), - path - )))); - } - None => return None, - }; + let connection_id = explicit_connection_id?; let Some(manager) = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() else { - return Some(Err(OpenBitFunError::service( - "Remote workspace manager is unavailable", - ))); + return Some(Err(OpenBitFunError::service(format!( + "Remote workspace manager is unavailable for connection '{connection_id}'; local filesystem fallback was not attempted" + )))); }; let Some(file_service) = manager.get_file_service().await else { - return Some(Err(OpenBitFunError::service( - "Remote file service is unavailable", - ))); + return Some(Err(OpenBitFunError::service(format!( + "Remote file service is unavailable for connection '{connection_id}'; local filesystem fallback was not attempted" + )))); }; - Some( - match file_service.read_dir(&entry.connection_id, path).await { - Ok(entries) => Ok(remote_directory_nodes(entries)), - Err(error) => Err(OpenBitFunError::service(format!( - "Failed to read remote directory: {}", - error - ))), - }, - ) + Some(match file_service.read_dir(connection_id, path).await { + Ok(entries) => Ok(remote_directory_nodes(entries)), + Err(error) => Err(OpenBitFunError::service(format!( + "Failed to read remote directory: {}", + error + ))), + }) } #[cfg(feature = "remote-workspace")] @@ -156,9 +133,7 @@ async fn read_saved_directory_with_provider( } /// Without the `remote-workspace` feature there is no SSH provider to route -/// to. A request that carries a remote marker (an explicit connection id or a -/// path owned by an opened workspace of kind `Remote`) is refused here so it -/// never reaches the controller filesystem scanner below. +/// to. An explicit remote target is refused before reaching the local scanner. #[cfg(not(feature = "remote-workspace"))] async fn read_remote_directory_contents( path: &str, @@ -168,25 +143,17 @@ async fn read_remote_directory_contents( if preferred_remote_connection_id.is_some_and(|id| id.is_empty()) { return None; } - use crate::service::remote_ssh::workspace_state::{ - is_remote_path, remote_workspace_not_compiled_message, - }; - let explicit_connection_id = preferred_remote_connection_id .map(str::trim) .filter(|value| !value.is_empty()); - if explicit_connection_id.is_some() || is_remote_path(path).await { + if explicit_connection_id.is_some() { return Some(Err(OpenBitFunError::NotImplemented( - remote_workspace_not_compiled_message(path), + format!("Remote workspaces are not compiled into this OpenBitFun host; refusing remote directory IO: {path}"), ))); } None } -async fn is_remote_path(path: &str) -> bool { - crate::service::remote_ssh::workspace_state::is_remote_path(path).await -} - /// Unified file system service pub struct FileSystemService { inner: BaseFileSystemService, @@ -208,11 +175,12 @@ impl FileSystemService { /// Builds a file tree. pub async fn build_file_tree(&self, root_path: &str) -> OpenBitFunResult> { - self.build_file_tree_with_remote_hint(root_path, None).await + self.build_file_tree_on_connection(root_path, None).await } - /// Same as [`Self::build_file_tree`], but disambiguates remote roots when `preferred_remote_connection_id` is set. - pub async fn build_file_tree_with_remote_hint( + /// Explicit provider IO. Workspace callers must first resolve their ID and use + /// the object kind to select local IO or its saved SSH connection. No path lookup. + pub async fn build_file_tree_on_connection( &self, root_path: &str, preferred_remote_connection_id: Option<&str>, @@ -225,8 +193,8 @@ impl FileSystemService { let explicit_remote_scope = preferred_remote_connection_id .map(str::trim) .is_some_and(|value| !value.is_empty()); - let tree = if explicit_remote_scope || is_remote_path(root_path).await { - self.get_directory_contents_with_remote_hint(root_path, preferred_remote_connection_id) + let tree = if explicit_remote_scope { + self.get_directory_contents_on_connection(root_path, preferred_remote_connection_id) .await? } else { self.inner @@ -253,29 +221,12 @@ impl FileSystemService { pub async fn scan_directory(&self, root_path: &str) -> OpenBitFunResult { let start_time = std::time::Instant::now(); - let (files, statistics) = if is_remote_path(root_path).await { - let nodes = self - .get_directory_contents_with_remote_hint(root_path, None) - .await?; - let stats = FileTreeStatistics { - total_files: nodes.iter().filter(|node| !node.is_directory).count(), - total_directories: nodes.iter().filter(|node| node.is_directory).count(), - total_size_bytes: 0, - max_depth_reached: 0, - file_type_counts: HashMap::new(), - large_files: Vec::new(), - symlinks_count: 0, - hidden_files_count: 0, - }; - (nodes, stats) - } else { - let scan_result = self - .inner - .scan_directory(root_path) - .await - .map_err(map_filesystem_error)?; - (scan_result.files, scan_result.statistics) - }; + let scan_result = self + .inner + .scan_directory(root_path) + .await + .map_err(map_filesystem_error)?; + let (files, statistics) = (scan_result.files, scan_result.statistics); let scan_time_ms = elapsed_ms_u64(start_time); @@ -299,11 +250,10 @@ impl FileSystemService { /// Gets directory contents (shallow). pub async fn get_directory_contents(&self, path: &str) -> OpenBitFunResult> { - self.get_directory_contents_with_remote_hint(path, None) - .await + self.get_directory_contents_on_connection(path, None).await } - pub async fn get_directory_contents_with_remote_hint( + pub async fn get_directory_contents_on_connection( &self, path: &str, preferred_remote_connection_id: Option<&str>, @@ -632,10 +582,10 @@ mod remote_marker_tests { for result in [ service - .get_directory_contents_with_remote_hint(&root, Some("ssh-remote-1")) + .get_directory_contents_on_connection(&root, Some("ssh-remote-1")) .await, service - .build_file_tree_with_remote_hint(&root, Some("ssh-remote-1")) + .build_file_tree_on_connection(&root, Some("ssh-remote-1")) .await, ] { let error = result.expect_err("remote-marked requests must fail closed"); @@ -676,7 +626,7 @@ mod remote_marker_tests { let root = temp.path().to_string_lossy().to_string(); let entries = FileSystemService::default() - .get_directory_contents_with_remote_hint(&root, None) + .get_directory_contents_on_connection(&root, None) .await .expect("local directories stay readable"); assert!(entries.iter().any(|entry| entry.name == local_name)); @@ -689,7 +639,7 @@ mod tests { use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; #[tokio::test] - async fn explicit_local_scope_does_not_follow_a_same_path_remote_workspace() { + async fn local_io_does_not_follow_a_same_path_remote_workspace() { let temp = tempfile::tempdir().expect("tempdir"); let root = temp.path().to_string_lossy().to_string(); openbitfun_services_core::filesystem::FileSystemService::default() @@ -710,7 +660,7 @@ mod tests { .await; let service = FileSystemService::default(); let nodes = service - .get_directory_contents_with_remote_hint(&root, Some("")) + .get_directory_contents_on_connection(&root, None) .await .unwrap(); assert!(nodes.iter().any(|node| node.name == "local.txt")); @@ -719,6 +669,36 @@ mod tests { .await; } + #[tokio::test] + async fn workspace_kind_selects_provider_despite_identical_paths_and_stale_metadata() { + use crate::service::workspace::{ + WorkspaceInfo, WorkspaceInfoRuntimeExt, WorkspaceKind, WorkspaceOpenOptions, + }; + let temp = tempfile::tempdir().unwrap(); + let mut local = WorkspaceInfo::new_without_worktree( + temp.path().to_path_buf(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(); + local.metadata.insert( + "connectionId".into(), + serde_json::json!("stale-ssh-profile"), + ); + let mut remote = local.clone(); + remote.id = "remote-workspace-id".into(); + remote.workspace_kind = WorkspaceKind::Remote; + assert_eq!(local.root_path, remote.root_path); + assert_eq!(local.filesystem_connection_id().unwrap(), None); + assert_eq!( + remote.filesystem_connection_id().unwrap(), + Some("stale-ssh-profile") + ); + remote.metadata.remove("connectionId"); + assert!(remote.filesystem_connection_id().is_err()); + assert_eq!(remote.workspace_kind, WorkspaceKind::Remote); + } + #[tokio::test] async fn registered_remote_path_without_file_provider_fails_closed() { let temp = tempfile::tempdir().expect("tempdir"); @@ -735,7 +715,7 @@ mod tests { .await; let error = FileSystemService::default() - .get_directory_contents_with_remote_hint(&remote_root, Some(connection_id)) + .get_directory_contents_on_connection(&remote_root, Some(connection_id)) .await .expect_err("registered remote paths must not fall back to the local filesystem"); @@ -767,7 +747,7 @@ mod tests { .await; let error = FileSystemService::default() - .get_directory_contents_with_remote_hint(&remote_root, Some(requested_connection_id)) + .get_directory_contents_on_connection(&remote_root, Some(requested_connection_id)) .await .expect_err("an explicit remote scope must not read the controller filesystem"); diff --git a/src/crates/assembly/core/src/service/filesystem/upload.rs b/src/crates/assembly/core/src/service/filesystem/upload.rs index a9898bfd3e..3a010c69d1 100644 --- a/src/crates/assembly/core/src/service/filesystem/upload.rs +++ b/src/crates/assembly/core/src/service/filesystem/upload.rs @@ -13,6 +13,10 @@ pub struct WorkspaceUploadRequest { pub path: String, #[serde(default)] pub session_id: Option, + /// Owning workspace ID; authoritative when present. + #[serde(default)] + pub workspace_id: Option, + /// Legacy explicit file workspace for pre-ID controllers. #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -49,6 +53,7 @@ pub async fn workspace_file_upload( let (target, target_id) = CoreServiceAgentRuntime::scoped_remote_file_target_with_identity( &request.path, request.session_id.as_deref(), + request.workspace_id.as_deref(), request.workspace_path.as_deref(), request.remote_connection_id.as_deref(), ) @@ -137,44 +142,55 @@ mod tests { let second = directory.path().join("second"); std::fs::create_dir_all(&first).unwrap(); std::fs::create_dir_all(&second).unwrap(); + // Explicit upload scopes are registered workspace records; the path + // is only the IO projection of the record the request names. + let first_record = + crate::service::workspace::legacy_compat::register_local_fixture(&first, None).await; + let second_record = + crate::service::workspace::legacy_compat::register_local_fixture(&second, None).await; let transfer = format!( "{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ); let account = format!("scope-{}", uuid::Uuid::new_v4()); - let make = |action: &str, root: &std::path::Path| { + let make = |action: &str, workspace: &crate::service::workspace::WorkspaceInfo| { + let root = workspace.root_path.as_path(); json!({ - "action":action,"transferId":transfer,"path":root.join("new.txt"),"workspacePath":root, + "action":action,"transferId":transfer,"path":root.join("new.txt"), + "workspaceId":workspace.id,"workspacePath":root, "remoteConnectionId":"","totalBytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "expectedHash":"","offset":0,"contentBase64":"aGVsbG8=" }) }; workspace_file_upload( account.clone(), - serde_json::from_value(make("begin", &second)).unwrap(), + serde_json::from_value(make("begin", &second_record)).unwrap(), ) .await .unwrap(); assert!(workspace_file_upload( account.clone(), - serde_json::from_value(make("append", &first)).unwrap() + serde_json::from_value(make("append", &first_record)).unwrap() ) .await .is_err()); workspace_file_upload( account.clone(), - serde_json::from_value(make("append", &second)).unwrap(), + serde_json::from_value(make("append", &second_record)).unwrap(), ) .await .unwrap(); workspace_file_upload( account, - serde_json::from_value(make("finish", &second)).unwrap(), + serde_json::from_value(make("finish", &second_record)).unwrap(), ) .await .unwrap(); - assert_eq!(std::fs::read(second.join("new.txt")).unwrap(), b"hello"); - assert!(!first.join("new.txt").exists()); + assert_eq!( + std::fs::read(second_record.root_path.join("new.txt")).unwrap(), + b"hello" + ); + assert!(!first_record.root_path.join("new.txt").exists()); } } diff --git a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs index c5c805e6e3..3cee6d8eee 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs @@ -120,7 +120,7 @@ impl MCPToolWrapper { fn is_blocked_in_context(&self, context: Option<&ToolUseContext>) -> bool { let workspace_key = crate::agentic::workspace::workspace_route_key( - context.and_then(ToolUseContext::workspace_root), + context.and_then(ToolUseContext::workspace_id), ); !self.context_policy.server_available_for_route( &self.server_id, diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs index ed9f148f79..e8fcaee3a9 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/mod.rs @@ -138,7 +138,7 @@ impl MCPServerManager { pub async fn server_available_for_context( &self, server_id: &str, - workspace_root: Option<&Path>, + workspace_id: Option<&str>, remote: bool, ) -> bool { let external_workspace_scope = self @@ -147,11 +147,11 @@ impl MCPServerManager { .await .get(server_id) .cloned(); - let workspace_key = crate::agentic::workspace::workspace_route_key(workspace_root); + let workspace_key = crate::agentic::workspace::workspace_route_key(workspace_id); self.tool_context_policy.server_available_for_route( server_id, external_workspace_scope.as_deref(), - workspace_root.map(|_| workspace_key.as_str()), + workspace_id.map(|_| workspace_key.as_str()), remote, ) } diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index f4af35f75d..9eff4f606a 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -178,7 +178,7 @@ fn ready_to_chat_body(state: &BotChatState, s: &'static BotStrings) -> Option { + state.current_assistant_id = Some(workspace.id.clone()); + state.current_assistant = Some(workspace.root_path.to_string_lossy().into_owned()); + state.current_assistant_name = + Some(remote_workspace_display_name(&workspace).to_string()); + } + Ok(None) => {} + Err(error) => log::warn!("Failed to upgrade bot assistant workspace identity: {error}"), } } @@ -470,21 +481,12 @@ async fn select_model( async fn open_bot_workspace( workspace_service: &crate::service::workspace::WorkspaceService, - path: std::path::PathBuf, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, - log_context: &str, + workspace_id: &str, ) -> Result { let coordinator = crate::agentic::coordination::get_global_coordinator() .ok_or_else(|| "Conversation coordinator not initialized".to_string())?; coordinator - .open_workspace_with_runtime_ownership( - workspace_service, - path, - remote_connection_id, - remote_ssh_host, - log_context, - ) + .select_workspace_with_runtime_ownership(workspace_service, workspace_id) .await .map_err(|error| error.to_string()) } @@ -521,18 +523,11 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String return s.bootstrap_workspace_unavailable.to_string(); }; - if let Err(e) = open_bot_workspace( - ws_service.as_ref(), - ws_info.root_path.clone(), - None, - None, - "IM bot pairing", - ) - .await - { + if let Err(e) = open_bot_workspace(ws_service.as_ref(), &ws_info.id).await { return format!("{}{e}", s.workspace_open_failed_prefix); } + state.current_assistant_id = Some(ws_info.id.clone()); state.current_assistant = Some(ws_info.root_path.to_string_lossy().to_string()); state.current_assistant_name = Some(remote_workspace_display_name(&ws_info).to_string()); state.current_session_id = None; @@ -1048,12 +1043,20 @@ fn remote_workspace_choices(response: &str) -> Result, S .filter(|value| !value.is_empty()) }; let path = text("path")?; - Some(BotWorkspaceChoice::new( - path, - text("name").unwrap_or(path), - text("remote_connection_id").map(str::to_string), - text("remote_ssh_host").map(str::to_string), - )) + let local = matches!(text("workspace_kind"), Some("normal" | "assistant")); + Some( + BotWorkspaceChoice::new( + path, + text("name").unwrap_or(path), + text("remote_connection_id") + .filter(|_| !local) + .map(str::to_string), + text("remote_ssh_host") + .filter(|_| !local) + .map(str::to_string), + ) + .with_workspace_id(text("workspace_id").map(str::to_string)), + ) }) .collect()) } @@ -1098,6 +1101,7 @@ fn local_pro_workspace_choices( workspace.remote_connection_id, workspace.remote_ssh_host, ) + .with_workspace_id(Some(workspace.workspace_id)) }) .collect() } @@ -1107,6 +1111,9 @@ fn current_workspace_choice<'a>( selected: &BotWorkspaceChoice, ) -> Option<&'a BotWorkspaceChoice> { options.iter().find(|option| { + if let Some(id) = selected.workspace_id.as_deref() { + return option.workspace_id.as_deref() == Some(id); + } option.path == selected.path && option.remote_connection_id == selected.remote_connection_id && option.remote_ssh_host == selected.remote_ssh_host @@ -1138,7 +1145,7 @@ async fn start_local_switch( .filter(|workspace| { workspace.kind == openbitfun_runtime_ports::RemoteWorkspaceKind::Assistant }) - .map(|workspace| (workspace.path, workspace.name)) + .map(|workspace| (workspace.workspace_id, workspace.name)) .collect(); if options.is_empty() { return result_from_menu( @@ -1191,10 +1198,20 @@ fn workspace_selection_view( let mut items = Vec::new(); let mut body = String::new(); for (i, choice) in options.iter().enumerate() { + // The ID decides the current item whenever both sides carry one; the + // legacy triple is only for rows a pre-ID host could not label. let is_current = state.current_workspace.as_ref().is_some_and(|current| { - current.path == choice.path - && current.remote_connection_id == choice.remote_connection_id - && current.remote_ssh_host == choice.remote_ssh_host + match ( + current.workspace_id.as_deref(), + choice.workspace_id.as_deref(), + ) { + (Some(current_id), Some(choice_id)) => current_id == choice_id, + _ => { + current.path == choice.path + && current.remote_connection_id == choice.remote_connection_id + && current.remote_ssh_host == choice.remote_ssh_host + } + } }); let marker = if is_current { s.current_marker } else { "" }; let host_hint = choice @@ -1229,8 +1246,8 @@ fn assistant_selection_view( ) -> MenuView { let mut items = Vec::new(); let mut body = String::new(); - for (i, (path, name)) in options.iter().enumerate() { - let is_current = state.current_assistant.as_deref() == Some(path.as_str()); + for (i, (workspace_id, name)) in options.iter().enumerate() { + let is_current = state.current_assistant_id.as_deref() == Some(workspace_id.as_str()); let marker = if is_current { s.current_marker } else { "" }; body.push_str(&format!("{}. {}{}\n", i + 1, name, marker)); items.push(MenuItem::default( @@ -1298,12 +1315,21 @@ async fn select_workspace( let result = show_workspace_choices(state, options, s); return workspace_list_changed_result(state, result, s); }; - let cmd = serde_json::json!({ - "cmd": "set_workspace", - "path": choice.path, - "remote_connection_id": choice.remote_connection_id, - "remote_ssh_host": choice.remote_ssh_host, - }); + // A choice the device labelled with an ID is selected by that ID + // alone, so an ID-aware host can never fall back to the path. Rows + // from a pre-ID host carry no ID and get the legacy projection. + let cmd = match choice.workspace_id.as_deref() { + Some(workspace_id) => serde_json::json!({ + "cmd": "set_workspace", + "workspace_id": workspace_id, + }), + None => serde_json::json!({ + "cmd": "set_workspace", + "path": choice.path, + "remote_connection_id": choice.remote_connection_id, + "remote_ssh_host": choice.remote_ssh_host, + }), + }; let cmd_json = serde_json::to_string(&cmd).unwrap_or_default(); return match exec_remote_rpc(state, &cmd_json).await { Ok(resp) => { @@ -1343,6 +1369,12 @@ async fn select_workspace( .and_then(|value| value.as_str()) .map(str::to_string) .or_else(|| choice.remote_ssh_host.clone()), + ) + .with_workspace_id( + val.get("workspace_id") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| choice.workspace_id.clone()), ); state.current_workspace = Some(workspace_ref); state.current_session_id = None; @@ -1390,6 +1422,7 @@ fn bot_workspace_ref(workspace: &crate::service::workspace::WorkspaceInfo) -> Bo ), remote_workspace_metadata(&workspace.workspace_kind, &workspace.metadata, "sshHost"), ) + .with_workspace_id(Some(workspace.id.clone())) } /// Repair the host-only local marker emitted by older bot versions using this @@ -1399,36 +1432,22 @@ fn repair_local_bot_workspace_state( state: &mut BotChatState, workspaces: &[crate::service::workspace::WorkspaceInfo], ) { - use crate::service::workspace::WorkspaceKind; if state.active_remote_device.is_some() || state.account_remote_context { return; } let Some(current) = state.current_workspace.as_mut() else { return; }; - if current - .remote_connection_id - .as_deref() - .is_some_and(|id| !id.trim().is_empty()) - || current.remote_ssh_host.as_deref().map(str::trim) != Some("localhost") - { - return; - } - let matches: Vec<_> = workspaces - .iter() - .filter(|workspace| workspace.root_path.to_string_lossy() == current.path) - .collect(); - if matches - .iter() - .any(|workspace| workspace.workspace_kind == WorkspaceKind::Remote) - { - return; - } - if matches - .iter() - .any(|workspace| workspace.workspace_kind != WorkspaceKind::Remote) + if let Ok(Some(record)) = + crate::service::workspace::legacy_compat::resolve_legacy_workspace_reference( + workspaces, + current.workspace_id.as_deref(), + ¤t.path, + current.remote_connection_id.as_deref(), + current.remote_ssh_host.as_deref(), + ) { - *current = BotWorkspaceRef::local(current.path.clone()); + *current = bot_workspace_ref(&record); } } @@ -1443,16 +1462,20 @@ async fn select_local_workspace( let result = show_workspace_choices(state, options, s); return workspace_list_changed_result(state, result, s); }; - let path_buf = std::path::PathBuf::from(&choice.path); - match open_bot_workspace( - ws_service, - path_buf, - choice.remote_connection_id.as_deref(), - choice.remote_ssh_host.as_deref(), - "bot workspace switch", - ) - .await - { + let result = async { + let id = choice + .workspace_id + .as_deref() + .ok_or_else(|| "Workspace catalog is missing its ID".to_string())?; + let coordinator = crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| "Conversation coordinator not initialized".to_string())?; + coordinator + .select_workspace_with_runtime_ownership(ws_service, id) + .await + .map_err(|error| error.to_string()) + } + .await; + match result { Ok(info) => { let workspace_path = info.root_path.to_string_lossy().to_string(); let workspace_ref = bot_workspace_ref(&info); @@ -1483,7 +1506,7 @@ async fn select_local_workspace( async fn select_assistant( state: &mut BotChatState, - path: &str, + workspace_id: &str, s: &'static BotStrings, ) -> HandleResult { use crate::service::workspace::get_global_workspace_service; @@ -1494,33 +1517,34 @@ async fn select_assistant( return result_from_menu(state, MenuView::plain(s.workspace_service_unavailable)); } }; - select_local_assistant(state, &ws_service, path, s).await + select_local_assistant(state, &ws_service, workspace_id, s).await } async fn select_local_assistant( state: &mut BotChatState, ws_service: &crate::service::workspace::WorkspaceService, - path: &str, + workspace_id: &str, s: &'static BotStrings, ) -> HandleResult { let workspaces = remote_opened_workspace_catalog(ws_service).await; let Some(workspace) = workspaces.iter().find(|workspace| { workspace.kind == openbitfun_runtime_ports::RemoteWorkspaceKind::Assistant - && workspace.path == path + && workspace.workspace_id == workspace_id }) else { let result = start_local_switch(state, ws_service, s).await; return workspace_list_changed_result(state, result, s); }; let name = &workspace.name; - let path_buf = std::path::PathBuf::from(path); - match open_bot_workspace(ws_service, path_buf, None, None, "bot assistant switch").await { + match open_bot_workspace(ws_service, &workspace.workspace_id).await { Ok(_info) => { - state.current_assistant = Some(path.to_string()); + state.current_assistant = Some(workspace.path.clone()); + state.current_assistant_id = Some(workspace_id.to_string()); state.current_assistant_name = Some(name.to_string()); state.current_session_id = None; - info!("Bot switched assistant to: {path}"); + info!("Bot switched assistant to workspace: {workspace_id}"); - let session_count = count_workspace_sessions(path).await; + let reference = state.assistant_workspace_ref().expect("selected assistant"); + let session_count = count_workspace_sessions_for_ref(&reference).await; let body = format!( "{}: {} · {}", s.current_assistant_label, @@ -1538,77 +1562,38 @@ async fn select_local_assistant( } } -/// Looks up SSH identity for a workspace path already known to WorkspaceService. -async fn bot_workspace_remote_identity(workspace_path: &str) -> (Option, Option) { - use crate::service::remote_ssh::normalize_remote_workspace_path; - use crate::service::workspace::{get_global_workspace_service, WorkspaceKind}; - - let Some(service) = get_global_workspace_service() else { - return (None, None); - }; - let want = normalize_remote_workspace_path(workspace_path); - let path_buf = std::path::PathBuf::from(workspace_path); - - let candidates = { - let mut list = Vec::new(); - if let Some(current) = service.get_current_workspace().await { - list.push(current); - } - if let Some(by_path) = service.get_workspace_by_path(&path_buf).await { - list.push(by_path); - } - list.extend(service.get_recent_workspaces().await); - list - }; - - for workspace in candidates { - if workspace.workspace_kind != WorkspaceKind::Remote { - continue; - } - let root = normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()); - if root != want { - continue; - } - let connection_id = workspace.remote_ssh_connection_id().map(str::to_string); - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - return (connection_id, ssh_host); +/// Upgrade-only conversion of persisted bot state; all operations below use ID. +async fn resolve_bot_workspace( + workspace: &BotWorkspaceRef, +) -> Result { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + if let Some(id) = workspace.workspace_id.as_deref() { + return service + .require_workspace(id) + .await + .map_err(|error| error.to_string()); } - - (None, None) + service + .resolve_legacy_workspace_reference( + None, + &workspace.path, + workspace.remote_connection_id.as_deref(), + workspace.remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Workspace ID is unavailable; select a saved workspace".to_string()) } -/// Resolves the on-disk sessions directory for a bot workspace ref. -/// -/// Remote SSH workspaces store sessions under `~/.openbitfun/remote_ssh/{host}/...`, -/// not under the remote POSIX path itself. Prefer identity captured at workspace -/// selection time; fall back to registry lookup only for legacy path-only state. async fn resolve_bot_session_storage_path_for_ref( workspace: &BotWorkspaceRef, ) -> Option { use crate::agentic::session::CoreSessionStorePort; - use openbitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; - - let mut remote_connection_id = workspace.remote_connection_id.clone(); - let mut remote_ssh_host = workspace.remote_ssh_host.clone(); - if remote_connection_id.is_none() && remote_ssh_host.is_none() { - let (fallback_connection_id, fallback_ssh_host) = - bot_workspace_remote_identity(&workspace.path).await; - remote_connection_id = fallback_connection_id; - remote_ssh_host = fallback_ssh_host; - } - + use openbitfun_runtime_ports::SessionStorePort; + let record = resolve_bot_workspace(workspace).await.ok()?; CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(&workspace.path), - remote_connection_id, - remote_ssh_host, - }) + .resolve_workspace_storage(&record.id) .await .ok() .map(|resolution| resolution.effective_storage_path) @@ -1637,10 +1622,6 @@ async fn count_workspace_sessions_for_ref(workspace: &BotWorkspaceRef) -> usize .unwrap_or(0) } -async fn count_workspace_sessions(workspace_path: &str) -> usize { - count_workspace_sessions_for_ref(&BotWorkspaceRef::local(workspace_path)).await -} - fn truncate_label(label: &str, max_chars: usize) -> String { let trimmed = label.trim(); if trimmed.chars().count() <= max_chars { @@ -1655,11 +1636,44 @@ fn truncate_label(label: &str, max_chars: usize) -> String { #[derive(Debug, Clone)] struct RemoteDeviceWorkspaceFacts { + /// Record ID reported by the device. A device that reports it accepts + /// ID-only workspace references; a device that does not is a pre-ID host. + workspace_id: Option, path: String, remote_connection_id: Option, remote_ssh_host: Option, } +impl RemoteDeviceWorkspaceFacts { + /// Workspace fields for a session-level RPC to this device. An ID-aware + /// device gets only the ID so it can never fall back to the path; a + /// pre-ID device gets the legacy projection it still understands. + fn apply_to_command(&self, command: &mut serde_json::Value) { + let Some(fields) = command.as_object_mut() else { + return; + }; + match self.workspace_id.as_deref() { + Some(workspace_id) => { + fields.insert( + "workspace_id".into(), + Value::String(workspace_id.to_string()), + ); + } + None => { + fields.insert("workspace_path".into(), Value::String(self.path.clone())); + fields.insert( + "remote_connection_id".into(), + serde_json::to_value(&self.remote_connection_id).unwrap_or(Value::Null), + ); + fields.insert( + "remote_ssh_host".into(), + serde_json::to_value(&self.remote_ssh_host).unwrap_or(Value::Null), + ); + } + } + } +} + async fn query_remote_device_workspace( state: &BotChatState, ) -> Result { @@ -1684,6 +1698,12 @@ async fn query_remote_device_workspace( .to_string() })?; Ok(RemoteDeviceWorkspaceFacts { + workspace_id: val + .get("workspace_id") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), path: path.to_string(), remote_connection_id: val .get("remote_connection_id") @@ -1717,14 +1737,12 @@ async fn start_resume( ); } }; - let cmd = serde_json::json!({ + let mut cmd = serde_json::json!({ "cmd": "list_sessions", - "workspace_path": workspace.path, - "remote_connection_id": workspace.remote_connection_id, - "remote_ssh_host": workspace.remote_ssh_host, "limit": 10, "offset": page * 10, }); + workspace.apply_to_command(&mut cmd); let cmd_json = serde_json::to_string(&cmd).unwrap_or_default(); match exec_remote_rpc(state, &cmd_json).await { Ok(resp) => { @@ -1855,8 +1873,8 @@ async fn start_resume( } } } else { - match &state.current_assistant { - Some(p) => BotWorkspaceRef::local(p.clone()), + match state.assistant_workspace_ref() { + Some(reference) => reference, None => { return result_from_menu( state, @@ -2127,14 +2145,12 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes ); } }; - let cmd = serde_json::json!({ + let mut cmd = serde_json::json!({ "cmd": "create_session", "agent_type": agent_type, "session_name": session_name, - "workspace_path": workspace.path, - "remote_connection_id": workspace.remote_connection_id, - "remote_ssh_host": workspace.remote_ssh_host, }); + workspace.apply_to_command(&mut cmd); let cmd_json = serde_json::to_string(&cmd).unwrap_or_default(); match exec_remote_rpc(state, &cmd_json).await { Ok(resp) => { @@ -2195,8 +2211,8 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes }; let workspace_ref = if is_claw { - if let Some(p) = state.current_assistant.clone() { - Some(BotWorkspaceRef::local(p)) + if let Some(reference) = state.assistant_workspace_ref() { + Some(reference) } else { let ws_service = match get_global_workspace_service() { Some(s) => s, @@ -2207,15 +2223,17 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes ); } }; - let resolved: Option<(String, String)> = + let resolved: Option<(String, String, String)> = if let Some(primary_ws) = ws_service.get_primary_assistant_workspace().await { Some(( + primary_ws.id.clone(), primary_ws.root_path.to_string_lossy().to_string(), remote_workspace_display_name(&primary_ws).to_string(), )) } else { match ws_service.create_assistant_workspace(None).await { Ok(ws_info) => Some(( + ws_info.id.clone(), ws_info.root_path.to_string_lossy().to_string(), remote_workspace_display_name(&ws_info).to_string(), )), @@ -2227,11 +2245,16 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes } } }; - if let Some((ref path, ref name)) = resolved { + if let Some((ref id, ref path, ref name)) = resolved { + state.current_assistant_id = Some(id.clone()); state.current_assistant = Some(path.clone()); state.current_assistant_name = Some(name.clone()); } - resolved.map(|(path, _)| BotWorkspaceRef::local(path)) + resolved.map(|(id, path, _)| { + let mut reference = BotWorkspaceRef::local(path); + reference.workspace_id = Some(id); + reference + }) } } else { state.current_workspace.clone() @@ -2276,20 +2299,25 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes return result_from_menu(state, view); }; - let (mut remote_connection_id, mut remote_ssh_host) = ( - workspace_ref.remote_connection_id.clone(), - workspace_ref.remote_ssh_host.clone(), - ); - if remote_connection_id.is_none() && remote_ssh_host.is_none() { - let fallback = bot_workspace_remote_identity(&workspace_ref.path).await; - remote_connection_id = fallback.0; - remote_ssh_host = fallback.1; - } + let record = match resolve_bot_workspace(&workspace_ref).await { + Ok(record) => record, + Err(error) => { + return result_from_menu( + state, + MenuView::plain(format!("{}{}", s.session_create_failed_prefix, error)), + ) + } + }; + let identity = bot_workspace_ref(&record); let request = build_remote_session_create_request( session_name, agent_type, - Some(workspace_ref.path.clone()), - RemoteSessionWorkspaceIdentity::new(remote_connection_id, remote_ssh_host), + Some(record.root_path.to_string_lossy().into_owned()), + RemoteSessionWorkspaceIdentity::new( + identity.remote_connection_id, + identity.remote_ssh_host, + ) + .with_workspace_id(Some(record.id)), RemoteConnectSubmissionSource::Bot, ); let runtime = match CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) { @@ -3794,7 +3822,10 @@ mod parse_command_tests { assert_eq!(choices.len(), 1); assert!(choices[0].remote_ssh_host.is_none()); // The identity captured after a successful open must agree with the picker. - assert_eq!(bot_workspace_ref(&project), BotWorkspaceRef::local(&path)); + assert_eq!( + bot_workspace_ref(&project), + BotWorkspaceRef::local(&path).with_workspace_id(Some(project.id.clone())) + ); let legacy = serde_json::json!({ "path": path, "remote_connection_id": null, "remote_ssh_host": "localhost" @@ -3803,11 +3834,17 @@ mod parse_command_tests { state.current_workspace = Some(serde_json::from_value(legacy.clone()).unwrap()); state.current_session_id = Some("keep-session".into()); repair_local_bot_workspace_state(&mut state, &[project.clone()]); - assert_eq!(state.current_workspace, Some(BotWorkspaceRef::local(&path))); + assert_eq!( + state.current_workspace, + Some(BotWorkspaceRef::local(&path).with_workspace_id(Some(project.id.clone()))) + ); assert_eq!(state.current_session_id.as_deref(), Some("keep-session")); let serialized = serde_json::to_value(state.current_workspace.as_ref().unwrap()).unwrap(); let restored: BotWorkspaceRef = serde_json::from_value(serialized).unwrap(); - assert_eq!(restored, BotWorkspaceRef::local(&path)); + assert_eq!( + restored, + BotWorkspaceRef::local(&path).with_workspace_id(Some(project.id.clone())) + ); let request = build_remote_session_create_request( "test", "Code", @@ -3815,13 +3852,16 @@ mod parse_command_tests { RemoteSessionWorkspaceIdentity::new( restored.remote_connection_id, restored.remote_ssh_host, - ), + ) + .with_workspace_id(restored.workspace_id), RemoteConnectSubmissionSource::Bot, ); + assert_eq!(request.workspace_id.as_deref(), Some(project.id.as_str())); assert!(request.remote_connection_id.is_none()); assert!(request.remote_ssh_host.is_none()); let mut remote = project.clone(); + remote.id = "remote-workspace".into(); remote.workspace_kind = WorkspaceKind::Remote; remote .metadata @@ -3918,6 +3958,25 @@ mod parse_command_tests { &service.get_assistant_workspaces().await, ); assert_eq!(state.current_assistant_name.as_deref(), Some("Mina")); + assert_eq!( + state.current_assistant_id.as_deref(), + Some(assistant.id.as_str()) + ); + let mut stale = state.clone(); + stale.current_assistant_id = Some("missing-assistant-id".into()); + stale.current_assistant_name = Some("Keep old label".into()); + refresh_assistant_name_from_workspaces( + &mut stale, + &service.get_assistant_workspaces().await, + ); + assert_eq!( + stale.current_assistant_id.as_deref(), + Some("missing-assistant-id") + ); + assert_eq!( + stale.current_assistant_name.as_deref(), + Some("Keep old label") + ); std::fs::write(assistant_root.join("IDENTITY.md"), "---\nname: Kira\n---\n").unwrap(); service @@ -3957,13 +4016,7 @@ mod parse_command_tests { "closed assistants remain tracked" ); state.display_mode = BotDisplayMode::Assistant; - let result = select_local_assistant( - &mut state, - &service, - &assistant.root_path.to_string_lossy(), - strings, - ) - .await; + let result = select_local_assistant(&mut state, &service, &assistant.id, strings).await; assert!(result.menu.title.contains(strings.workspace_list_changed)); assert!(result.menu.title.contains(strings.switch_no_assistants)); assert!(state.pending_action.is_none()); @@ -4541,9 +4594,7 @@ mod handle_chat_tests { } async fn assert_remote_question_round_trip(supports_interaction: bool) { - use openbitfun_services_integrations::remote_connect::{ - account::AccountSession, device_crypto, encryption, - }; + use openbitfun_services_integrations::remote_connect::{device_crypto, encryption}; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs index da27ee0ba0..4c0788356b 100644 --- a/src/crates/assembly/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/assembly/core/src/service/remote_connect/remote_server.rs @@ -229,6 +229,7 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { RemoteResponse::DeviceInfo { device_name: None, + workspace_id: workspace.as_ref().map(|facts| facts.workspace_id.clone()), workspace_path: workspace.as_ref().map(|facts| facts.path.clone()), workspace_kind: workspace .as_ref() @@ -256,6 +257,7 @@ impl RemoteCommandRuntimeHost for CoreRemoteCommandRuntimeHost<'_> { handle_remote_workspace_command( &host, &RemoteCommand::SetWorkspace { + workspace_id: None, path: path.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -649,6 +651,7 @@ mod tests { assert_eq!(cancel["turn_id"], "turn-1"); let list = serde_json::to_value(RemoteCommand::ListSessions { + workspace_id: None, workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/assembly/core/src/service/remote_ssh/mod.rs b/src/crates/assembly/core/src/service/remote_ssh/mod.rs index b316aec3ee..bf618cc8c1 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/mod.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/mod.rs @@ -50,10 +50,9 @@ pub use remote_terminal::{RemoteTerminalManager, RemoteTerminalSession, SessionS pub use types::*; pub use workspace_state::{ canonicalize_local_workspace_root, get_remote_workspace_manager, init_remote_workspace_manager, - is_remote_path, is_remote_workspace_active, local_workspace_roots_equal, - local_workspace_stable_storage_id, lookup_remote_connection, - lookup_remote_connection_with_hint, normalize_local_workspace_root_for_stable_id, - normalize_remote_workspace_path, remote_workspace_stable_id, workspace_logical_key, - RemoteWorkspaceEntry, RemoteWorkspaceState, RemoteWorkspaceStateManager, - LOCAL_WORKSPACE_SSH_HOST, + is_remote_path, local_workspace_roots_equal, local_workspace_stable_storage_id, + lookup_remote_connection, lookup_remote_connection_with_hint, + normalize_local_workspace_root_for_stable_id, normalize_remote_workspace_path, + remote_workspace_stable_id, workspace_logical_key, RemoteWorkspaceEntry, RemoteWorkspaceState, + RemoteWorkspaceStateManager, LOCAL_WORKSPACE_SSH_HOST, }; diff --git a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs index eec2554adc..70530f32b2 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs @@ -40,6 +40,7 @@ pub async fn resolve_workspace_session_identity( lookup_remote_connection_with_hint(workspace_path, Some(connection_id)).await { return Some(WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: entry.ssh_host, logical_workspace_path: entry.remote_root, remote_connection_id: Some(entry.connection_id), @@ -47,6 +48,7 @@ pub async fn resolve_workspace_session_identity( } return Some(WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "_unresolved".to_string(), logical_workspace_path: normalize_remote_workspace_path(workspace_path), remote_connection_id: Some(connection_id.to_string()), @@ -64,6 +66,7 @@ pub async fn resolve_workspace_session_identity( // workspace must never be misclassified as an invalid local path. if let Some(entry) = lookup_remote_connection_with_hint(workspace_path, None).await { return Some(WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: entry.ssh_host, logical_workspace_path: entry.remote_root, remote_connection_id: Some(entry.connection_id), @@ -379,7 +382,7 @@ pub async fn get_effective_session_path( ); } } - if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { + if !identity.is_remote() { return runtime_service .context_for_local_workspace(Path::new(identity.logical_workspace_path())) .sessions_dir; @@ -453,16 +456,6 @@ pub async fn lookup_remote_connection(path: &str) -> Option bool { - if let Some(manager) = get_remote_workspace_manager() { - manager.has_any().await - } else { - false - } -} - #[cfg(test)] mod tests { use super::remote_workspace_session_mirror_dir; diff --git a/src/crates/assembly/core/src/service/review_platform/mod.rs b/src/crates/assembly/core/src/service/review_platform/mod.rs index 558c843d37..493eff2ae4 100644 --- a/src/crates/assembly/core/src/service/review_platform/mod.rs +++ b/src/crates/assembly/core/src/service/review_platform/mod.rs @@ -22,61 +22,115 @@ pub use openbitfun_services_integrations::review_platform::{ ReviewPlatformReplyToThreadRequest, ReviewPlatformRepositoryRef, ReviewPlatformRequestChangesRequest, ReviewPlatformResolveThreadRequest, ReviewPlatformSubmitReviewRequest, ReviewPlatformThread, ReviewPlatformThreadKind, - ReviewPlatformWorkspaceSnapshot, ReviewSubmitEvent, + ReviewPlatformWorkspaceSnapshot, ReviewRepositoryLocator, ReviewSubmitEvent, }; +use crate::service::workspace::{WorkspaceInfo, WorkspaceKind}; use openbitfun_services_integrations::review_platform::{ - ReviewPlatformService as ReviewPlatformOwnerService, ReviewPlatformWorkspaceClassifier, - REVIEW_PLATFORM_TOKEN_FILE_NAME, + ReviewGitExecution, ReviewPlatformService as ReviewPlatformOwnerService, + ReviewPlatformWorkspaceClassifier, ReviewRemoteGitTarget, REVIEW_PLATFORM_TOKEN_FILE_NAME, }; pub struct ReviewPlatformService; struct CoreReviewPlatformWorkspaceClassifier; +/// Resolves the workspace record that owns a review repository locator. +/// +/// An explicit `workspace_id` is authoritative. A legacy path-only locator is +/// only accepted through the workspace legacy-compat boundary, which fails on +/// ambiguous paths instead of guessing a connection. +async fn resolve_review_workspace( + repository: &ReviewRepositoryLocator, +) -> Result, ReviewPlatformError> { + let Some(service) = crate::service::workspace::get_global_workspace_service() else { + if repository.workspace_id.is_some() { + return Err(ReviewPlatformError::InvalidRepository( + "Workspace service is not initialized; cannot resolve the review workspace" + .to_string(), + )); + } + return Ok(None); + }; + match repository.workspace_id.as_deref() { + Some(workspace_id) => service + .require_workspace(workspace_id) + .await + .map(Some) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string())), + None => service + .resolve_legacy_workspace_reference(None, &repository.repository_path, None, None) + .await + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string())), + } +} + +fn remote_git_target( + workspace: &WorkspaceInfo, +) -> Result { + let connection_id = workspace + .remote_ssh_connection_id() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + ReviewPlatformError::InvalidRepository(format!( + "Remote workspace {} has no SSH connection bound to it", + workspace.id + )) + })?; + Ok(ReviewRemoteGitTarget { + workspace_id: workspace.id.clone(), + connection_id: connection_id.to_string(), + }) +} + #[async_trait::async_trait] impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier { - /// With `remote-workspace` this consults the SSH registry. Without it the - /// compat facade still recognises an opened workspace record of kind - /// `Remote`, and the owner then routes Git probes to - /// [`Self::execute_remote_git_command`], which refuses below instead of - /// running `git` against the controller filesystem. - async fn is_remote_workspace_path(&self, path: &str) -> bool { - #[cfg(any(feature = "remote-workspace", feature = "agent-runtime"))] - { - return crate::service::remote_ssh::workspace_state::is_remote_path(path).await; + /// Classification is keyed by the owning workspace record: `workspace_kind` + /// decides local vs. remote and the record's SSH connection carries remote + /// Git probes. A legacy path that is not bound to any open workspace runs + /// locally only when no remote registry claims it; a remote path without a + /// workspace record is rejected instead of guessing a connection. + async fn classify_repository( + &self, + repository: &ReviewRepositoryLocator, + ) -> Result { + if let Some(workspace) = resolve_review_workspace(repository).await? { + return if workspace.workspace_kind == WorkspaceKind::Remote { + remote_git_target(&workspace).map(ReviewGitExecution::Remote) + } else { + Ok(ReviewGitExecution::Local) + }; } - #[cfg(not(any(feature = "remote-workspace", feature = "agent-runtime")))] + + #[cfg(any(feature = "remote-workspace", feature = "agent-runtime"))] { - // No SSH registry and no workspace records exist in this build, - // so nothing can mark a path as remote. - let _ = path; - false + if crate::service::remote_ssh::workspace_state::is_remote_path( + &repository.repository_path, + ) + .await + { + return Err(ReviewPlatformError::InvalidRepository(format!( + "Remote repository path {} is not bound to an open workspace; select the workspace by its ID", + repository.repository_path + ))); + } } + Ok(ReviewGitExecution::Local) } async fn execute_remote_git_command( &self, - workspace_path: &str, + target: &ReviewRemoteGitTarget, current_dir: &str, args: &[&str], ) -> Result { #[cfg(feature = "remote-workspace")] { - use crate::service::remote_ssh::workspace_state::{ - get_remote_workspace_manager, lookup_remote_connection, - }; + use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; use openbitfun_services_integrations::remote_ssh::{ build_remote_git_command, normalize_remote_workspace_path, }; - let entry = lookup_remote_connection(workspace_path) - .await - .ok_or_else(|| { - ReviewPlatformError::InvalidRepository(format!( - "No SSH connection is registered for remote workspace {workspace_path}" - )) - })?; let manager = match get_remote_workspace_manager() { Some(state) => state.get_ssh_manager().await, None => None, @@ -90,11 +144,12 @@ impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier let command = build_remote_git_command(&normalize_remote_workspace_path(current_dir), args); let (stdout, stderr, exit_code) = manager - .execute_command(&entry.connection_id, &command) + .execute_command(&target.connection_id, &command) .await .map_err(|error| { ReviewPlatformError::InvalidRepository(format!( - "Failed to execute git command on remote workspace: {error}" + "Failed to execute git command on remote workspace {}: {error}", + target.workspace_id )) })?; @@ -115,7 +170,8 @@ impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier { let _ = (current_dir, args); Err(ReviewPlatformError::InvalidRepository(format!( - "Remote workspaces are not compiled into this OpenBitFun host (feature `remote-workspace`); refusing to run Git against the local filesystem for a remote workspace: {workspace_path}" + "Remote workspaces are not compiled into this OpenBitFun host (feature `remote-workspace`); refusing to run Git against the local filesystem for remote workspace {}", + target.workspace_id ))) } } @@ -134,60 +190,60 @@ fn owner_service() -> Result { impl ReviewPlatformService { pub async fn discover_remotes( - repository_path: &str, + repository: &ReviewRepositoryLocator, ) -> Result, ReviewPlatformError> { - owner_service()?.discover_remotes(repository_path).await + owner_service()?.discover_remotes(repository).await } pub async fn workspace_snapshot( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, page: Option, per_page: Option, ) -> Result { owner_service()? - .workspace_snapshot(repository_path, remote_id, page, per_page) + .workspace_snapshot(repository, remote_id, page, per_page) .await } pub async fn workspace_context( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, ) -> Result { owner_service()? - .workspace_context(repository_path, remote_id) + .workspace_context(repository, remote_id) .await } pub async fn workspace_snapshot_with_state( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, page: Option, per_page: Option, state: ReviewPlatformListState, ) -> Result { owner_service()? - .workspace_snapshot_with_state(repository_path, remote_id, page, per_page, state) + .workspace_snapshot_with_state(repository, remote_id, page, per_page, state) .await } pub async fn pull_request_detail( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ) -> Result { owner_service()? - .pull_request_detail(repository_path, remote_id, pull_request_id) + .pull_request_detail(repository, remote_id, pull_request_id) .await } pub async fn pull_request_review_target( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ) -> Result { owner_service()? - .pull_request_review_target(repository_path, remote_id, pull_request_id) + .pull_request_review_target(repository, remote_id, pull_request_id) .await } @@ -198,7 +254,7 @@ impl ReviewPlatformService { issue_id: &str, page: Option, per_page: Option, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { owner_service()? .issue( @@ -208,7 +264,7 @@ impl ReviewPlatformService { issue_id, page, per_page, - repository_path, + repository, ) .await } @@ -218,7 +274,7 @@ impl ReviewPlatformService { host: &str, project_path: &str, pull_request_id: &str, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { owner_service()? .pull_request_review_target_by_identity( @@ -226,7 +282,7 @@ impl ReviewPlatformService { host, project_path, pull_request_id, - repository_path, + repository, ) .await } @@ -241,7 +297,7 @@ impl ReviewPlatformService { expected_head_revision: &str, file_path: &str, file_page_hint: Option, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { owner_service()? .pull_request_file_diff_by_identity( @@ -253,13 +309,13 @@ impl ReviewPlatformService { expected_head_revision, file_path, file_page_hint, - repository_path, + repository, ) .await } pub async fn pull_request_file_diff( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, expected_base_revision: &str, @@ -269,7 +325,7 @@ impl ReviewPlatformService { ) -> Result { owner_service()? .pull_request_file_diff( - repository_path, + repository, remote_id, pull_request_id, expected_base_revision, @@ -281,7 +337,7 @@ impl ReviewPlatformService { } pub async fn pull_request_detail_page( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, section: ReviewPlatformDetailSection, @@ -290,7 +346,7 @@ impl ReviewPlatformService { ) -> Result { owner_service()? .pull_request_detail_page( - repository_path, + repository, remote_id, pull_request_id, section, @@ -301,7 +357,7 @@ impl ReviewPlatformService { } pub async fn pull_request_ci_log( - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ci_item_id: &str, @@ -309,7 +365,7 @@ impl ReviewPlatformService { ) -> Result { owner_service()? .pull_request_ci_log( - repository_path, + repository, remote_id, pull_request_id, ci_item_id, @@ -384,12 +440,15 @@ mod tests { #[cfg(feature = "remote-workspace")] #[tokio::test] - async fn remote_git_execution_fails_loudly_without_registered_connection() { + async fn remote_git_execution_fails_loudly_without_ssh_manager() { let classifier = CoreReviewPlatformWorkspaceClassifier; let error = classifier .execute_remote_git_command( - "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/openbitfun-tests/unregistered-remote-workspace", + &ReviewRemoteGitTarget { + workspace_id: "ws-unregistered".to_string(), + connection_id: "conn-unregistered".to_string(), + }, "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/openbitfun-tests/unregistered-remote-workspace", &["remote", "-v"], ) @@ -398,7 +457,8 @@ mod tests { let message = error.to_string(); assert!( - message.contains("No SSH connection is registered"), + message.contains("SSH connection manager is not initialized") + || message.contains("Failed to execute git command on remote workspace"), "unexpected error message: {message}" ); } @@ -408,14 +468,35 @@ mod tests { async fn remote_git_execution_fails_loudly_without_remote_workspace_capability() { let classifier = CoreReviewPlatformWorkspaceClassifier; - assert!(!classifier.is_remote_workspace_path("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/remote/project").await); let error = classifier - .execute_remote_git_command("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/remote/project", "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/remote/project", &["status"]) + .execute_remote_git_command( + &ReviewRemoteGitTarget { + workspace_id: "ws-remote".to_string(), + connection_id: "conn".to_string(), + }, + "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/remote/project", + &["status"], + ) .await .expect_err("a narrow review-platform build must reject remote execution"); assert!(error .to_string() - .contains("Remote workspace support is not available in this build")); + .contains("Remote workspaces are not compiled into this OpenBitFun host")); + } + + #[tokio::test] + async fn unknown_workspace_id_is_rejected_instead_of_falling_back_to_path() { + let classifier = CoreReviewPlatformWorkspaceClassifier; + let result = classifier + .classify_repository(&ReviewRepositoryLocator::new( + Some("ws-does-not-exist".to_string()), + "/tmp/some-local-repo", + )) + .await; + assert!( + result.is_err(), + "an explicit but unknown workspace ID must not degrade to path classification" + ); } } diff --git a/src/crates/assembly/core/src/service/search/mod.rs b/src/crates/assembly/core/src/service/search/mod.rs index de32dfe0dc..38c301373b 100644 --- a/src/crates/assembly/core/src/service/search/mod.rs +++ b/src/crates/assembly/core/src/service/search/mod.rs @@ -15,9 +15,11 @@ pub use openbitfun_services_integrations::workspace_search::{ WorkspaceSearchTaskStatus, }; #[cfg(feature = "ssh-remote")] -pub use remote::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; +pub use remote::{remote_workspace_search_service_for_workspace, RemoteWorkspaceSearchService}; #[cfg(not(feature = "ssh-remote"))] -pub use remote_disabled::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; +pub use remote_disabled::{ + remote_workspace_search_service_for_workspace, RemoteWorkspaceSearchService, +}; pub use service::{ get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, set_global_workspace_search_service, workspace_search_daemon_available, diff --git a/src/crates/assembly/core/src/service/search/remote.rs b/src/crates/assembly/core/src/service/search/remote.rs index 3772f3512c..2996207669 100644 --- a/src/crates/assembly/core/src/service/search/remote.rs +++ b/src/crates/assembly/core/src/service/search/remote.rs @@ -1,7 +1,6 @@ use crate::service::config::{get_global_config_service, types::WorkspaceConfig, ConfigService}; use crate::service::remote_ssh::workspace_state::{ - get_remote_workspace_manager, lookup_remote_connection, lookup_remote_connection_with_hint, - RemoteWorkspaceEntry, + get_remote_workspace_manager, RemoteWorkspaceEntry, }; use crate::service::remote_ssh::{RemoteFileService, SSHConnectionManager}; use crate::service::search::{ @@ -26,12 +25,14 @@ pub struct RemoteWorkspaceSearchService { } impl RemoteWorkspaceSearchService { - pub fn new( + fn new( + workspace: RemoteWorkspaceEntry, ssh_manager: SSHConnectionManager, remote_file_service: RemoteFileService, config_service: Arc, ) -> Self { let provider = Arc::new(CoreRemoteWorkspaceSearchProvider { + workspace, ssh_manager, remote_file_service, config_service, @@ -81,11 +82,54 @@ impl RemoteWorkspaceSearchService { #[derive(Clone)] struct CoreRemoteWorkspaceSearchProvider { + workspace: RemoteWorkspaceEntry, ssh_manager: SSHConnectionManager, remote_file_service: RemoteFileService, config_service: Arc, } +async fn resolve_search_workspace(workspace_id: &str) -> Result { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or("Workspace service is unavailable")?; + let record = service + .require_workspace(workspace_id) + .await + .map_err(|error| error.to_string())?; + let connection_id = record + .filesystem_connection_id()? + .ok_or("Remote search requires a remote workspace")? + .to_owned(); + let ssh_host = record + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .filter(|host| !host.is_empty()) + .ok_or("Remote workspace is missing SSH host metadata")? + .to_owned(); + Ok(RemoteWorkspaceEntry { + connection_id, + ssh_host, + connection_name: record.name, + remote_root: crate::service::remote_ssh::normalize_remote_workspace_path( + &record.root_path.to_string_lossy(), + ), + }) +} + +fn validate_search_io_scope( + workspace: &RemoteWorkspaceEntry, + root_path: &str, + requested_connection: Option<&str>, +) -> Result { + if requested_connection.is_some_and(|connection| connection != workspace.connection_id) + || crate::service::remote_ssh::normalize_remote_workspace_path(root_path) + != workspace.remote_root + { + return Err("Remote search IO scope differs from the workspace selected by ID".into()); + } + Ok(workspace.clone()) +} + #[async_trait] impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider { async fn resolve_workspace_entry( @@ -93,14 +137,7 @@ impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider { root_path: &str, preferred_connection_id: Option<&str>, ) -> Result { - if let Some(entry) = - lookup_remote_connection_with_hint(root_path, preferred_connection_id).await - { - return Ok(entry); - } - lookup_remote_connection(root_path) - .await - .ok_or_else(|| format!("Remote workspace is not registered for path: {root_path}")) + validate_search_io_scope(&self.workspace, root_path, preferred_connection_id) } async fn cached_server_os_type(&self, connection_id: &str) -> Option { @@ -180,20 +217,16 @@ impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider { } } -pub async fn remote_workspace_search_service_for_path( - root_path: &str, - preferred_connection_id: Option, +pub async fn remote_workspace_search_service_for_workspace( + workspace_id: &str, ) -> Result { + let workspace = resolve_search_workspace(workspace_id).await?; + let connection_id = workspace.connection_id.clone(); let manager = get_remote_workspace_manager() .ok_or_else(|| "Remote workspace manager is unavailable".to_string())?; - let preferred_connection_id = match preferred_connection_id { - Some(connection_id) => Some(connection_id), - None => lookup_remote_connection(root_path) - .await - .map(|entry| entry.connection_id), - }; Ok(RemoteWorkspaceSearchService::new( + workspace, manager .get_ssh_manager() .await @@ -206,7 +239,7 @@ pub async fn remote_workspace_search_service_for_path( .await .map_err(|error| format!("Config service unavailable: {error}"))?, ) - .with_preferred_connection_id(preferred_connection_id)) + .with_preferred_connection_id(Some(connection_id))) } fn spawn_remote_stdio_owner( @@ -309,3 +342,42 @@ fn spawn_remote_stdio_owner( .await; }); } + +#[cfg(test)] +mod identity_tests { + #[tokio::test] + async fn search_selects_id_without_a_path_registry_or_active_connection() { + let root = format!("/search-id-test/{}", uuid::Uuid::new_v4()); + let first = crate::service::workspace::legacy_compat::register_remote_fixture( + &root, + "search-a", + "search.example", + ) + .await; + let second = crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + &root, + "search-b", + "search.example", + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; + let entry = super::resolve_search_workspace(&first.id).await.unwrap(); + let other = super::resolve_search_workspace(&second.id).await.unwrap(); + assert_eq!(entry.connection_id, "search-a"); + assert_eq!(other.connection_id, "search-b"); + assert!(super::resolve_search_workspace(&root).await.is_err()); + assert!(super::validate_search_io_scope(&entry, &root, Some("search-b")).is_err()); + assert!(super::validate_search_io_scope(&entry, "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/different/project", None).is_err()); + assert_eq!( + super::validate_search_io_scope(&entry, &root, None) + .unwrap() + .connection_id, + "search-a" + ); + let local = tempfile::tempdir().unwrap(); + let local = + crate::service::workspace::legacy_compat::register_local_fixture(local.path(), None) + .await; + assert!(super::resolve_search_workspace(&local.id).await.is_err()); + } +} diff --git a/src/crates/assembly/core/src/service/search/remote_disabled.rs b/src/crates/assembly/core/src/service/search/remote_disabled.rs index 6df258c74b..2c144823b5 100644 --- a/src/crates/assembly/core/src/service/search/remote_disabled.rs +++ b/src/crates/assembly/core/src/service/search/remote_disabled.rs @@ -24,9 +24,8 @@ impl RemoteWorkspaceSearchService { } } -pub async fn remote_workspace_search_service_for_path( - _root_path: &str, - _preferred_connection_id: Option, +pub async fn remote_workspace_search_service_for_workspace( + _workspace_id: &str, ) -> Result { Err(unsupported()) } diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 2a078b2f67..ba7d9eb076 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -77,7 +77,13 @@ pub async fn generate_session_usage_report_from_storage_path( Vec::new() }; - let snapshot_facts = load_snapshot_facts(&request, revert_boundary).await; + let workspace_id = persistence_manager + .load_session_header(session_storage_path, &request.session_id) + .await + .ok() + .and_then(|session| session.config.workspace_id); + let snapshot_facts = + load_snapshot_facts(&request, workspace_id.as_deref(), revert_boundary).await; Ok(build_session_usage_report_from_sources_with_scope( request, @@ -329,13 +335,13 @@ fn build_session_usage_report_from_sources_with_scope( async fn load_snapshot_facts( request: &SessionUsageReportRequest, + workspace_id: Option<&str>, revert_boundary: Option, ) -> UsageSnapshotFacts { - let Some(workspace_path) = request.workspace_path.as_deref() else { + let Some(workspace_id) = workspace_id else { return UsageSnapshotFacts::default(); }; - - let Some(manager) = get_snapshot_manager_for_workspace(Path::new(workspace_path)) else { + let Some(manager) = get_snapshot_manager_for_workspace(workspace_id) else { return UsageSnapshotFacts::default(); }; @@ -394,7 +400,7 @@ fn build_workspace(request: &SessionUsageReportRequest) -> UsageWorkspace { .workspace_path .as_deref() .map(|path| redact_usage_label(path, 120).value), - workspace_id: None, + workspace_id: request.workspace_id.clone(), remote_connection_id: request.remote_connection_id.clone(), remote_ssh_host: request.remote_ssh_host.clone(), } @@ -2713,6 +2719,7 @@ mod tests { fn test_request(remote_connection_id: Option<&str>) -> SessionUsageReportRequest { SessionUsageReportRequest { session_id: "session-1".to_string(), + workspace_id: None, workspace_path: Some("D:/workspace/openbitfun".to_string()), remote_connection_id: remote_connection_id.map(ToOwned::to_owned), remote_ssh_host: remote_connection_id.map(|_| "host.example".to_string()), diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs index 1dc4c3a126..2ce503c57f 100644 --- a/src/crates/assembly/core/src/service/snapshot/manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/manager.rs @@ -36,10 +36,7 @@ pub struct SnapshotManager { impl SnapshotManager { /// Creates a new snapshot manager. - pub async fn new( - workspace_dir: PathBuf, - config: Option, - ) -> SnapshotResult { + async fn new(workspace_dir: PathBuf, config: Option) -> SnapshotResult { #[cfg(test)] record_snapshot_manager_new_for_test(&workspace_dir).await; @@ -481,8 +478,8 @@ impl SnapshotManager { } } -fn snapshot_managers() -> &'static StdRwLock>> { - static SNAPSHOT_MANAGERS: OnceLock>>> = +fn snapshot_managers() -> &'static StdRwLock>> { + static SNAPSHOT_MANAGERS: OnceLock>>> = OnceLock::new(); SNAPSHOT_MANAGERS.get_or_init(|| StdRwLock::new(HashMap::new())) } @@ -492,21 +489,20 @@ fn snapshot_managers() -> &'static StdRwLock &'static StdRwLock>> { - static SNAPSHOT_VIEW_MANAGERS: OnceLock>>> = +fn snapshot_view_managers() -> &'static StdRwLock>> { + static SNAPSHOT_VIEW_MANAGERS: OnceLock>>> = OnceLock::new(); SNAPSHOT_VIEW_MANAGERS.get_or_init(|| StdRwLock::new(HashMap::new())) } -fn snapshot_manager_init_locks() -> &'static AsyncMutex>>> { - static SNAPSHOT_MANAGER_INIT_LOCKS: OnceLock< - AsyncMutex>>>, - > = OnceLock::new(); +fn snapshot_manager_init_locks() -> &'static AsyncMutex>>> { + static SNAPSHOT_MANAGER_INIT_LOCKS: OnceLock>>>> = + OnceLock::new(); SNAPSHOT_MANAGER_INIT_LOCKS.get_or_init(|| AsyncMutex::new(HashMap::new())) } -async fn snapshot_manager_init_lock(workspace_dir: &Path) -> Arc> { - let workspace_key = snapshot_workspace_key(workspace_dir); +async fn snapshot_manager_init_lock(workspace_id: &str) -> Arc> { + let workspace_key = workspace_id.to_owned(); let mut locks = snapshot_manager_init_locks().lock().await; locks .entry(workspace_key) @@ -514,7 +510,7 @@ async fn snapshot_manager_init_lock(workspace_dir: &Path) -> Arc> .clone() } -fn snapshot_workspace_key(workspace_dir: &Path) -> PathBuf { +fn canonical_snapshot_io_path(workspace_dir: &Path) -> PathBuf { dunce::canonicalize(workspace_dir).unwrap_or_else(|_| workspace_dir.to_path_buf()) } @@ -522,8 +518,7 @@ fn snapshot_workspace_key(workspace_dir: &Path) -> PathBuf { /// the same path and host while accessing different users or containers. #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct BoundSnapshotKey { - identity: WorkspaceSessionIdentity, - runtime_root: PathBuf, + workspace_id: String, } #[derive(Default)] @@ -550,11 +545,11 @@ fn bound_snapshot_context( ) -> SnapshotResult { match &context.target { WorkspaceRuntimeTarget::LocalWorkspace { workspace_root } => { - if identity.remote_connection_id.is_some() + if identity.is_remote() || identity.hostname != openbitfun_services_core::workspace_identity::LOCAL_WORKSPACE_SSH_HOST - || snapshot_workspace_key(workspace_root) - != snapshot_workspace_key(Path::new(&identity.logical_workspace_path)) + || canonical_snapshot_io_path(workspace_root) + != canonical_snapshot_io_path(Path::new(&identity.logical_workspace_path)) { return Err(SnapshotError::ConfigError( "Snapshot workspace identity does not match its local runtime context".into(), @@ -609,60 +604,73 @@ fn bound_snapshot_context( /// The caller must resolve both from the same WorkspaceBinding; this function /// never selects a transport by looking up a path on the controller. pub async fn get_or_create_snapshot_manager_with_workspace( - identity: &WorkspaceSessionIdentity, + workspace_id: &str, workspace_fs: Arc, runtime_context: WorkspaceRuntimeContext, config: Option, ) -> SnapshotResult> { - open_bound_snapshot_manager(identity, Some(workspace_fs), runtime_context, config, false).await + open_bound_snapshot_manager( + workspace_id, + Some(workspace_fs), + runtime_context, + config, + false, + ) + .await } /// Open connection-scoped persisted facts without creating any runtime state. pub async fn open_snapshot_manager_for_workspace_view( - identity: &WorkspaceSessionIdentity, + workspace_id: &str, workspace_fs: Option>, runtime_context: WorkspaceRuntimeContext, ) -> SnapshotResult> { - open_bound_snapshot_manager(identity, workspace_fs, runtime_context, None, true).await + open_bound_snapshot_manager(workspace_id, workspace_fs, runtime_context, None, true).await } /// Read recorded operation history from controller storage. This does not /// connect to SSH, create runtime directories or infer a connection from paths. pub async fn open_snapshot_history_for_workspace( - identity: &WorkspaceSessionIdentity, + workspace_id: &str, ) -> SnapshotResult> { + let binding = crate::agentic::WorkspaceBinding::resolve(workspace_id) + .await + .map_err(|error| SnapshotError::ConfigError(error.to_string()))?; + let identity = &binding.session_identity; let runtime_service = get_workspace_runtime_service_arc(); - let context = if identity.remote_connection_id.is_some() { + let context = if identity.is_remote() { runtime_service .context_for_remote_workspace(&identity.hostname, &identity.logical_workspace_path) } else { runtime_service.context_for_local_workspace(Path::new(&identity.logical_workspace_path)) }; - open_snapshot_manager_for_workspace_view(identity, None, context).await + open_snapshot_manager_for_workspace_view(workspace_id, None, context).await } async fn open_bound_snapshot_manager( - identity: &WorkspaceSessionIdentity, + workspace_id: &str, workspace_fs: Option>, runtime_context: WorkspaceRuntimeContext, config: Option, read_only: bool, ) -> SnapshotResult> { + let binding = crate::agentic::WorkspaceBinding::resolve(workspace_id) + .await + .map_err(|error| SnapshotError::ConfigError(error.to_string()))?; + let identity = &binding.session_identity; let runtime_context = bound_snapshot_context(identity, runtime_context)?; if matches!( runtime_context.target, WorkspaceRuntimeTarget::LocalWorkspace { .. } ) { return if read_only { - open_snapshot_manager_for_view(Path::new(&identity.logical_workspace_path)).await + open_snapshot_manager_for_view(workspace_id).await } else { - get_or_create_snapshot_manager(PathBuf::from(&identity.logical_workspace_path), config) - .await + get_or_create_snapshot_manager(workspace_id, config).await }; } let key = BoundSnapshotKey { - identity: identity.clone(), - runtime_root: runtime_context.runtime_root.clone(), + workspace_id: workspace_id.to_owned(), }; let init_lock = bound_snapshot_init_locks() .lock() @@ -731,13 +739,9 @@ async fn open_bound_snapshot_manager( } #[cfg(test)] -pub(super) async fn clear_bound_snapshot_manager_for_test( - identity: &WorkspaceSessionIdentity, - runtime_root: &Path, -) { +pub(super) async fn clear_bound_snapshot_manager_for_test(workspace_id: &str) { let key = BoundSnapshotKey { - identity: identity.clone(), - runtime_root: runtime_root.into(), + workspace_id: workspace_id.to_owned(), }; let mut managers = bound_snapshot_managers().lock().await; managers.writers.remove(&key); @@ -780,7 +784,7 @@ async fn record_snapshot_manager_new_for_test(workspace_dir: &Path) { #[cfg(test)] fn observe_snapshot_manager_new_for_test(workspace_dir: &Path) { if let Ok(mut observed_workspace) = snapshot_manager_observed_workspace_for_test().write() { - *observed_workspace = Some(snapshot_workspace_key(workspace_dir)); + *observed_workspace = Some(canonical_snapshot_io_path(workspace_dir)); } SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.store(0, Ordering::SeqCst); } @@ -796,12 +800,12 @@ fn set_snapshot_manager_new_delay_for_test(delay: Duration) { } #[cfg(test)] -pub(crate) fn clear_snapshot_manager_for_test(workspace_dir: &Path) { +pub(crate) fn clear_snapshot_manager_for_test(workspace_id: &str) { if let Ok(mut managers) = snapshot_managers().write() { - managers.remove(&snapshot_workspace_key(workspace_dir)); + managers.remove(workspace_id); } if let Ok(mut views) = snapshot_view_managers().write() { - views.remove(&snapshot_workspace_key(workspace_dir)); + views.remove(workspace_id); } } @@ -1138,7 +1142,9 @@ impl WrappedTool { let workspace_fs = context.file_system_for_path(&resolved)?; let runtime_context = context.ensure_current_workspace_runtime().await?; let snapshot_manager = get_or_create_snapshot_manager_with_workspace( - &binding.session_identity, + binding.workspace_id.as_deref().ok_or_else(|| { + crate::OpenBitFunError::service("Workspace ID is required for snapshot tracking") + })?, workspace_fs.clone(), runtime_context, None, @@ -1272,11 +1278,27 @@ impl WrappedTool { } } +async fn local_snapshot_io_root(workspace_id: &str) -> SnapshotResult { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| SnapshotError::ConfigError("Workspace service is unavailable".into()))?; + let record = service + .require_workspace(workspace_id) + .await + .map_err(|error| SnapshotError::ConfigError(error.to_string()))?; + if record.workspace_kind == crate::service::workspace::WorkspaceKind::Remote { + return Err(SnapshotError::ConfigError( + "Local snapshot manager requires a local workspace ID".into(), + )); + } + Ok(record.root_path) +} + pub async fn get_or_create_snapshot_manager( - workspace_dir: PathBuf, + workspace_id: &str, config: Option, ) -> SnapshotResult> { - let workspace_key = snapshot_workspace_key(&workspace_dir); + let workspace_key = workspace_id.to_owned(); + let workspace_dir = local_snapshot_io_root(workspace_id).await?; if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_key) { return Ok(existing); } @@ -1297,7 +1319,7 @@ pub async fn get_or_create_snapshot_manager( "Snapshot manager cold initialization started: workspace={}", workspace_dir.display() ); - let manager = Arc::new(SnapshotManager::new(workspace_key.clone(), config).await?); + let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); { let mut managers = snapshot_managers().write().map_err(|_| { SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()) @@ -1320,8 +1342,8 @@ pub async fn get_or_create_snapshot_manager( Ok(manager) } -pub fn get_snapshot_manager_for_workspace(workspace_dir: &Path) -> Option> { - let workspace_key = snapshot_workspace_key(workspace_dir); +pub fn get_snapshot_manager_for_workspace(workspace_id: &str) -> Option> { + let workspace_key = workspace_id.to_owned(); snapshot_managers() .read() .ok() @@ -1331,9 +1353,10 @@ pub fn get_snapshot_manager_for_workspace(workspace_dir: &Path) -> Option SnapshotResult> { - let workspace_key = snapshot_workspace_key(workspace_dir); + let workspace_dir = local_snapshot_io_root(workspace_id).await?; + let workspace_key = workspace_id.to_owned(); if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_key) { return Ok(manager); } @@ -1352,8 +1375,8 @@ pub async fn open_snapshot_manager_for_view( let started_at = Instant::now(); let runtime_context = - get_workspace_runtime_service_arc().context_for_local_workspace(&workspace_key); - let mut snapshot_service = SnapshotService::new(workspace_key.clone(), runtime_context, None); + get_workspace_runtime_service_arc().context_for_local_workspace(&workspace_dir); + let mut snapshot_service = SnapshotService::new(workspace_dir.clone(), runtime_context, None); snapshot_service.initialize_for_view().await?; let view = Arc::new(SnapshotManager { snapshot_service: Arc::new(RwLock::new(snapshot_service)), @@ -1369,7 +1392,7 @@ pub async fn open_snapshot_manager_for_view( Ok(view) } -fn get_snapshot_view_manager_for_workspace(workspace_key: &Path) -> Option> { +fn get_snapshot_view_manager_for_workspace(workspace_key: &str) -> Option> { snapshot_view_managers() .read() .ok() @@ -1377,22 +1400,22 @@ fn get_snapshot_view_manager_for_workspace(workspace_key: &Path) -> Option SnapshotResult> { - get_snapshot_manager_for_workspace(workspace_dir).ok_or_else(|| { + get_snapshot_manager_for_workspace(workspace_id).ok_or_else(|| { SnapshotError::ConfigError(format!( "Snapshot manager not initialized for workspace: {}", - workspace_dir.display() + workspace_id )) }) } /// Initializes a snapshot manager for the provided workspace. pub async fn initialize_snapshot_manager_for_workspace( - workspace_dir: PathBuf, + workspace_id: &str, config: Option, ) -> SnapshotResult<()> { - get_or_create_snapshot_manager(workspace_dir, config).await?; + get_or_create_snapshot_manager(workspace_id, config).await?; debug!("Snapshot manager initialized for workspace"); Ok(()) } @@ -1425,17 +1448,23 @@ mod tests { use uuid::Uuid; struct TestWorkspace { + id: String, path: PathBuf, } impl TestWorkspace { - fn new() -> Self { + async fn new() -> Self { let path = std::env::temp_dir().join(format!( "openbitfun-snapshot-manager-test-{}", Uuid::new_v4() )); std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } + let record = + crate::service::workspace::legacy_compat::register_local_fixture(&path, None).await; + Self { + path, + id: record.id, + } } fn path(&self) -> &Path { @@ -1445,18 +1474,21 @@ mod tests { impl Drop for TestWorkspace { fn drop(&mut self) { - clear_snapshot_manager_for_test(&self.path); + clear_snapshot_manager_for_test(&self.id); let _ = std::fs::remove_dir_all(&self.path); } } - fn tool_context(workspace: PathBuf, session_id: &str) -> ToolUseContext { + async fn tool_context(workspace: PathBuf, session_id: &str) -> ToolUseContext { + let record = + crate::service::workspace::legacy_compat::register_local_fixture(&workspace, None) + .await; ToolUseContext { tool_call_id: Some("snapshot-write-call".to_string()), agent_type: None, session_id: Some(session_id.to_string()), dialog_turn_id: None, - workspace: Some(WorkspaceBinding::new(None, workspace)), + workspace: Some(WorkspaceBinding::new(Some(record.id.clone()), workspace)), loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), @@ -1564,7 +1596,7 @@ mod tests { #[tokio::test] async fn wrapped_mutation_executes_once_and_records_snapshot() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), @@ -1580,7 +1612,7 @@ mod tests { }); let tool = wrap_tool_for_snapshot_tracking(original.clone()); let input = serde_json::json!({ "file_path": "mutation.txt" }); - let context = tool_context(workspace.path().to_path_buf(), "single-mutation"); + let context = tool_context(workspace.path().to_path_buf(), "single-mutation").await; let results = tool .call(&input, &context) @@ -1609,8 +1641,7 @@ mod tests { tool.render_tool_result_message(&input), original.render_tool_result_message(&input) ); - let manager = - get_snapshot_manager_for_workspace(workspace.path()).expect("snapshot manager"); + let manager = get_snapshot_manager_for_workspace(&workspace.id).expect("snapshot manager"); assert_eq!( manager.get_session_files("single-mutation").await.unwrap(), vec![file.clone()] @@ -1624,7 +1655,7 @@ mod tests { #[tokio::test] async fn wrapped_mutation_executes_once_when_snapshot_start_fails() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let runtime = Arc::new(WorkspaceRuntimeService::new(Arc::new( PathManager::with_user_root_for_tests(workspace.path().join("user-root")), ))); @@ -1634,7 +1665,7 @@ mod tests { .await .unwrap() .context; - get_or_create_snapshot_manager(workspace.path().to_path_buf(), None) + get_or_create_snapshot_manager(&workspace.id, None) .await .unwrap(); block_metadata_storage(&runtime_context.snapshot_metadata_dir); @@ -1651,7 +1682,7 @@ mod tests { let results = tool .call( &serde_json::json!({ "file_path": "mutation.txt" }), - &tool_context(workspace.path().to_path_buf(), "failed-start"), + &tool_context(workspace.path().to_path_buf(), "failed-start").await, ) .await .expect("snapshot start failure must preserve tool success"); @@ -1662,7 +1693,7 @@ mod tests { #[tokio::test] async fn wrapped_mutation_does_not_repeat_when_snapshot_completion_fails() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let runtime = Arc::new(WorkspaceRuntimeService::new(Arc::new( PathManager::with_user_root_for_tests(workspace.path().join("user-root")), ))); @@ -1684,7 +1715,7 @@ mod tests { let results = tool .call( &serde_json::json!({ "file_path": "mutation.txt" }), - &tool_context(workspace.path().to_path_buf(), "failed-completion"), + &tool_context(workspace.path().to_path_buf(), "failed-completion").await, ) .await .expect("snapshot completion failure must preserve tool success"); @@ -1695,7 +1726,7 @@ mod tests { #[tokio::test] async fn wrapped_mutation_preserves_tool_error_without_repeating() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), @@ -1713,7 +1744,7 @@ mod tests { let error = tool .call( &serde_json::json!({ "file_path": "mutation.txt" }), - &tool_context(workspace.path().to_path_buf(), "failed-tool"), + &tool_context(workspace.path().to_path_buf(), "failed-tool").await, ) .await .expect_err("original tool error must propagate"); @@ -1730,16 +1761,23 @@ mod tests { #[cfg(feature = "remote-workspace")] #[tokio::test] async fn wrapped_remote_mutation_preserves_tool_error_without_repeating() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let remote_root = format!("/openbitfun-tests/snapshot-single-call/{}", Uuid::new_v4()); let connection_id = format!("snapshot-test-{}", Uuid::new_v4()); - let mut context = tool_context(PathBuf::from(&remote_root), "failed-remote-tool"); + let mut context = tool_context(workspace.path().to_path_buf(), "failed-remote-tool").await; + let record = crate::service::workspace::legacy_compat::register_remote_fixture( + &remote_root, + &connection_id, + "test-host", + ) + .await; context.workspace = Some(WorkspaceBinding::new_remote( - None, + Some(record.id.clone()), PathBuf::from(&remote_root), connection_id.clone(), "test".into(), openbitfun_services_core::workspace_identity::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "test-host".into(), logical_workspace_path: remote_root.clone(), remote_connection_id: Some(connection_id), @@ -1769,13 +1807,13 @@ mod tests { "{error}" ); assert_single_mutation(&file, &calls, "mutation\n"); - assert!(get_snapshot_manager_for_workspace(Path::new(&remote_root)).is_none()); + assert!(get_snapshot_manager_for_workspace(&record.id).is_none()); } - #[test] - fn delete_keeps_its_input_path_instead_of_canonical_permission_resource() { - let workspace = TestWorkspace::new(); - let context = tool_context(workspace.path().to_path_buf(), "delete-session"); + #[tokio::test] + async fn delete_keeps_its_input_path_instead_of_canonical_permission_resource() { + let workspace = TestWorkspace::new().await; + let context = tool_context(workspace.path().to_path_buf(), "delete-session").await; let tool = super::WrappedTool::new(Arc::new(DeleteFileTool::new())); assert_eq!( @@ -1787,7 +1825,7 @@ mod tests { #[tokio::test] async fn wrapped_delete_rejects_symlink_before_mutation() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), @@ -1802,7 +1840,7 @@ mod tests { if std::os::windows::fs::symlink_file(&target, &link).is_err() { return; } - let context = tool_context(workspace.path().to_path_buf(), "delete-link-session"); + let context = tool_context(workspace.path().to_path_buf(), "delete-link-session").await; let tool = wrap_tool_for_snapshot_tracking(Arc::new(DeleteFileTool::new())); let error = tool @@ -1820,7 +1858,7 @@ mod tests { #[tokio::test] async fn wrapped_write_payload_records_and_rolls_back_created_file() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), @@ -1829,7 +1867,7 @@ mod tests { let alias_anchor = workspace.path().join("alias-anchor"); std::fs::create_dir_all(&alias_anchor).expect("alias anchor"); let workspace_alias = alias_anchor.join(".."); - let context = tool_context(workspace_alias, "write-session"); + let context = tool_context(workspace_alias, "write-session").await; let tool = wrap_tool_for_snapshot_tracking(Arc::new(FileWriteTool::new())); let file = workspace.path().join("new/deep/file.txt"); @@ -1840,7 +1878,7 @@ mod tests { .await .expect("wrapped Write should succeed"); - let manager = get_snapshot_manager_for_workspace(workspace.path()) + let manager = get_snapshot_manager_for_workspace(&workspace.id) .expect("Write should initialize snapshot manager"); assert_eq!( manager @@ -1860,18 +1898,18 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_get_or_create_initializes_snapshot_manager_once_per_workspace() { let _test_guard = snapshot_manager_test_serial_lock().lock().await; - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), ))), )); - clear_snapshot_manager_for_test(workspace.path()); + clear_snapshot_manager_for_test(&workspace.id); observe_snapshot_manager_new_for_test(workspace.path()); set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); - let first = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); - let second = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); + let first = get_or_create_snapshot_manager(&workspace.id, None); + let second = get_or_create_snapshot_manager(&workspace.id, None); let (first, second) = tokio::join!(first, second); set_snapshot_manager_new_delay_for_test(Duration::ZERO); @@ -1885,7 +1923,7 @@ mod tests { #[tokio::test] async fn read_only_view_reloads_persisted_history_without_becoming_a_writer() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), @@ -1893,7 +1931,7 @@ mod tests { )); let file = workspace.path().join("tracked.txt"); tokio::fs::write(&file, "before").await.expect("seed file"); - let writer = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None) + let writer = get_or_create_snapshot_manager(&workspace.id, None) .await .expect("writer manager"); let operation_id = writer @@ -1914,9 +1952,9 @@ mod tests { .complete_file_modification("session-1", &operation_id, 1) .await .expect("complete operation"); - clear_snapshot_manager_for_test(workspace.path()); + clear_snapshot_manager_for_test(&workspace.id); - let view = open_snapshot_manager_for_view(workspace.path()) + let view = open_snapshot_manager_for_view(&workspace.id) .await .expect("read-only view"); @@ -1924,7 +1962,7 @@ mod tests { view.get_session_files("session-1").await.unwrap(), vec![file] ); - assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(get_snapshot_manager_for_workspace(&workspace.id).is_none()); let error = view .record_file_change( "session-2", @@ -1940,18 +1978,18 @@ mod tests { #[tokio::test] async fn view_manager_is_cached_and_superseded_by_a_later_writer() { - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), ))), )); - clear_snapshot_manager_for_test(workspace.path()); + clear_snapshot_manager_for_test(&workspace.id); - let first_view = open_snapshot_manager_for_view(workspace.path()) + let first_view = open_snapshot_manager_for_view(&workspace.id) .await .expect("first view"); - let second_view = open_snapshot_manager_for_view(workspace.path()) + let second_view = open_snapshot_manager_for_view(&workspace.id) .await .expect("second view"); assert!( @@ -1959,10 +1997,10 @@ mod tests { "repeated view opens must reuse the cached view instead of reloading the index" ); - let writer = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None) + let writer = get_or_create_snapshot_manager(&workspace.id, None) .await .expect("writer manager"); - let view_after_writer = open_snapshot_manager_for_view(workspace.path()) + let view_after_writer = open_snapshot_manager_for_view(&workspace.id) .await .expect("view after writer"); assert!( @@ -1978,19 +2016,19 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn read_only_view_waits_for_an_in_flight_writer_initialization() { let _test_guard = snapshot_manager_test_serial_lock().lock().await; - let workspace = TestWorkspace::new(); + let workspace = TestWorkspace::new().await; let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( workspace.path().join("user-root"), ))), )); - clear_snapshot_manager_for_test(workspace.path()); + clear_snapshot_manager_for_test(&workspace.id); observe_snapshot_manager_new_for_test(workspace.path()); set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); - let workspace_path = workspace.path().to_path_buf(); + let workspace_id = workspace.id.clone(); let writer_task = tokio::spawn(async move { - get_or_create_snapshot_manager(workspace_path, None) + get_or_create_snapshot_manager(&workspace_id, None) .await .expect("writer manager") }); @@ -2000,7 +2038,13 @@ mod tests { let alias_anchor = workspace.path().join("alias-anchor"); std::fs::create_dir_all(&alias_anchor).expect("alias anchor"); let workspace_alias = alias_anchor.join(".."); - let view = open_snapshot_manager_for_view(&workspace_alias) + let alias_record = crate::service::workspace::legacy_compat::register_local_fixture( + &workspace_alias, + None, + ) + .await; + assert_eq!(alias_record.id, workspace.id); + let view = open_snapshot_manager_for_view(&alias_record.id) .await .expect("aliased view waits for writer"); let writer = writer_task.await.expect("writer task"); diff --git a/src/crates/assembly/core/src/service/snapshot/workspace_io_tests.rs b/src/crates/assembly/core/src/service/snapshot/workspace_io_tests.rs index 0aea19af2e..fb786b777e 100644 --- a/src/crates/assembly/core/src/service/snapshot/workspace_io_tests.rs +++ b/src/crates/assembly/core/src/service/snapshot/workspace_io_tests.rs @@ -484,11 +484,20 @@ async fn connection_scoped_managers_do_not_read_legacy_or_other_profile_snapshot let fs = Arc::new(RemoteMemoryFs::new(root)); let path = "/remote/shared/file.txt"; fs.write_file(path, b"first-user").await.unwrap(); - let identity = |connection: &str| WorkspaceSessionIdentity { - hostname: "shared-server".into(), - logical_workspace_path: root.into(), - remote_connection_id: Some(connection.into()), - }; + let alice = crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + root, + "alice", + "shared-server", + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; + let bob = crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + root, + "bob", + "shared-server", + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; // Legacy data has no connection owner. Its presence must not make a fresh // profile inherit a previous profile's baseline or operations. let mut legacy = open_core(&context, fs.clone()).await; @@ -500,14 +509,10 @@ async fn connection_scoped_managers_do_not_read_legacy_or_other_profile_snapshot Some(b"legacy"), ) .await; - let first = get_or_create_snapshot_manager_with_workspace( - &identity("alice"), - fs.clone(), - context.clone(), - None, - ) - .await - .unwrap(); + let first = + get_or_create_snapshot_manager_with_workspace(&alice.id, fs.clone(), context.clone(), None) + .await + .unwrap(); assert!(first.get_session_files("session").await.unwrap().is_empty()); let operation = first .record_file_change( @@ -527,7 +532,7 @@ async fn connection_scoped_managers_do_not_read_legacy_or_other_profile_snapshot .complete_file_modification("session", &operation, 1) .await .unwrap(); - let second = open_snapshot_manager_for_workspace_view(&identity("bob"), None, context.clone()) + let second = open_snapshot_manager_for_workspace_view(&bob.id, None, context.clone()) .await .unwrap(); assert!(!Arc::ptr_eq(&first, &second)); @@ -536,15 +541,14 @@ async fn connection_scoped_managers_do_not_read_legacy_or_other_profile_snapshot .await .unwrap() .is_empty()); - let same = open_snapshot_manager_for_workspace_view(&identity("alice"), None, context.clone()) + let same = open_snapshot_manager_for_workspace_view(&alice.id, None, context.clone()) .await .unwrap(); assert!(Arc::ptr_eq(&first, &same)); - clear_bound_snapshot_manager_for_test(&identity("alice"), &context.runtime_root).await; - let offline = - open_snapshot_manager_for_workspace_view(&identity("alice"), None, context.clone()) - .await - .unwrap(); + clear_bound_snapshot_manager_for_test(&alice.id).await; + let offline = open_snapshot_manager_for_workspace_view(&alice.id, None, context.clone()) + .await + .unwrap(); assert!(!Arc::ptr_eq(&first, &offline)); assert_eq!( offline @@ -565,10 +569,15 @@ async fn connection_scoped_managers_do_not_read_legacy_or_other_profile_snapshot .unwrap_err() .to_string() .contains("not explicitly bound")); - let mut mismatch = identity("alice"); - mismatch.hostname = "different-server".into(); + let mismatch = crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + root, + "alice", + "different-server", + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; assert!( - get_or_create_snapshot_manager_with_workspace(&mismatch, fs, context.clone(), None) + get_or_create_snapshot_manager_with_workspace(&mismatch.id, fs, context.clone(), None) .await .is_err() ); @@ -652,17 +661,25 @@ async fn wrapped_remote_write_and_delete_produce_recorded_history_without_a_remo let root = "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/remote/real-tool"; let fs = Arc::new(RemoteMemoryFs::new(root)); let identity = WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname: "shared-server".into(), logical_workspace_path: root.into(), remote_connection_id: Some("real-tool-connection".into()), }; + let workspace = crate::service::workspace::legacy_compat::register_remote_fixture_with_id( + root, + "real-tool-connection", + "shared-server", + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; let mut context = ToolUseContext { tool_call_id: Some("remote-write".into()), agent_type: None, session_id: Some("session".into()), dialog_turn_id: None, workspace: Some(WorkspaceBinding::new_remote( - None, + Some(workspace.id.clone()), PathBuf::from(root), "real-tool-connection".into(), "Shared server".into(), @@ -715,10 +732,9 @@ async fn wrapped_remote_write_and_delete_produce_recorded_history_without_a_remo .await .unwrap()); - let runtime_context = runtime.context_for_remote_workspace(&identity.hostname, root); - clear_bound_snapshot_manager_for_test(&identity, &runtime_context.runtime_root).await; + clear_bound_snapshot_manager_for_test(&workspace.id).await; fs.fail_metadata.store(true, Ordering::SeqCst); - let history = super::manager::open_snapshot_history_for_workspace(&identity) + let history = super::manager::open_snapshot_history_for_workspace(&workspace.id) .await .unwrap(); let write_operation = history diff --git a/src/crates/assembly/core/src/service/workspace/legacy_compat.rs b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs new file mode 100644 index 0000000000..9bf85aff4a --- /dev/null +++ b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs @@ -0,0 +1,1407 @@ +//! Temporary upgrade adapter for pre-ID workspace references (including 1.0.0). +//! +//! Paths are NOT workspace keys. Only legacy persisted data and old protocol +//! ingress may call this adapter. Normal runtime code must use `require_workspace`. +//! Successful conversion must retain the record ID; never store the input path +//! as its identity. Ambiguous and missing references must not select another +//! workspace, create a local fallback, or delete user data. +//! +//! Sunset: remove after supported persisted data has migrated and all supported +//! peers negotiate ID-based workspace references. Do not add new path callers. +use super::{WorkspaceInfo, WorkspaceKind, WorkspaceService}; +use crate::{OpenBitFunError, OpenBitFunResult}; + +pub(crate) fn resolve_legacy_workspace_reference( + records: &[WorkspaceInfo], + workspace_id: Option<&str>, + path: &str, + connection_id: Option<&str>, + ssh_host: Option<&str>, +) -> OpenBitFunResult> { + let text = |value: Option<&str>| { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + let connection_id = text(connection_id); + let ssh_host = text(ssh_host); + let path_matches = |workspace: &&WorkspaceInfo| { + if workspace.workspace_kind == WorkspaceKind::Remote { + openbitfun_services_core::workspace_identity::normalize_remote_workspace_path( + &workspace.root_path.to_string_lossy(), + ) == openbitfun_services_core::workspace_identity::normalize_remote_workspace_path(path) + } else { + let local = std::path::Path::new(path); + workspace.root_path == local + || dunce::canonicalize(local).is_ok_and(|root| root == workspace.root_path) + } + }; + if let Some(id) = workspace_id { + // An explicit ID is authoritative. A stale or unknown ID must never + // fall through to a similarly named folder on this or another host. + return records + .iter() + .find(|record| record.id == id) + .cloned() + .map(Some) + .ok_or_else(|| { + OpenBitFunError::service(format!("Workspace ID is unavailable on this host: {id}")) + }); + } + let mut candidates: Vec<_> = records.iter().filter(path_matches).collect(); + if let Some(id) = connection_id.as_deref() { + candidates.retain(|workspace| { + workspace.workspace_kind == WorkspaceKind::Remote + && workspace.remote_ssh_connection_id() == Some(id) + }); + } + if let Some(host) = ssh_host + .as_deref() + .filter(|host| *host != "localhost" || connection_id.is_some()) + { + candidates.retain(|workspace| { + workspace.workspace_kind == WorkspaceKind::Remote + && workspace.metadata.get("sshHost").and_then(|v| v.as_str()) == Some(host) + }); + } + match candidates.as_slice() { + [] => Ok(None), + [record] => Ok(Some((*record).clone())), + _ => Err(OpenBitFunError::service( + "Workspace path is ambiguous; select a workspace by its ID or saved SSH connection", + )), + } +} + +impl WorkspaceService { + pub async fn resolve_legacy_workspace_reference( + &self, + workspace_id: Option<&str>, + path: &str, + connection_id: Option<&str>, + ssh_host: Option<&str>, + ) -> OpenBitFunResult> { + resolve_legacy_workspace_reference( + &self.list_workspace_infos().await, + workspace_id, + path, + connection_id, + ssh_host, + ) + } +} + +// The following adapter is exclusively for old path-only open requests. It is +// part of the same sunset boundary as resolve_legacy_workspace_reference. +use std::path::PathBuf; + +impl WorkspaceService { + /// Upgrade-only open adapter for pre-ID requests, including 1.0.0. + /// New callers must select by ID or explicitly create a local/remote workspace. + pub async fn upgrade_legacy_workspace_open( + &self, + path: PathBuf, + preferred_connection_id: Option<&str>, + preferred_ssh_host: Option<&str>, + ) -> OpenBitFunResult { + let path_str = path.to_string_lossy().to_string(); + let known = self + .resolve_legacy_workspace_open(&path_str, preferred_connection_id, preferred_ssh_host) + .await?; + self.finish_legacy_workspace_open(path, known).await + } + + pub(crate) async fn finish_legacy_workspace_open( + &self, + path: PathBuf, + known_remote: Option, + ) -> OpenBitFunResult { + let path_str = path.to_string_lossy().to_string(); + if let Some(known) = known_remote { + return if known.workspace_kind == WorkspaceKind::Remote { + self.open_known_remote_workspace(&known).await + } else { + self.open_workspace_by_id(&known.id).await + }; + } + match self.open_workspace(path).await { + Ok(info) => Ok(info), + Err(error) => { + let message = error.to_string(); + if message.contains("Workspace path does not exist") { + Err(OpenBitFunError::service(format!( + "Workspace path does not exist locally and is not a known remote SSH \ + workspace: {path_str}. Open it once from the desktop SSH remote UI so \ + OpenBitFun can remember the connection, then try again." + ))) + } else { + Err(error) + } + } + } + } + + /// Resolve persisted identity or verify a new path against an existing SSH + /// connection. Caller input alone never authorizes a remote runtime scope. + pub(crate) async fn resolve_legacy_workspace_open( + &self, + path: &str, + connection_id: Option<&str>, + ssh_host: Option<&str>, + ) -> OpenBitFunResult> { + let known = self + .resolve_legacy_workspace_reference(None, path, connection_id, ssh_host) + .await?; + if let Some(record) = known.as_ref() { + return if record.workspace_kind == WorkspaceKind::Remote { + if record.remote_ssh_connection_id().is_none() { + Err(OpenBitFunError::service( + "Remote workspace record is missing its saved SSH connection ID", + )) + } else { + Ok(known) + } + } else { + Ok(known) + }; + } + let connection_id = connection_id.map(str::trim).filter(|id| !id.is_empty()); + let ssh_host = ssh_host.map(str::trim).filter(|id| !id.is_empty()); + if connection_id.is_none() && ssh_host.is_none() { + return Ok(known); + } + let connection_id = connection_id.ok_or_else(|| { + OpenBitFunError::service( + "Opening a remote workspace requires its saved SSH connection ID", + ) + })?; + self.prepare_remote_workspace(path, connection_id, ssh_host) + .await + .map(Some) + } +} + +#[cfg(feature = "agent-runtime")] +impl crate::agentic::coordination::ConversationCoordinator { + /// Gates workspace attachment before opening it, then prepares local + /// Snapshot ownership without treating remote workspaces as local paths. + pub async fn upgrade_legacy_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: PathBuf, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + snapshot_log_context: &str, + ) -> OpenBitFunResult { + let known_remote = workspace_service + .resolve_legacy_workspace_open( + &path.to_string_lossy(), + remote_connection_id, + remote_ssh_host, + ) + .await?; + if known_remote.is_none() && !path.exists() { + return Err(OpenBitFunError::service(format!( + "Workspace path does not exist locally and is not a known remote SSH workspace: {}. Open it once from the desktop SSH remote UI so OpenBitFun can remember the connection, then try again.", + path.display() + ))); + } + // Caller-provided remote facts only select a known workspace. They are + // not authority to bypass the local Runtime ownership lease. + let resolved_connection_id = known_remote + .as_ref() + .filter(|workspace| workspace.workspace_kind == WorkspaceKind::Remote) + .and_then(WorkspaceInfo::remote_ssh_connection_id) + .map(ToOwned::to_owned); + let resolved_ssh_host = known_remote + .as_ref() + .filter(|workspace| workspace.workspace_kind == WorkspaceKind::Remote) + .and_then(|workspace| { + workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + }); + if let Some(connection_id) = resolved_connection_id.as_deref() { + self.ensure_verified_remote_workspace_runtime_ownership( + &path, + connection_id, + resolved_ssh_host.as_deref(), + )?; + } else { + self.ensure_runtime_ownership(&path, None, None)?; + } + let info = workspace_service + .finish_legacy_workspace_open(path, known_remote) + .await?; + if info.workspace_kind != WorkspaceKind::Remote { + if let Err(error) = + crate::service::snapshot::initialize_snapshot_manager_for_workspace(&info.id, None) + .await + { + log::error!( + "Failed to initialize snapshot after {}: {}", + snapshot_log_context, + error + ); + } + } + Ok(info) + } +} + +#[cfg(test)] +async fn fixture_workspace_service() -> std::sync::Arc { + use std::sync::Arc; + static SERVICE: tokio::sync::OnceCell<(tempfile::TempDir, Arc)> = + tokio::sync::OnceCell::const_new(); + let (_, service) = SERVICE + .get_or_init(|| async { + let root = tempfile::tempdir().unwrap(); + let paths = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests( + root.path().join("user"), + ), + ); + let service = Arc::new(WorkspaceService::new_for_test_path_manager(paths).await); + super::set_global_workspace_service(service.clone()); + (root, service) + }) + .await; + service.clone() +} + +#[cfg(test)] +pub(crate) async fn register_local_fixture( + path: &std::path::Path, + project: Option<&std::path::Path>, +) -> WorkspaceInfo { + use super::{ + WorkspaceExport, WorkspaceInfoRuntimeExt, WorkspaceOpenOptions, WorkspaceWorktreeInfo, + }; + let service = fixture_workspace_service().await; + let mut record = + WorkspaceInfo::new_without_worktree(path.to_path_buf(), WorkspaceOpenOptions::default()) + .await + .unwrap(); + if let Some(project) = project { + record.worktree = Some(WorkspaceWorktreeInfo { + main_workspace_id: None, + path: path.to_string_lossy().into_owned(), + main_repo_path: project.to_string_lossy().into_owned(), + branch: None, + is_main: false, + }); + } + let mut records = vec![record.clone()]; + if let Some(project) = project.filter(|project| *project != path) { + records.push( + WorkspaceInfo::new_without_worktree( + project.to_path_buf(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(), + ); + } + service + .import_workspaces( + WorkspaceExport { + workspaces: records, + current_workspace_id: None, + primary_assistant_key: None, + recent_workspaces: vec![], + recent_assistant_workspaces: vec![], + export_timestamp: "test".into(), + version: "1".into(), + }, + true, + ) + .await + .unwrap(); + service.require_workspace(&record.id).await.unwrap() +} + +/// Synchronous form of [`register_local_fixture`] for test helpers that build +/// their workspace directory outside an async context (for example a +/// `TestWorkspace::new()` constructor). Production hosts register a directory +/// before creating sessions in it; a fixture directory used as a session +/// workspace must do the same, otherwise path-only session configs are +/// (correctly) rejected as unavailable legacy references. +/// +/// The registration runs on its own thread with a private current-thread +/// runtime so it can be called from inside or outside another Tokio runtime. +#[cfg(test)] +pub(crate) fn register_local_fixture_blocking(path: &std::path::Path) -> WorkspaceInfo { + let path = path.to_path_buf(); + std::thread::Builder::new() + .name("workspace-fixture-registration".into()) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("fixture registration runtime") + .block_on(register_local_fixture(&path, None)) + }) + .expect("spawn fixture registration thread") + .join() + .expect("fixture registration thread") +} + +/// Test fixtures register real persisted workspace records before exercising +/// session routing; transport metadata alone is deliberately insufficient. +#[cfg(test)] +pub(crate) async fn register_remote_fixture( + path: &str, + connection: &str, + host: &str, +) -> WorkspaceInfo { + register_remote_fixture_with_id(path, connection, host, None).await +} + +#[cfg(test)] +pub(crate) async fn register_remote_fixture_with_id( + path: &str, + connection: &str, + host: &str, + id: Option<&str>, +) -> WorkspaceInfo { + use super::{WorkspaceExport, WorkspaceInfoRuntimeExt, WorkspaceOpenOptions}; + let service = fixture_workspace_service().await; + let mut record = WorkspaceInfo::new_without_worktree( + std::path::PathBuf::from(path), + WorkspaceOpenOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some(connection.to_owned()), + remote_ssh_host: Some(host.to_owned()), + stable_workspace_id: Some( + openbitfun_services_core::workspace_identity::remote_workspace_stable_id( + host, path, + ), + ), + ..Default::default() + }, + ) + .await + .unwrap(); + if let Some(id) = id { + record.id = id.to_owned(); + } + let result = service + .import_workspaces( + WorkspaceExport { + workspaces: vec![record.clone()], + current_workspace_id: None, + primary_assistant_key: None, + recent_workspaces: vec![], + recent_assistant_workspaces: vec![], + export_timestamp: "test".into(), + version: "1".into(), + }, + true, + ) + .await + .unwrap(); + assert_eq!(result.imported_workspaces, 1); + record +} + +#[cfg(all(test, feature = "agent-runtime"))] +mod tests { + use super::*; + use crate::agentic::core::SessionConfig; + use crate::agentic::workspace::{apply_workspace_record, reject_session_storage_root}; + use std::sync::Arc; + + #[cfg(feature = "scheduled-jobs")] + #[tokio::test] + async fn cron_100_target_upgrades_once_and_round_trips_without_losing_io() { + use crate::service::cron::CronJobTarget; + let root = tempfile::tempdir().unwrap(); + let local = register_local_fixture(root.path(), None).await; + let remote = register_remote_fixture( + &local.root_path.to_string_lossy(), + "cron-upgrade-ssh", + "cron.example", + ) + .await; + let payload = serde_json::json!({ + "kind": "workspace", "workspace": { + "workspacePath": remote.root_path, "remoteConnectionId": "cron-upgrade-ssh", + "remoteSshHost": "cron.example" + }, "launch": { "agentType": "Standard" } + }); + let legacy: CronJobTarget = serde_json::from_value(payload.clone()).unwrap(); + let upgraded = upgrade_legacy_cron_target(legacy).await.unwrap(); + assert_eq!( + upgraded.workspace().workspace_id.as_deref(), + Some(remote.id.as_str()) + ); + let round_trip: CronJobTarget = + serde_json::from_value(serde_json::to_value(&upgraded).unwrap()).unwrap(); + assert_eq!(round_trip, upgraded); + assert_eq!( + round_trip.workspace().workspace_path, + payload["workspace"]["workspacePath"].as_str().unwrap() + ); + let mut ambiguous = payload.clone(); + ambiguous["workspace"] + .as_object_mut() + .unwrap() + .remove("remoteConnectionId"); + ambiguous["workspace"] + .as_object_mut() + .unwrap() + .remove("remoteSshHost"); + assert!( + upgrade_legacy_cron_target(serde_json::from_value(ambiguous).unwrap()) + .await + .is_err() + ); + let id_only: CronJobTarget = serde_json::from_value(serde_json::json!({ + "kind": "workspace", "workspace": { "workspaceId": local.id }, + "launch": { "agentType": "Standard" } + })) + .unwrap(); + assert_eq!( + upgrade_legacy_cron_target(id_only.clone()).await.unwrap(), + id_only + ); + } + + #[tokio::test] + async fn current_local_io_scope_uses_ids_or_an_explicit_controller_target() { + let root = tempfile::tempdir().unwrap(); + let local = register_local_fixture(root.path(), None).await; + let remote = register_remote_fixture( + &local.root_path.to_string_lossy(), + "io-scope-ssh", + "io.example", + ) + .await; + let path = local.root_path.to_string_lossy(); + assert!(!remote_io_for_legacy_or_id(Some(&local.id), false, &path) + .await + .unwrap()); + assert!(remote_io_for_legacy_or_id(Some(&remote.id), false, &path) + .await + .unwrap()); + assert!(remote_io_for_legacy_or_id(Some(&remote.id), true, &path) + .await + .unwrap()); + assert!(!remote_io_for_legacy_or_id(None, true, &path).await.unwrap()); + assert!( + remote_io_for_legacy_or_id(Some("missing-io-id"), true, &path) + .await + .is_err() + ); + } + + #[tokio::test] + async fn old_worktree_owner_is_migrated_once_and_never_reselected_by_path() { + use super::super::{WorkspaceInfoRuntimeExt, WorkspaceOpenOptions, WorkspaceWorktreeInfo}; + let root = tempfile::tempdir().unwrap(); + let parent = WorkspaceInfo::new_without_worktree( + root.path().to_path_buf(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(); + let mut execution = parent.clone(); + execution.id = "execution-id".into(); + execution.root_path = root.path().join("worktree"); + execution.worktree = Some( + serde_json::from_value::(serde_json::json!({ + "path": execution.root_path, "mainRepoPath": parent.root_path, "isMain": false + })) + .unwrap(), + ); + assert!(execution.project_workspace_id().is_err()); + let mut remote = parent.clone(); + remote.id = "same-path-remote".into(); + remote.workspace_kind = WorkspaceKind::Remote; + let mut records = std::collections::HashMap::from([ + (parent.id.clone(), parent.clone()), + (execution.id.clone(), execution.clone()), + (remote.id.clone(), remote), + ]); + upgrade_legacy_worktree_references( + &mut records, + &crate::infrastructure::get_path_manager_arc(), + ) + .await; + assert_eq!( + records[&execution.id].project_workspace_id().unwrap(), + parent.id + ); + let serialized = serde_json::to_value(&records[&execution.id]).unwrap(); + assert_eq!(serialized["worktree"]["mainWorkspaceId"], parent.id); + records + .get_mut(&execution.id) + .unwrap() + .worktree + .as_mut() + .unwrap() + .main_repo_path = "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/stale/projection".into(); + upgrade_legacy_worktree_references( + &mut records, + &crate::infrastructure::get_path_manager_arc(), + ) + .await; + assert_eq!( + records[&execution.id].project_workspace_id().unwrap(), + parent.id + ); + let mut collision = parent.clone(); + collision.id = "another-local-parent".into(); + records.insert(collision.id.clone(), collision); + records.insert(execution.id.clone(), execution.clone()); + upgrade_legacy_worktree_references( + &mut records, + &crate::infrastructure::get_path_manager_arc(), + ) + .await; + assert!(records[&execution.id].project_workspace_id().is_err()); + assert_eq!(records.len(), 4); + } + + #[tokio::test] + async fn legacy_worktree_registers_missing_parent_once_and_preserves_unavailable_records() { + use super::super::{WorkspaceInfoRuntimeExt, WorkspaceOpenOptions, WorkspaceWorktreeInfo}; + let root = tempfile::tempdir().unwrap(); + let paths = + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().join("user")); + let execution_root = root.path().join("tree"); + let project_root = root.path().join("project"); + std::fs::create_dir_all(&execution_root).unwrap(); + std::fs::create_dir_all(&project_root).unwrap(); + let mut execution = WorkspaceInfo::new_without_worktree( + execution_root.clone(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(); + execution.worktree = Some(WorkspaceWorktreeInfo { + path: execution_root.to_string_lossy().into_owned(), + main_repo_path: project_root.to_string_lossy().into_owned(), + main_workspace_id: None, + branch: None, + is_main: false, + }); + let mut records = + std::collections::HashMap::from([(execution.id.clone(), execution.clone())]); + upgrade_legacy_worktree_references(&mut records, &paths).await; + let owner_id = records[&execution.id] + .project_workspace_id() + .unwrap() + .to_owned(); + assert_eq!( + records[&owner_id].root_path, + dunce::canonicalize(&project_root).unwrap() + ); + assert_eq!(records.len(), 2); + upgrade_legacy_worktree_references(&mut records, &paths).await; + assert_eq!(records.len(), 2); + assert_eq!( + records[&execution.id].project_workspace_id().unwrap(), + owner_id + ); + + for unavailable in [ + root.path().join("missing"), + paths.projects_root().join("hidden"), + ] { + if unavailable.starts_with(paths.projects_root()) { + std::fs::create_dir_all(&unavailable).unwrap(); + } + execution.worktree.as_mut().unwrap().main_repo_path = + unavailable.to_string_lossy().into_owned(); + let mut legacy = + std::collections::HashMap::from([(execution.id.clone(), execution.clone())]); + upgrade_legacy_worktree_references(&mut legacy, &paths).await; + assert_eq!(legacy.len(), 1); + assert!(legacy[&execution.id].project_workspace_id().is_err()); + assert_eq!(legacy[&execution.id].worktree, execution.worktree); + } + } + + #[tokio::test] + async fn legacy_worktree_migration_resolves_execution_and_project_separately() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let execution = root.path().join("worktree"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&execution).unwrap(); + let record = register_local_fixture(&execution, Some(&project)).await; + let mut config = SessionConfig { + workspace_path: Some(execution.to_string_lossy().into_owned()), + project_workspace_path: Some(project.to_string_lossy().into_owned()), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .unwrap(); + assert_eq!(config.workspace_id.as_deref(), Some(record.id.as_str())); + assert_ne!(config.project_workspace_id, config.workspace_id); + assert_eq!(config.workspace_path.as_deref(), record.root_path.to_str()); + assert_eq!( + config.project_workspace_path.as_deref(), + dunce::canonicalize(&project).unwrap().to_str() + ); + // Rehydration uses IDs even when stale projections look like another workspace. + config.workspace_path = Some(project.to_string_lossy().into_owned()); + config.remote_connection_id = Some("stale-ssh-id".into()); + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .unwrap(); + assert_eq!(config.workspace_path.as_deref(), record.root_path.to_str()); + assert_eq!(config.workspace_kind, Some(WorkspaceKind::Normal)); + assert_eq!(config.remote_connection_id, None); + } + + #[tokio::test] + async fn unknown_legacy_local_folder_cannot_become_an_unregistered_workspace() { + let root = tempfile::tempdir().unwrap(); + let _ = fixture_workspace_service().await; + let mut config = SessionConfig { + workspace_path: Some(root.path().to_string_lossy().into_owned()), + ..Default::default() + }; + let original = config.workspace_path.clone(); + assert!( + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .is_err() + ); + assert_eq!(config.workspace_id, None); + assert_eq!(config.workspace_kind, None); + assert_eq!(config.workspace_path, original); + assert!(root.path().exists()); + } + + #[tokio::test] + async fn id_only_restore_uses_record_type_and_separates_execution_from_storage() { + use crate::agentic::session::CoreSessionStorePort; + use openbitfun_runtime_ports::{SessionStorageKind, SessionStorePort}; + let logical_root = format!("/srv/id-only-{}", uuid::Uuid::new_v4()); + let record = register_remote_fixture(&logical_root, "ssh-localhost", "localhost").await; + let mut config = SessionConfig { + workspace_id: Some(record.id.clone()), + remote_connection_id: Some("untrusted-request".into()), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .unwrap(); + assert_eq!( + config.workspace_path.as_deref(), + Some(logical_root.as_str()) + ); + assert_eq!( + config.remote_connection_id.as_deref(), + Some("ssh-localhost") + ); + assert!(config.is_remote_workspace()); + let temp = tempfile::tempdir().unwrap(); + let paths = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(temp.path().join("user")), + ); + let resolution = CoreSessionStorePort::with_path_manager_for_tests(paths.clone()) + .resolve_workspace_storage(&record.id) + .await + .unwrap(); + assert_eq!(resolution.storage_kind, SessionStorageKind::Remote); + assert_eq!(resolution.requested_workspace_path, record.root_path); + assert!(resolution + .effective_storage_path + .starts_with(paths.remote_ssh_mirror_root_dir())); + assert_ne!(resolution.effective_storage_path, record.root_path); + assert!(CoreSessionStorePort::with_path_manager_for_tests(paths) + .resolve_workspace_storage(&logical_root) + .await + .is_err()); + } + + #[tokio::test] + async fn workspace_kind_controls_legacy_identity_and_path_resolution() { + let root = tempfile::tempdir().unwrap(); + let paths = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().join("data")), + ); + let service = WorkspaceService::new_for_test_path_manager(paths.clone()).await; + let project = root.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let local = service.open_workspace(project.clone()).await.unwrap(); + let path = local.root_path.to_string_lossy().to_string(); + // Exact 1.0.0 shape: no workspace_kind in session config and a local host marker. + let mut json = serde_json::to_value(SessionConfig::default()).unwrap(); + json["workspace_path"] = serde_json::json!(path); + json["remote_ssh_host"] = serde_json::json!("localhost"); + json.as_object_mut().unwrap().remove("workspace_kind"); + let mut legacy: SessionConfig = serde_json::from_value(json).unwrap(); + let resolved = resolve_legacy_workspace_reference( + &[local.clone()], + None, + &path, + None, + Some("localhost"), + ) + .unwrap() + .unwrap(); + apply_workspace_record(&mut legacy, &resolved).unwrap(); + assert_eq!(legacy.workspace_kind, Some(WorkspaceKind::Normal)); + assert!(legacy.remote_ssh_host.is_none()); + assert!(legacy.remote_connection_id.is_none()); + assert!(!legacy.is_remote_workspace()); + let round_trip: SessionConfig = + serde_json::from_value(serde_json::to_value(&legacy).unwrap()).unwrap(); + assert_eq!(round_trip.workspace_kind, legacy.workspace_kind); + let reopened = service + .resolve_legacy_workspace_open(&path, None, Some("localhost")) + .await + .unwrap() + .unwrap(); + assert_eq!(reopened.id, local.id); + assert_eq!(reopened.workspace_kind, WorkspaceKind::Normal); + + let mut remote = local.clone(); + remote.id = "ssh-workspace".into(); + remote.workspace_kind = WorkspaceKind::Remote; + remote + .metadata + .insert("connectionId".into(), serde_json::json!("saved-ssh")); + // localhost can also be a real SSH host. Type decides, never host spelling. + let records = [local.clone(), remote.clone()]; + assert!(resolve_legacy_workspace_reference(&records, None, &path, None, None).is_err()); + let resolved = + resolve_legacy_workspace_reference(&records, Some(&remote.id), &path, None, None) + .unwrap() + .unwrap(); + apply_workspace_record(&mut legacy, &resolved).unwrap(); + assert!(legacy.is_remote_workspace()); + assert_eq!(legacy.remote_connection_id.as_deref(), Some("saved-ssh")); + assert_eq!(legacy.remote_ssh_host.as_deref(), Some("localhost")); + let resolved = resolve_legacy_workspace_reference( + &records, + Some(&local.id), + &path, + Some("wrong-id"), + Some("wrong-host"), + ) + .unwrap() + .unwrap(); + apply_workspace_record(&mut legacy, &resolved).unwrap(); + assert!(!legacy.is_remote_workspace()); + assert!(legacy.remote_connection_id.is_none()); + remote.metadata.remove("connectionId"); + assert!(apply_workspace_record(&mut legacy, &remote).is_err()); + + let storage = paths + .remote_ssh_mirror_root_dir() + .join("localhost/project/sessions"); + std::fs::create_dir_all(&storage).unwrap(); + assert!(reject_session_storage_root( + &storage, + &paths.projects_root(), + &paths.remote_ssh_mirror_root_dir() + ) + .is_err()); + assert!(reject_session_storage_root( + &project, + &paths.projects_root(), + &paths.remote_ssh_mirror_root_dir() + ) + .is_ok()); + let resolved = resolve_legacy_workspace_reference( + &records, + Some(&remote.id), + &storage.to_string_lossy(), + None, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(resolved.root_path, remote.root_path); + assert!(resolve_legacy_workspace_reference( + &records, + Some("deleted-workspace"), + &path, + None, + None + ) + .is_err()); + } +} + +/// Temporary pre-ID wire adapter. Paths may only be resolved here, never used +/// as runtime workspace keys. A missing or ambiguous reference fails closed. +pub(crate) async fn upgrade_optional_workspace_reference( + workspace_id: Option<&str>, + legacy_path: Option<&str>, +) -> Result, String> { + if workspace_id.is_none() && legacy_path.is_none() { + return Ok(None); + } + let service = super::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + if let Some(id) = workspace_id { + return service + .require_workspace(id) + .await + .map(Some) + .map_err(|error| error.to_string()); + } + service + .resolve_legacy_workspace_reference(None, legacy_path.unwrap_or_default(), None, None) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Legacy workspace reference cannot be resolved".to_string()) + .map(Some) +} + +#[cfg(feature = "external-sources")] +pub(crate) fn upgrade_external_policy_key( + overrides: &mut std::collections::BTreeMap, + records: &[WorkspaceInfo], + workspace_id: &str, + new_key: &str, +) -> Result { + use sha2::{Digest, Sha256}; + let record = records + .iter() + .find(|record| record.id == workspace_id) + .ok_or_else(|| "Unknown workspace ID during policy migration".to_string())?; + if overrides.contains_key(new_key) { + return Ok(false); + } + // Reproduce only the retired on-disk key algorithm. Runtime routes never + // use this value. Preserve old entries for rollback and old readers. + let legacy_key = |record: &WorkspaceInfo| { + let root = + dunce::canonicalize(&record.root_path).unwrap_or_else(|_| record.root_path.clone()); + let normalized = root.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + let normalized = normalized.to_ascii_lowercase(); + let digest = Sha256::digest(normalized.as_bytes()); + format!("workspace:{}", hex::encode(&digest[..16])) + }; + let old_key = legacy_key(record); + let Some(value) = overrides.get(&old_key).cloned() else { + return Ok(false); + }; + if records + .iter() + .filter(|other| legacy_key(other) == old_key) + .count() + != 1 + { + return Err("Legacy external integration policy belongs to an ambiguous workspace; original settings were preserved".into()); + } + overrides.insert(new_key.to_owned(), value); + Ok(true) +} + +#[cfg(all(test, feature = "external-sources"))] +mod external_policy_upgrade_tests { + use super::super::{WorkspaceInfoRuntimeExt, WorkspaceOpenOptions}; + use super::*; + use sha2::{Digest, Sha256}; + use std::collections::BTreeMap; + + #[tokio::test] + async fn copies_legacy_policy_once_without_overwriting_id_settings_or_old_data() { + let root = tempfile::tempdir().unwrap(); + let record = WorkspaceInfo::new_without_worktree( + root.path().to_owned(), + WorkspaceOpenOptions::default(), + ) + .await + .unwrap(); + let path = dunce::canonicalize(root.path()) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + #[cfg(windows)] + let path = path.to_ascii_lowercase(); + let legacy_key = format!( + "workspace:{}", + hex::encode(&Sha256::digest(path.as_bytes())[..16]) + ); + let mut settings = BTreeMap::from([(legacy_key.clone(), false)]); + let records = vec![record.clone()]; + assert!( + upgrade_external_policy_key(&mut settings, &records, &record.id, "new-id-key").unwrap() + ); + assert_eq!(settings.get("new-id-key"), Some(&false)); + assert_eq!(settings.get(&legacy_key), Some(&false)); + settings.insert("new-id-key".into(), true); + assert!( + !upgrade_external_policy_key(&mut settings, &records, &record.id, "new-id-key") + .unwrap() + ); + assert_eq!(settings.get("new-id-key"), Some(&true)); + settings.remove("new-id-key"); + let mut remote = record.clone(); + remote.id = "another-workspace".into(); + remote.workspace_kind = WorkspaceKind::Remote; + let before = settings.clone(); + assert!(upgrade_external_policy_key( + &mut settings, + &[record.clone(), remote], + &record.id, + "new-id-key" + ) + .is_err()); + assert_eq!(settings, before); + } + + #[test] + fn route_keys_preserve_id_case_and_never_canonicalize_as_paths() { + use crate::agentic::workspace::workspace_route_key; + assert_eq!(workspace_route_key(Some("Alpha")), "workspace:Alpha"); + assert_ne!( + workspace_route_key(Some("Alpha")), + workspace_route_key(Some("alpha")) + ); + assert_ne!( + workspace_route_key(Some("Alpha")), + workspace_route_key(None) + ); + } +} + +/// Upgrade-only hydration for persisted 1.0.0 session references. Execution and +/// storage roots have different roles: resolve the execution object first, then +/// migrate the owning project relationship. Unknown or ambiguous data is retained +/// by the persistence owner and reported as unavailable, never made local. +#[cfg(feature = "agent-runtime")] +pub(crate) async fn upgrade_session_workspace_reference( + config: &mut crate::agentic::core::SessionConfig, + service: &WorkspaceService, +) -> OpenBitFunResult<()> { + if config.workspace_id.is_none() { + let path = config + .workspace_path + .as_deref() + .or(config.project_workspace_path.as_deref()) + .ok_or_else(|| OpenBitFunError::service("Session has no workspace reference"))?; + let record = service + .resolve_legacy_workspace_reference( + None, + path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) + .await? + .ok_or_else(|| { + OpenBitFunError::service( + "Legacy session workspace is unavailable; restore its workspace record", + ) + })?; + config.workspace_id = Some(record.id); + } + if config.project_workspace_id.is_none() { + let record = service + .require_workspace(config.workspace_id.as_deref().expect("workspace resolved")) + .await?; + config.project_workspace_id = Some( + record + .project_workspace_id() + .map_err(OpenBitFunError::service)? + .to_owned(), + ); + } + Ok(()) +} + +#[cfg(feature = "agent-runtime")] +impl WorkspaceService { + /// Upgrade-only conversion of an old Runtime binding projection. New producers + /// send IDs and kind; this adapter never infers kind from SSH metadata. + pub async fn upgrade_legacy_session_binding( + &self, + binding: openbitfun_runtime_ports::AgentSessionWorkspaceBinding, + ) -> OpenBitFunResult { + let mut config = crate::agentic::core::SessionConfig { + workspace_id: binding.workspace_id, + project_workspace_id: binding.project_workspace_id, + workspace_kind: binding.workspace_kind, + workspace_path: Some(binding.workspace_path), + project_workspace_path: binding.project_workspace_path, + execution_target: binding.execution_target, + remote_connection_id: binding.remote_connection_id, + remote_ssh_host: binding.remote_ssh_host, + ..Default::default() + }; + upgrade_session_workspace_reference(&mut config, self).await?; + let record = self + .require_workspace(config.workspace_id.as_deref().expect("resolved workspace")) + .await?; + let project = self + .require_workspace( + config + .project_workspace_id + .as_deref() + .expect("resolved project"), + ) + .await?; + if (record.workspace_kind == WorkspaceKind::Remote + || project.workspace_kind == WorkspaceKind::Remote) + && record.id != project.id + { + return Err(OpenBitFunError::service( + "Remote workspace cannot use another project workspace", + )); + } + crate::agentic::workspace::apply_workspace_record(&mut config, &record)?; + Ok(openbitfun_runtime_ports::AgentSessionWorkspaceBinding { + workspace_kind: config.workspace_kind, + workspace_id: config.workspace_id, + project_workspace_id: config.project_workspace_id, + workspace_path: record.root_path.to_string_lossy().into_owned(), + project_workspace_path: Some(project.root_path.to_string_lossy().into_owned()), + execution_target: config.execution_target, + remote_connection_id: config.remote_connection_id, + remote_ssh_host: config.remote_ssh_host, + }) + } +} + +#[cfg(feature = "remote-workspace")] +impl WorkspaceService { + /// Upgrade-only ingress for pre-ID filesystem requests. Never call this from + /// current workspace operations: resolve their ID and use the record's kind. + /// Device directory browsers carry an explicit connection (empty means local). + pub async fn upgrade_legacy_file_connection( + &self, + path: &str, + legacy_workspace_path: Option<&str>, + connection_id: Option<&str>, + ) -> OpenBitFunResult> { + if let Some(root) = legacy_workspace_path.filter(|value| !value.is_empty()) { + let record = self + .resolve_legacy_workspace_reference( + None, + root, + connection_id.filter(|id| !id.is_empty()), + None, + ) + .await? + .ok_or_else(|| { + OpenBitFunError::service( + "Legacy filesystem workspace cannot be resolved; select its ID", + ) + })?; + return record + .filesystem_connection_id() + .map(|id| id.map(str::to_owned)) + .map_err(OpenBitFunError::service); + } + if let Some(id) = connection_id { + return Ok((!id.is_empty()).then(|| id.to_owned())); + } + // Old file browsers omitted the target. Compare only registered IO + // projections inside this upgrade boundary. An active connection is + // never authority, and colliding local/remote providers are ambiguous. + let mut providers = std::collections::BTreeSet::new(); + let local_path = + dunce::canonicalize(path).unwrap_or_else(|_| std::path::PathBuf::from(path)); + let remote_path = + openbitfun_services_core::workspace_identity::normalize_remote_workspace_path(path); + for record in self.list_workspace_infos().await { + let contains = if record.workspace_kind == WorkspaceKind::Remote { + let root = + openbitfun_services_core::workspace_identity::normalize_remote_workspace_path( + &record.root_path.to_string_lossy(), + ); + remote_path == root + || remote_path.starts_with(&format!("{}/", root.trim_end_matches('/'))) + } else { + local_path.starts_with(&record.root_path) + }; + if contains { + providers.insert( + record + .filesystem_connection_id() + .map_err(OpenBitFunError::service)? + .map(str::to_owned), + ); + } + } + if providers.len() > 1 { + return Err(OpenBitFunError::service("Legacy filesystem path matches multiple workspace providers; select the workspace ID")); + } + Ok(providers.into_iter().next().flatten()) + } +} + +/// Upgrade-only persisted worktree relationship migration. All normal navigation, +/// session storage and execution binding follow `main_workspace_id` afterwards. +/// Unknown/ambiguous parents stay intact and unavailable, never become local roots. +pub(crate) async fn upgrade_legacy_worktree_references( + records: &mut std::collections::HashMap, + path_manager: &crate::infrastructure::PathManager, +) { + use super::{WorkspaceInfoRuntimeExt, WorkspaceOpenOptions}; + let old_records: Vec<_> = records.values().cloned().collect(); + for record in old_records { + if record.workspace_kind == WorkspaceKind::Remote { + continue; + } + let Some(tree) = record.worktree.as_ref() else { + continue; + }; + if tree.main_workspace_id.is_some() { + continue; + } + let parent_id = if tree.is_main { + Some(record.id.clone()) + } else { + let snapshot: Vec<_> = records + .values() + .filter(|record| record.workspace_kind != WorkspaceKind::Remote) + .cloned() + .collect(); + match resolve_legacy_workspace_reference( + &snapshot, + None, + &tree.main_repo_path, + None, + None, + ) { + Ok(Some(parent)) => Some(parent.id), + Ok(None) => { + // 1.0.0 could persist a linked worktree before its main folder + // was opened. Register that existing local folder once; never + // use a hidden session directory as the parent. + let root = match dunce::canonicalize(&tree.main_repo_path) { + Ok(root) => root, + Err(error) => { + log::warn!("Legacy project folder is unavailable; preserving workspace: workspace_id={}, error={}", record.id, error); + continue; + } + }; + let valid = [ + path_manager.projects_root(), + path_manager.remote_ssh_mirror_root_dir(), + ] + .into_iter() + .all(|storage| { + let storage = dunce::canonicalize(&storage).unwrap_or(storage); + !root.starts_with(storage) + }); + if !valid { + None + } else { + match WorkspaceInfo::new_without_worktree( + root, + WorkspaceOpenOptions { + auto_set_current: false, + add_to_recent: false, + ..Default::default() + }, + ) + .await + { + Ok(parent) => { + let id = parent.id.clone(); + records.entry(id.clone()).or_insert(parent); + Some(id) + } + Err(error) => { + log::warn!("Legacy project registration failed; preserving workspace: workspace_id={}, error={}", record.id, error); + None + } + } + } + } + Err(error) => { + log::warn!("Legacy worktree project is ambiguous; preserving workspace: workspace_id={}, error={}", record.id, error); + None + } + } + }; + if let Some(tree) = records + .get_mut(&record.id) + .and_then(|record| record.worktree.as_mut()) + { + tree.main_workspace_id = parent_id; + } + } +} + +/// Temporary local-IO wire compatibility boundary. Current workspace operations +/// supply an ID and the record kind is authoritative. Only a pre-ID payload may +/// consult the retired remote path registry; remove that branch at protocol sunset. +pub async fn remote_io_for_legacy_or_id( + workspace_id: Option<&str>, + controller_local: bool, + legacy_path: &str, +) -> OpenBitFunResult { + if let Some(id) = workspace_id { + let service = super::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + return Ok(service.require_workspace(id).await?.workspace_kind == WorkspaceKind::Remote); + } + if controller_local { + return Ok(false); + } + #[cfg(any(feature = "remote-workspace", feature = "agent-runtime"))] + { + Ok(crate::service::remote_ssh::workspace_state::is_remote_path(legacy_path).await) + } + #[cfg(not(any(feature = "remote-workspace", feature = "agent-runtime")))] + { + let _ = legacy_path; + Ok(false) + } +} + +/// Temporary 1.0.0 persisted/wire Cron adapter. New producers must supply an ID. +/// Preserve the IO projection for old readers; the runtime refreshes it by ID. +#[cfg(all(feature = "agent-runtime", feature = "scheduled-jobs"))] +pub(crate) async fn upgrade_legacy_cron_target( + mut target: crate::service::cron::CronJobTarget, +) -> OpenBitFunResult { + use crate::service::cron::CronJobTarget; + let workspace = match &mut target { + CronJobTarget::Session { workspace, .. } | CronJobTarget::Workspace { workspace, .. } => { + workspace + } + }; + if workspace.workspace_id.is_none() { + let service = super::get_global_workspace_service() + .ok_or_else(|| OpenBitFunError::service("Workspace service is unavailable"))?; + let record = service + .resolve_legacy_workspace_reference( + None, + &workspace.workspace_path, + workspace.remote_connection_id.as_deref(), + workspace.remote_ssh_host.as_deref(), + ) + .await? + .ok_or_else(|| { + OpenBitFunError::service( + "Scheduled job workspace is unavailable; select its workspace by ID", + ) + })?; + workspace.workspace_id = Some(record.id); + } + Ok(target) +} + +/// Temporary 1.0.0 fork ingress. Some old SDK/plugin clients sent a resolved +/// Session store rather than a project directory. Resolve the source through +/// its registered ID and compare the IO projection only inside this adapter. +/// New callers must never send a path; remove with the pre-ID protocol sunset. +#[cfg(feature = "agent-runtime")] +impl WorkspaceService { + pub(crate) async fn resolve_legacy_fork_workspace_reference( + &self, + sessions: &crate::agentic::session::SessionManager, + path: &str, + connection: Option<&str>, + host: Option<&str>, + source_session_id: &str, + ) -> OpenBitFunResult { + if let Some(record) = self + .resolve_legacy_workspace_reference(None, path, connection, host) + .await? + { + return Ok(record); + } + if connection.is_none() && host.is_none() { + if let Some(binding) = sessions + .resolve_session_workspace_binding(source_session_id) + .await + { + if let Some(id) = binding.workspace_id.as_deref() { + use openbitfun_runtime_ports::SessionStorePort; + let storage = crate::agentic::session::CoreSessionStorePort::with_path_manager( + sessions.persistence_manager().path_manager().clone(), + ) + .resolve_workspace_storage(id) + .await + .map_err(|e| OpenBitFunError::service(e.to_string()))?; + let input = std::path::Path::new(path); + if input == storage.effective_storage_path + || dunce::canonicalize(input) + .is_ok_and(|canonical| canonical == storage.effective_storage_path) + { + return self.require_workspace(id).await; + } + } + } + } + Err(OpenBitFunError::service( + "Legacy fork workspace reference is unavailable; select its workspace ID", + )) + } +} + +#[cfg(all(test, feature = "agent-runtime", feature = "remote-workspace"))] +mod legacy_file_scope_tests { + #[tokio::test] + async fn legacy_file_collision_requires_id_or_explicit_device_scope() { + let folder = tempfile::tempdir().unwrap(); + let local = super::register_local_fixture(folder.path(), None).await; + let path = local + .root_path + .join("file.txt") + .to_string_lossy() + .into_owned(); + let service = super::fixture_workspace_service().await; + assert_eq!( + service + .upgrade_legacy_file_connection(&path, None, None) + .await + .unwrap(), + None + ); + let remote = super::register_remote_fixture( + &local.root_path.to_string_lossy(), + "legacy-file-ssh", + "legacy-file-host", + ) + .await; + assert!(service + .upgrade_legacy_file_connection(&path, None, None) + .await + .is_err()); + assert_eq!( + service + .upgrade_legacy_file_connection(&path, None, Some("")) + .await + .unwrap(), + None + ); + assert_eq!( + service + .require_workspace(&local.id) + .await + .unwrap() + .filesystem_connection_id() + .unwrap(), + None + ); + assert_eq!( + service + .require_workspace(&remote.id) + .await + .unwrap() + .filesystem_connection_id() + .unwrap(), + Some("legacy-file-ssh") + ); + assert_eq!( + service + .upgrade_legacy_file_connection(&path, None, Some("legacy-file-ssh")) + .await + .unwrap() + .as_deref(), + Some("legacy-file-ssh") + ); + } +} diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index 13a422a10a..050222adf5 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -39,7 +39,6 @@ pub(crate) trait WorkspaceIdentityRuntimeExt: Sized { async fn load_from_workspace_root(workspace_root: &Path) -> Result, String>; fn from_markdown(content: &str) -> Result; fn is_empty(&self) -> bool; - #[cfg(any(feature = "agent-runtime", test))] fn collect_changed_fields( previous: Option<&WorkspaceIdentity>, current: Option<&WorkspaceIdentity>, @@ -92,7 +91,6 @@ impl WorkspaceIdentityRuntimeExt for WorkspaceIdentity { && self.emoji.is_none() } - #[cfg(any(feature = "agent-runtime", test))] fn collect_changed_fields( previous: Option<&WorkspaceIdentity>, current: Option<&WorkspaceIdentity>, @@ -450,6 +448,7 @@ impl WorkspaceInfoRuntimeExt for WorkspaceInfo { .into_iter() .find(|worktree| worktree.path == normalized_workspace_path) .map(|worktree| WorkspaceWorktreeInfo { + main_workspace_id: None, path: worktree.path, branch: worktree.branch, main_repo_path: main_repo_path.clone(), @@ -799,6 +798,32 @@ impl WorkspaceManager { .await } + /// Refreshes activity of a workspace the caller already selected by ID. + /// Only the access time, recent placement and (optionally) the worktree + /// projection change; the recorded kind, name and SSH facts stay as they + /// are because a session's stale projection must not rewrite the record. + pub async fn touch_workspace_by_id( + &mut self, + workspace_id: &str, + add_to_recent: bool, + refresh_worktree: Option>, + ) -> OpenBitFunResult { + let workspace = self.workspaces.get_mut(workspace_id).ok_or_else(|| { + OpenBitFunError::service(format!("Workspace not found: {workspace_id}")) + })?; + if let Some(worktree) = refresh_worktree { + workspace.load_identity().await; + workspace.worktree = worktree; + } + self.touch_workspace_access(workspace_id, add_to_recent); + self.workspaces.get(workspace_id).cloned().ok_or_else(|| { + OpenBitFunError::service(format!( + "Workspace '{}' disappeared after touching it", + workspace_id + )) + }) + } + /// Registers or refreshes workspace activity without changing opened UI state. pub async fn track_workspace_with_options( &mut self, @@ -1220,21 +1245,18 @@ impl WorkspaceManager { } /// Cleans up invalid workspaces. + /// Compatibility entry point. Unavailable paths are recoverable (offline + /// mounts, moved folders, credentials); only explicit removal deletes IDs. pub async fn cleanup_invalid_workspaces(&mut self) -> OpenBitFunResult { - let mut invalid_workspaces = Vec::new(); - for (workspace_id, workspace) in &self.workspaces { if !workspace.is_valid().await { - invalid_workspaces.push(workspace_id.clone()); + log::warn!( + "Workspace is unavailable; preserving its record: workspace_id={}", + workspace_id + ); } } - - let count = invalid_workspaces.len(); - for workspace_id in invalid_workspaces { - self.remove_workspace(&workspace_id)?; - } - - Ok(count) + Ok(0) } /// Updates the recent-workspaces list. diff --git a/src/crates/assembly/core/src/service/workspace/mod.rs b/src/crates/assembly/core/src/service/workspace/mod.rs index bf46e7654e..a53a4f85b5 100644 --- a/src/crates/assembly/core/src/service/workspace/mod.rs +++ b/src/crates/assembly/core/src/service/workspace/mod.rs @@ -7,6 +7,10 @@ pub mod factory; #[cfg(feature = "workspace-watch")] pub mod identity_watch; #[cfg(feature = "workspace-runtime")] +pub(crate) mod legacy_compat; +#[cfg(feature = "workspace-runtime")] +pub use legacy_compat::remote_io_for_legacy_or_id; +#[cfg(feature = "workspace-runtime")] pub mod manager; pub(crate) mod persistence; #[cfg(feature = "workspace-runtime")] diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 57b70a8358..1ca978af20 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -5,7 +5,7 @@ use super::manager::{ PrimaryAssistantKey, RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, - WorkspaceStatus, WorkspaceSummary, WorkspaceType, + WorkspaceStatus, WorkspaceSummary, WorkspaceType, WorkspaceWorktreeInfo, }; use super::manager::{WorkspaceIdentityRuntimeExt, WorkspaceInfoRuntimeExt}; use super::persistence::{ @@ -286,7 +286,22 @@ impl WorkspaceService { Ok(service) } - #[cfg(all(test, feature = "agent-runtime"))] + /// Builds a workspace service whose catalog, persistence, and runtime + /// state live under `user_root` instead of the real user data directory. + /// + /// In-process tests in downstream crates (CLI, ACP, server hosts) that + /// exercise code paths creating workspace records must install this as the + /// global workspace service before the first record is created. Otherwise + /// the fixtures' temporary folders leak into the developer's real + /// `workspace_data.json` as opened, current workspaces and the desktop + /// fails to start a session in a folder that no longer exists. + #[cfg(any(test, feature = "test-support"))] + pub async fn new_isolated_for_tests(user_root: PathBuf) -> Self { + Self::new_for_test_path_manager(Arc::new(PathManager::with_user_root_for_tests(user_root))) + .await + } + + #[cfg(any(test, feature = "test-support"))] pub(crate) async fn new_for_test_path_manager(path_manager: Arc) -> Self { path_manager .initialize_user_directories() @@ -328,55 +343,55 @@ impl WorkspaceService { .await } - /// Opens a workspace by path, recovering remote SSH metadata from known - /// workspace history when the caller only has a remote POSIX path. + /// Resolves the stable workspace record for a local directory operand + /// without changing what the user sees as open. /// - /// IM bots, mobile Remote Connect, and similar path-only surfaces must use - /// this instead of [`Self::open_workspace`]: remote roots such as - /// `/root/repos` do not exist on the desktop host filesystem, so a bare - /// local open fails with "Workspace path does not exist". - pub async fn open_workspace_resolving_known( + /// `open_workspace` is the explicit "open this folder" action: it marks the + /// record opened, pushes it into the recent list, and makes it the current + /// workspace of the shared catalog. Hosts that merely receive a directory + /// as an operand (CLI commands run in a cwd, ACP `cwd`, headless helpers) + /// must not do that, because the same catalog drives the desktop sidebar + /// and the desktop's startup workspace. This registers (or touches) the + /// record so sessions and runtime ownership can be addressed by ID, and + /// leaves opened/recent/current state untouched. + pub async fn register_local_workspace_record( &self, path: PathBuf, - preferred_connection_id: Option<&str>, - preferred_ssh_host: Option<&str>, ) -> OpenBitFunResult { - let path_str = path.to_string_lossy().to_string(); - let known = self - .resolve_remote_workspace_for_open( - &path_str, - preferred_connection_id, - preferred_ssh_host, - ) - .await?; - self.open_workspace_after_known_resolution(path, known) - .await + self.track_workspace_activity( + path, + WorkspaceCreateOptions { + add_to_recent: false, + auto_set_current: false, + ..Default::default() + }, + WorkspaceActivityMode::TouchOnly, + ) + .await } - pub(crate) async fn open_workspace_after_known_resolution( + async fn register_worktree_project( &self, - path: PathBuf, - known_remote: Option, - ) -> OpenBitFunResult { - let path_str = path.to_string_lossy().to_string(); - if let Some(known) = known_remote { - return self.open_known_remote_workspace(&known).await; - } - match self.open_workspace(path).await { - Ok(info) => Ok(info), - Err(error) => { - let message = error.to_string(); - if message.contains("Workspace path does not exist") { - Err(OpenBitFunError::service(format!( - "Workspace path does not exist locally and is not a known remote SSH \ - workspace: {path_str}. Open it once from the desktop SSH remote UI so \ - OpenBitFun can remember the connection, then try again." - ))) - } else { - Err(error) - } - } + mut tree: Option, + ) -> OpenBitFunResult> { + if let Some(tree) = tree.as_mut().filter(|tree| !tree.is_main) { + let parent = self + .manager + .write() + .await + .track_workspace_with_options( + PathBuf::from(&tree.main_repo_path), + WorkspaceOpenOptions { + auto_set_current: false, + add_to_recent: false, + ..Default::default() + }, + None, + ) + .await?; + tree.main_workspace_id = Some(parent.id); } + Ok(tree) } /// Opens a workspace with explicit workspace metadata. @@ -392,8 +407,12 @@ impl WorkspaceService { "Remote workspace support is not compiled into this product profile", )); } - let worktree = - WorkspaceInfo::resolve_worktree_info(&path, WorktreeTopologyFreshness::Cached).await; + let worktree = if options.workspace_kind == WorkspaceKind::Remote { + None + } else { + WorkspaceInfo::resolve_worktree_info(&path, WorktreeTopologyFreshness::Cached).await + }; + let worktree = self.register_worktree_project(worktree).await?; let result = { let mut manager = self.manager.write().await; manager @@ -425,78 +444,16 @@ impl WorkspaceService { result } - pub(crate) async fn find_known_remote_workspace_for_path( + /// Validate a new remote folder against its explicitly selected saved connection. + /// No existing workspace is selected through the folder path. + pub(crate) async fn prepare_remote_workspace( &self, path: &str, - preferred_connection_id: Option<&str>, - preferred_ssh_host: Option<&str>, - ) -> Option { - let want_path = normalize_remote_workspace_path(path); - let preferred_connection_id = preferred_connection_id - .map(str::trim) - .filter(|value| !value.is_empty()); - let preferred_ssh_host = preferred_ssh_host - .map(str::trim) - .filter(|value| !value.is_empty()); - - let manager = self.manager.read().await; - let mut matches: Vec<&WorkspaceInfo> = manager - .get_workspaces() - .values() - .filter(|workspace| { - workspace.workspace_kind == WorkspaceKind::Remote - && normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()) - == want_path - }) - .collect(); - - if matches.is_empty() { - return None; - } - - // Explicit identity is a constraint, never a hint to another host. - matches.retain(|workspace| { - preferred_connection_id - .is_none_or(|id| workspace.remote_ssh_connection_id() == Some(id)) - && preferred_ssh_host.is_none_or(|host| { - workspace - .metadata - .get("sshHost") - .and_then(|v| v.as_str()) - .map(str::trim) - == Some(host) - }) - }); - - // Prefer the most recently accessed match when the path alone is ambiguous - // (e.g. the same POSIX root opened on two SSH hosts). - matches.sort_by(|left, right| right.last_accessed.cmp(&left.last_accessed)); - matches.first().map(|workspace| (*workspace).clone()) - } - - /// Resolve persisted identity or verify a new path against an existing SSH - /// connection. Caller input alone never authorizes a remote runtime scope. - pub(crate) async fn resolve_remote_workspace_for_open( - &self, - path: &str, - connection_id: Option<&str>, + connection_id: &str, ssh_host: Option<&str>, - ) -> OpenBitFunResult> { - let known = self - .find_known_remote_workspace_for_path(path, connection_id, ssh_host) - .await; - let connection_id = connection_id.map(str::trim).filter(|id| !id.is_empty()); - let ssh_host = ssh_host.map(str::trim).filter(|id| !id.is_empty()); - if connection_id.is_none() && ssh_host.is_none() { - return Ok(known); - } + ) -> OpenBitFunResult { #[cfg(feature = "ssh-remote")] { - let connection_id = connection_id.ok_or_else(|| { - OpenBitFunError::service( - "Opening a remote workspace with an explicit SSH host requires its saved SSH connection ID", - ) - })?; if !path.starts_with('/') || path.contains('\0') { return Err(OpenBitFunError::service( "Remote workspace path must be an absolute POSIX path", @@ -520,9 +477,6 @@ impl WorkspaceService { "Remote workspace connection is not saved on this host", )); } - if let Some(known) = known { - return Ok(Some(known)); - } ssh.ensure_connected(connection_id) .await .map_err(|e| OpenBitFunError::service(e.to_string()))?; @@ -560,17 +514,18 @@ impl WorkspaceService { stable_workspace_id: Some(remote_workspace_stable_id(&host, &path)), ..Default::default() }; - return WorkspaceInfo::new_without_worktree(PathBuf::from(path), options) - .await - .map(Some); + return WorkspaceInfo::new_without_worktree(PathBuf::from(path), options).await; } #[cfg(not(feature = "ssh-remote"))] - Err(OpenBitFunError::service( - "Opening a new remote workspace requires SSH support on this host", - )) + { + let _ = (path, connection_id, ssh_host); + Err(OpenBitFunError::service( + "Opening a new remote workspace requires SSH support on this host", + )) + } } - async fn open_known_remote_workspace( + pub(crate) async fn open_known_remote_workspace( &self, known: &WorkspaceInfo, ) -> OpenBitFunResult { @@ -671,6 +626,53 @@ impl WorkspaceService { .await; } + /// Refreshes activity for a workspace selected by ID without opening it. + /// The record decides local vs. remote; nothing is created or re-keyed. + pub async fn track_workspace_activity_by_id( + &self, + workspace_id: &str, + mode: WorkspaceActivityMode, + ) -> OpenBitFunResult { + let workspace = self.require_workspace(workspace_id).await?; + let refresh_worktree = match mode { + WorkspaceActivityMode::TouchOnly => None, + WorkspaceActivityMode::RefreshMetadata => { + Some(if workspace.workspace_kind == WorkspaceKind::Remote { + None + } else { + WorkspaceInfo::resolve_worktree_info( + &workspace.root_path, + WorktreeTopologyFreshness::Cached, + ) + .await + }) + } + }; + let refresh_worktree = match refresh_worktree { + Some(tree) => Some(self.register_worktree_project(tree).await?), + None => None, + }; + let result = self + .manager + .write() + .await + .touch_workspace_by_id(&workspace.id, true, refresh_worktree) + .await; + if let Ok(workspace) = result.as_ref() { + self.ensure_workspace_runtime_best_effort(workspace, "tracked") + .await; + } + if result.is_ok() { + if let Err(e) = self.save_workspace_data().await { + warn!( + "Failed to save workspace data after tracking activity: {}", + e + ); + } + } + result + } + /// Registers or refreshes workspace activity without marking it as opened in the UI. pub async fn track_workspace_activity( &self, @@ -682,10 +684,18 @@ impl WorkspaceService { options.auto_set_current = false; let refresh_worktree = match mode { WorkspaceActivityMode::TouchOnly => None, - WorkspaceActivityMode::RefreshMetadata => Some( - WorkspaceInfo::resolve_worktree_info(&path, WorktreeTopologyFreshness::Cached) - .await, - ), + WorkspaceActivityMode::RefreshMetadata => { + Some(if options.workspace_kind == WorkspaceKind::Remote { + None + } else { + WorkspaceInfo::resolve_worktree_info(&path, WorktreeTopologyFreshness::Cached) + .await + }) + } + }; + let refresh_worktree = match refresh_worktree { + Some(tree) => Some(self.register_worktree_project(tree).await?), + None => None, }; let result = { let mut manager = self.manager.write().await; @@ -698,6 +708,23 @@ impl WorkspaceService { .await }; + let result = match result { + Ok(mut workspace) + if workspace + .worktree + .as_ref() + .is_some_and(|tree| !tree.is_main && tree.main_workspace_id.is_none()) => + { + workspace.worktree = self.register_worktree_project(workspace.worktree).await?; + self.manager + .write() + .await + .get_workspaces_mut() + .insert(workspace.id.clone(), workspace.clone()); + Ok(workspace) + } + other => other, + }; if let Ok(workspace) = result.as_ref() { self.ensure_workspace_runtime_best_effort(workspace, "tracked") .await; @@ -956,26 +983,53 @@ impl WorkspaceService { }) } + /// Reopen/select a persisted workspace without re-identifying it by path. + pub async fn open_workspace_by_id( + &self, + workspace_id: &str, + ) -> OpenBitFunResult { + let workspace = self.require_workspace(workspace_id).await?; + #[cfg(not(feature = "remote-workspace"))] + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(OpenBitFunError::service( + "Remote workspace support is not compiled into this product profile", + )); + } + if workspace.workspace_kind != WorkspaceKind::Remote && !workspace.root_path.is_dir() { + return Err(OpenBitFunError::service( + "The selected local workspace directory is unavailable", + )); + } + self.manager + .write() + .await + .set_current_workspace(workspace.id.clone())?; + self.save_workspace_data().await?; + self.ensure_workspace_runtime_best_effort(&workspace, "opened by ID") + .await; + #[cfg(feature = "remote-workspace")] + if workspace.workspace_kind == WorkspaceKind::Remote { + self.register_remote_workspace_runtime(&workspace).await; + } + self.require_workspace(workspace_id).await + } + /// Returns workspace details. pub async fn get_workspace(&self, workspace_id: &str) -> Option { let manager = self.manager.read().await; manager.get_workspace(workspace_id).cloned() } - /// Returns workspace details by root path. - pub async fn get_workspace_by_path(&self, path: &Path) -> Option { - let manager = self.manager.read().await; - manager - .get_workspaces() - .values() - .find(|workspace| { - if workspace.workspace_kind == WorkspaceKind::Remote { - workspace.root_path == path - } else { - local_workspace_roots_equal(&workspace.root_path, path) - } - }) - .cloned() + /// Resolve an identity on this host. Paths and SSH hints cannot redirect it. + pub async fn require_workspace(&self, workspace_id: &str) -> OpenBitFunResult { + if workspace_id.trim().is_empty() { + return Err(OpenBitFunError::service("Workspace ID is required")); + } + self.get_workspace(workspace_id).await.ok_or_else(|| { + OpenBitFunError::service(format!( + "Workspace ID is unavailable on this host: {workspace_id}" + )) + }) } /// Returns all currently opened workspaces. @@ -994,45 +1048,6 @@ impl WorkspaceService { manager.get_workspaces().values().cloned().collect() } - /// `metadata["sshHost"]` for a remote workspace matching `connection_id` and normalized remote root. - /// - /// Used when session APIs receive `remote_connection_id` but the client omitted `remote_ssh_host`: - /// session files live under `~/.openbitfun/remote_ssh/{sshHost}/...`, not the legacy per-connection tree. - /// This reads only persisted workspace records (no filesystem guessing, no DNS). - pub async fn remote_ssh_host_for_remote_workspace( - &self, - connection_id: &str, - remote_workspace_path: &str, - ) -> Option { - let cid = connection_id.trim(); - if cid.is_empty() { - return None; - } - let want = normalize_remote_workspace_path(remote_workspace_path); - let manager = self.manager.read().await; - for w in manager.get_workspaces().values() { - if w.workspace_kind != WorkspaceKind::Remote { - continue; - } - let wcid = w.remote_ssh_connection_id()?; - if wcid != cid { - continue; - } - let root = normalize_remote_workspace_path(&w.root_path.to_string_lossy()); - if root != want { - continue; - } - let host = w - .metadata - .get("sshHost") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty())?; - return Some(host.to_string()); - } - None - } - /// Returns all tracked assistant workspaces, including inactive ones. pub async fn get_assistant_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -1238,11 +1253,16 @@ impl WorkspaceService { workspace_id ))); }; - let worktree = WorkspaceInfo::resolve_worktree_info( - &workspace_path, - WorktreeTopologyFreshness::ForceRefresh, - ) - .await; + let worktree = if existing_workspace.workspace_kind == WorkspaceKind::Remote { + None + } else { + let tree = WorkspaceInfo::resolve_worktree_info( + &workspace_path, + WorktreeTopologyFreshness::ForceRefresh, + ) + .await; + self.register_worktree_project(tree).await? + }; let new_workspace = WorkspaceInfo::new_without_worktree( workspace_path, WorkspaceOpenOptions { @@ -1754,10 +1774,9 @@ impl WorkspaceService { for workspace in export.workspaces { if !workspace.is_valid().await { result.warnings.push(format!( - "Workspace path no longer valid: {:?}", - workspace.root_path + "Workspace is currently unavailable; preserving imported record: {} ({:?})", + workspace.id, workspace.root_path )); - continue; } if !overwrite && manager.get_workspaces().contains_key(&workspace.id) { @@ -1771,6 +1790,12 @@ impl WorkspaceService { result.imported_workspaces += 1; } + super::legacy_compat::upgrade_legacy_worktree_references( + manager.get_workspaces_mut(), + &self.path_manager, + ) + .await; + manager.set_recent_workspaces(export.recent_workspaces.clone()); manager.set_recent_assistant_workspaces(export.recent_assistant_workspaces.clone()); manager.set_primary_assistant_key(imported_primary_key); @@ -1862,7 +1887,12 @@ impl WorkspaceService { OpenBitFunError::service(format!("Failed to load workspace data: {}", e)) })?; - if let Some(data) = workspace_data { + if let Some(mut data) = workspace_data { + super::legacy_compat::upgrade_legacy_worktree_references( + &mut data.workspaces, + &self.path_manager, + ) + .await; validate_workspace_persistence_data(&data, &self.path_manager.miniapps_dir())?; let mut manager = self.manager.write().await; @@ -1882,6 +1912,7 @@ impl WorkspaceService { let workspaces_to_restore = Self::collect_startup_restored_workspaces(&manager); drop(manager); + self.save_workspace_data().await?; self.prepare_startup_restored_workspaces(workspaces_to_restore) .await; } else { @@ -1934,7 +1965,12 @@ impl WorkspaceService { continue; } - if self.get_workspace_by_path(&path).await.is_none() { + if !self + .get_assistant_workspaces() + .await + .iter() + .any(|workspace| workspace.assistant_id.as_deref() == Some(&assistant_id)) + { return Ok(assistant_id); } } @@ -2358,6 +2394,63 @@ mod tests { .await; } + /// Directory operands handed to CLI/ACP hosts resolve to a stable record + /// without hijacking the desktop's opened, recent, or current workspace. + #[tokio::test] + async fn register_local_workspace_record_keeps_desktop_selection_untouched() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let opened_project = env.create_workspace_dir("opened-project"); + let cli_cwd = env.create_workspace_dir("cli-cwd"); + + let opened = service + .open_workspace(opened_project) + .await + .expect("explicit open should succeed"); + + let hidden = service + .register_local_workspace_record(cli_cwd.clone()) + .await + .expect("hidden registration should succeed"); + assert_eq!(hidden.workspace_kind, WorkspaceKind::Normal); + assert_ne!(hidden.id, opened.id); + + let current = service.get_current_workspace().await; + assert_eq!(current.map(|w| w.id), Some(opened.id.clone())); + let opened_ids: Vec = service + .get_opened_workspaces() + .await + .into_iter() + .map(|w| w.id) + .collect(); + assert_eq!(opened_ids, vec![opened.id.clone()]); + assert!( + !service + .get_recent_workspaces() + .await + .iter() + .any(|w| w.id == hidden.id), + "hidden record must not enter the recent list" + ); + + // Registering again resolves the same stable ID, and an explicit open + // later surfaces that record instead of creating a second identity. + let again = service + .register_local_workspace_record(cli_cwd.clone()) + .await + .expect("re-registration should succeed"); + assert_eq!(again.id, hidden.id); + let explicit = service + .open_workspace(cli_cwd) + .await + .expect("explicit open should succeed"); + assert_eq!(explicit.id, hidden.id); + assert_eq!( + service.get_current_workspace().await.map(|w| w.id), + Some(hidden.id) + ); + } + #[tokio::test] async fn primary_assistant_role_can_move_to_a_named_workspace() { let env = TestEnvironment::new(); @@ -2514,7 +2607,7 @@ mod tests { } #[tokio::test] - async fn load_workspace_history_rejects_noncanonical_local_id_without_rewriting_file() { + async fn load_workspace_history_preserves_opaque_local_id() { let env = TestEnvironment::new(); let service = build_test_workspace_service(env.path_manager.clone()).await; let workspace_root = env.create_workspace_dir("noncanonical-workspace-id"); @@ -2536,7 +2629,7 @@ mod tests { workspaces: HashMap::from([(noncanonical_id.clone(), workspace)]), opened_workspace_ids: vec![noncanonical_id.clone()], current_workspace_id: Some(noncanonical_id.clone()), - recent_workspaces: vec![noncanonical_id], + recent_workspaces: vec![noncanonical_id.clone()], recent_assistant_workspaces: Vec::new(), primary_assistant_key: None, saved_at: chrono::Utc::now(), @@ -2547,22 +2640,19 @@ mod tests { .await .expect("noncanonical workspace fixture should save"); let persistence_path = service.persistence.base_dir().join("workspace_data.json"); - let before = std::fs::read(&persistence_path).expect("fixture should be readable"); - - let error = service + service .load_workspace_history_only() .await - .expect_err("noncanonical workspace ids must require explicit migration"); - - assert!(error.to_string().contains("workspace id")); - assert!(error.to_string().contains("is not canonical")); - assert!(error.to_string().contains("left unchanged")); + .expect("opaque IDs are preserved"); + let restored = service.require_workspace(&noncanonical_id).await.unwrap(); + assert_eq!(restored.id, noncanonical_id); + let saved: WorkspacePersistenceData = + serde_json::from_slice(&std::fs::read(&persistence_path).unwrap()).unwrap(); + assert!(saved.workspaces.contains_key(&noncanonical_id)); assert_eq!( - std::fs::read(&persistence_path).expect("fixture should remain readable"), - before, - "unsupported workspace persistence must not be rewritten" + saved.current_workspace_id.as_deref(), + Some(noncanonical_id.as_str()) ); - assert!(service.list_workspace_infos().await.is_empty()); } #[tokio::test] @@ -2648,11 +2738,11 @@ mod tests { .await .expect("workspace tracking should succeed"); - let tracked_by_path = service - .get_workspace_by_path(&workspace_root) + let tracked_by_id = service + .get_workspace(&tracked.id) .await - .expect("tracked workspace should be queryable by path"); - assert_eq!(tracked_by_path.id, tracked.id); + .expect("tracked workspace should be queryable by ID"); + assert_eq!(tracked_by_id.id, tracked.id); let recent = service.get_recent_workspaces().await; assert_eq!(recent.len(), 1); @@ -2691,7 +2781,7 @@ mod tests { assert_eq!( service - .get_workspace_by_path(&miniapp_workspace_root) + .get_workspace(&tracked.id) .await .map(|workspace| workspace.id), Some(tracked.id), @@ -2718,6 +2808,7 @@ mod tests { .await .expect("workspace tracking should succeed"); let expected_worktree = WorkspaceWorktreeInfo { + main_workspace_id: None, path: workspace_root.to_string_lossy().replace('\\', "/"), branch: Some("cached-branch".to_string()), main_repo_path: workspace_root.to_string_lossy().replace('\\', "/"), @@ -2774,7 +2865,71 @@ mod tests { #[cfg(feature = "remote-workspace")] #[tokio::test] - async fn open_workspace_resolving_known_reopens_remote_without_local_exists() { + async fn legacy_workspace_open_keeps_assistant_id_and_kind() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let root = env + .path_manager + .user_data_dir() + .join("legacy-assistant-workspace"); + std::fs::create_dir_all(&root).unwrap(); + let original = service + .open_workspace_with_options( + root.clone(), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Assistant, + assistant_id: Some("assistant-1".to_owned()), + ..Default::default() + }, + ) + .await + .unwrap(); + let reopened = service + .upgrade_legacy_workspace_open(root, None, None) + .await + .unwrap(); + assert_eq!(reopened.id, original.id); + assert_eq!(reopened.workspace_kind, WorkspaceKind::Assistant); + assert_eq!(reopened.assistant_id, original.assistant_id); + } + + #[tokio::test] + async fn explicit_local_folder_creation_does_not_select_same_path_remote_record() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let folder = tempfile::tempdir().unwrap(); + let remote = service + .track_workspace_activity( + folder.path().to_path_buf(), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("other-machine".into()), + remote_ssh_host: Some("other.example".into()), + ..Default::default() + }, + WorkspaceActivityMode::RefreshMetadata, + ) + .await + .unwrap(); + let local = service + .open_workspace(folder.path().to_path_buf()) + .await + .unwrap(); + assert_eq!(local.workspace_kind, WorkspaceKind::Normal); + assert_ne!(local.id, remote.id); + assert_eq!( + service + .require_workspace(&remote.id) + .await + .unwrap() + .workspace_kind, + WorkspaceKind::Remote + ); + } + + #[cfg(feature = "remote-workspace")] + #[tokio::test] + async fn upgrade_legacy_workspace_open_reopens_remote_without_local_exists() { let env = TestEnvironment::new(); let service = build_test_workspace_service(env.path_manager.clone()).await; let remote_path = PathBuf::from("/root/repos"); @@ -2795,7 +2950,7 @@ mod tests { .expect("remote workspace should be remembered"); let opened = service - .open_workspace_resolving_known(remote_path.clone(), None, None) + .upgrade_legacy_workspace_open(remote_path.clone(), None, None) .await .expect("known remote workspace must open without a local path"); @@ -2838,34 +2993,44 @@ mod tests { .await .unwrap(); assert!(service - .find_known_remote_workspace_for_path(root.to_str().unwrap(), Some("saved-beta"), None) + .resolve_legacy_workspace_reference( + None, + root.to_str().unwrap(), + Some("saved-beta"), + None + ) .await + .unwrap() .is_none()); assert!(service - .find_known_remote_workspace_for_path( + .resolve_legacy_workspace_reference( + None, root.to_str().unwrap(), Some("saved-alpha"), Some("beta.example") ) .await + .unwrap() .is_none()); assert!(service - .find_known_remote_workspace_for_path( + .resolve_legacy_workspace_reference( + None, root.to_str().unwrap(), Some("saved-alpha"), Some("alpha.example") ) .await + .unwrap() .is_some()); } #[tokio::test] - async fn open_workspace_resolving_known_reports_unknown_remote_paths_clearly() { + async fn upgrade_legacy_workspace_open_reports_unknown_remote_paths_clearly() { let env = TestEnvironment::new(); let service = build_test_workspace_service(env.path_manager.clone()).await; let error = service - .open_workspace_resolving_known( + .upgrade_legacy_workspace_open( PathBuf::from("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/openbitfun-tests/unknown-remote-path"), None, None, diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index 7bcb6a5873..a954d84d44 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -42,6 +42,10 @@ static REPOSITORY_LOCKS: OnceLock>>>> #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeListRequest { + /// Owning project workspace ID. Hosts resolve the local project path from + /// this ID; `project_workspace_path` is the legacy/IO projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, } @@ -52,6 +56,11 @@ pub struct WorktreeProjectListRequest {} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeProjectSummary { + /// Workspace ID of the open workspace whose root is this project's main + /// worktree. `None` when the main worktree is not itself an open + /// workspace and only linked worktrees of it are open. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, pub worktrees: Vec, } @@ -60,6 +69,8 @@ pub struct WorktreeProjectSummary { #[serde(rename_all = "camelCase")] pub struct WorktreeCreateRequest { pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_workspace_path: Option, @@ -85,6 +96,8 @@ pub struct WorktreeCreateResult { #[serde(rename_all = "camelCase")] pub struct WorktreeCreateBranchRequest { pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, pub worktree_id: String, pub branch: String, @@ -94,6 +107,8 @@ pub struct WorktreeCreateBranchRequest { #[serde(rename_all = "camelCase")] pub struct WorktreePromoteRequest { pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, pub worktree_id: String, } @@ -102,6 +117,8 @@ pub struct WorktreePromoteRequest { #[serde(rename_all = "camelCase")] pub struct WorktreeRemoveRequest { pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, pub worktree_id: String, #[serde(default)] @@ -112,6 +129,8 @@ pub struct WorktreeRemoveRequest { #[serde(rename_all = "camelCase")] pub struct WorktreeRecreateRequest { pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub project_workspace_path: String, pub worktree_id: String, } @@ -154,6 +173,8 @@ impl WorktreeRegistry { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisteredWorktree { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, worktree_id: String, path: String, base_ref: Option, @@ -274,11 +295,8 @@ impl WorktreeService { } let mut cleanup_issues = Vec::new(); if let Some(workspace_service) = get_global_workspace_service() { - if let Some(workspace) = workspace_service - .get_workspace_by_path(Path::new(&record.path)) - .await - { - if let Err(remove_error) = workspace_service.remove_workspace(&workspace.id).await { + if let Some(workspace_id) = record.workspace_id.as_deref() { + if let Err(remove_error) = workspace_service.remove_workspace(workspace_id).await { cleanup_issues.push(format!( "workspace registration could not be removed: {remove_error}" )); @@ -345,9 +363,10 @@ impl WorktreeService { let project_paths = known_project_workspace_paths().await; let mut projects = Vec::new(); - for project_path in project_paths { + for (project_path, project_workspace_id) in project_paths { match Self::list_scoped( WorktreeListRequest { + project_workspace_id: project_workspace_id.clone(), project_workspace_path: path_string(&project_path), }, Some(&managed_root), @@ -363,6 +382,7 @@ impl WorktreeService { continue; } projects.push(WorktreeProjectSummary { + project_workspace_id, project_workspace_path: path_string(&project_path), worktrees, }); @@ -544,6 +564,7 @@ impl WorktreeService { let created_claim = claimed_by.clone(); registry.worktrees.push(RegisteredWorktree { + workspace_id: tracked_workspace_id.clone(), worktree_id: worktree_id.clone(), path: path_string(&target_path), base_ref: Some(base_ref.to_string()), @@ -830,11 +851,8 @@ impl WorktreeService { .map_err(map_git_error)?; let mut cleanup_issues = Vec::new(); if let Some(workspace_service) = get_global_workspace_service() { - if let Some(workspace) = workspace_service - .get_workspace_by_path(Path::new(&summary.path)) - .await - { - if let Err(remove_error) = workspace_service.remove_workspace(&workspace.id).await { + if let Some(workspace_id) = summary.workspace_id.as_deref() { + if let Err(remove_error) = workspace_service.remove_workspace(workspace_id).await { cleanup_issues.push(format!( "workspace registration could not be removed: {remove_error}" )); @@ -1090,6 +1108,7 @@ impl WorktreeService { )) ); registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.clone(), path: git_worktree.path.clone(), base_ref: git_worktree.branch.clone(), @@ -1155,32 +1174,50 @@ impl WorktreeService { .then_with(|| left.path.cmp(&right.path)) }); if let Some(workspace_service) = get_global_workspace_service() { - for summary in &summaries { - if summary.is_main + for summary in &mut summaries { + let record = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == summary.worktree_id); + if let Some(record) = record.as_ref() { + summary.workspace_id = record.workspace_id.clone(); + } + if summary.workspace_id.is_some() + || summary.is_main || summary.missing || summary.lifecycle == WorktreeLifecycle::External - || workspace_service - .get_workspace_by_path(Path::new(&summary.path)) - .await - .is_some() { continue; } - workspace_service - .track_workspace_activity( - PathBuf::from(&summary.path), - WorkspaceCreateOptions::default(), - WorkspaceActivityMode::RefreshMetadata, - ) + // Upgrade-only: old worktree registry rows have no workspace ID. + let workspace = match workspace_service + .resolve_legacy_workspace_reference(None, &summary.path, None, None) .await - .map_err(|workspace_error| { - error( - WorktreeErrorCode::IoFailed, - format!( - "Failed to restore a managed worktree workspace registration: {workspace_error}" - ), + .map_err(|error_| error(WorktreeErrorCode::IoFailed, error_.to_string()))? + { + Some(workspace) if workspace.workspace_kind != WorkspaceKind::Remote => { + workspace + } + Some(_) => { + return Err(error( + WorktreeErrorCode::RemoteUnsupported, + "Managed local worktree cannot reference a remote workspace", + )) + } + None => workspace_service + .track_workspace_activity( + PathBuf::from(&summary.path), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, ) - })?; + .await + .map_err(|error_| error(WorktreeErrorCode::IoFailed, error_.to_string()))?, + }; + summary.workspace_id = Some(workspace.id.clone()); + if let Some(record) = record { + record.workspace_id = Some(workspace.id); + changed = true; + } } } Ok((summaries, changed)) @@ -1424,12 +1461,9 @@ impl WorktreeService { } if let Some(workspace_service) = get_global_workspace_service() { - if let Some(workspace) = workspace_service - .get_workspace_by_path(Path::new(&summary.path)) - .await - { + if let Some(workspace_id) = summary.workspace_id.as_deref() { if let Err(workspace_error) = - workspace_service.remove_workspace(&workspace.id).await + workspace_service.remove_workspace(workspace_id).await { log::warn!( "Automatically removed worktree {}, but its workspace registration could not be removed: {}", @@ -1457,12 +1491,14 @@ impl WorktreeService { } } -async fn known_project_workspace_paths() -> Vec { +/// Known local Git projects, keyed by main worktree path, paired with the ID +/// of the open workspace whose root is that main worktree when one exists. +async fn known_project_workspace_paths() -> Vec<(PathBuf, Option)> { let Some(workspace_service) = get_global_workspace_service() else { return Vec::new(); }; let workspaces = workspace_service.list_workspaces().await; - let mut projects = HashMap::::new(); + let mut projects = HashMap::)>::new(); for workspace in workspaces { if workspace.workspace_kind != WorkspaceKind::Normal || !workspace.root_path.is_dir() { @@ -1475,11 +1511,18 @@ async fn known_project_workspace_paths() -> Vec { continue; }; let main_path = PathBuf::from(main_worktree.path); - projects.insert(normalized_lookup_path(&main_path), main_path); + let main_key = normalized_lookup_path(&main_path); + let owns_main = normalized_lookup_path(&workspace.root_path) == main_key; + let entry = projects + .entry(main_key) + .or_insert_with(|| (main_path, None)); + if owns_main && entry.1.is_none() { + entry.1 = Some(workspace.id.clone()); + } } let mut paths = projects.into_values().collect::>(); - paths.sort_by_key(|left| path_string(left)); + paths.sort_by_key(|(path, _)| path_string(path)); paths } @@ -1551,6 +1594,10 @@ async fn build_summary( let session_summaries = associated .iter() .map(|metadata| WorktreeSessionSummary { + workspace_id: metadata + .project_workspace_id + .clone() + .or_else(|| metadata.workspace_id.clone()), session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), status: session_status_name(&metadata.status).to_string(), @@ -1578,6 +1625,7 @@ async fn build_summary( ) }; Ok(WorktreeSummary { + workspace_id: None, worktree_id: worktree_id.to_string(), project_workspace_path: path_string(&context.project_workspace_path), path: git_worktree.path, @@ -2044,6 +2092,7 @@ mod tests { fn removable_summary() -> WorktreeSummary { WorktreeSummary { + workspace_id: None, worktree_id: "wt-1".to_string(), project_workspace_path: "/repo".to_string(), path: "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/worktrees/wt-1".to_string(), @@ -2344,6 +2393,7 @@ mod tests { ("external", WorktreeLifecycle::External, 2), ] { registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.to_string(), path: format!("/worktrees/{worktree_id}"), base_ref: Some("main".to_string()), @@ -2371,6 +2421,7 @@ mod tests { ("claimed", 10, Some("dispatch:job-1")), ] { registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.to_string(), path: format!("/worktrees/{worktree_id}"), base_ref: Some("main".to_string()), @@ -2396,6 +2447,7 @@ mod tests { let mut registry = WorktreeRegistry::new(project); for worktree_id in ["new", "old"] { registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.to_string(), path: format!("/worktrees/{worktree_id}"), base_ref: Some("main".to_string()), @@ -2419,6 +2471,7 @@ mod tests { let mut registry = WorktreeRegistry::new(project); for (worktree_id, created_at_ms) in [("newest", 100), ("recent", 90)] { registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.to_string(), path: format!("/worktrees/{worktree_id}"), base_ref: Some("main".to_string()), @@ -2450,6 +2503,7 @@ mod tests { }; let mut registry = WorktreeRegistry::new(&project); registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: "wt-restored".to_string(), path: "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/managed/wt-restored".to_string(), base_ref: Some("main".to_string()), @@ -2506,6 +2560,7 @@ mod tests { let project = Path::new("/repo"); let mut registry = WorktreeRegistry::new(project); registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: "wt-claimed".to_string(), path: "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/managed/wt-claimed".to_string(), base_ref: Some("main".to_string()), @@ -2555,6 +2610,7 @@ mod tests { ("wt-other", Some("dispatch:job-2")), ] { registry.worktrees.push(RegisteredWorktree { + workspace_id: None, worktree_id: worktree_id.to_string(), path: format!("/managed/{worktree_id}"), base_ref: Some("main".to_string()), diff --git a/src/crates/assembly/core/src/service/worktree/session_binding.rs b/src/crates/assembly/core/src/service/worktree/session_binding.rs index 76eff6b925..03ec0d2335 100644 --- a/src/crates/assembly/core/src/service/worktree/session_binding.rs +++ b/src/crates/assembly/core/src/service/worktree/session_binding.rs @@ -12,8 +12,6 @@ use crate::agentic::coordination::get_global_coordinator; use crate::agentic::keyed_lock::KeyedAsyncLock; use crate::agentic::session::{SessionExecutionBindingError, SessionExecutionBindingUpdate}; -use crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST; -use crate::service::workspace::get_global_workspace_service; use crate::service::worktree::{ WorktreeCreateRequest, WorktreeListRequest, WorktreeRemoveRequest, WorktreeService, }; @@ -21,7 +19,6 @@ use openbitfun_core_types::{ SessionExecutionTarget, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, }; use serde::{Deserialize, Serialize}; -use std::path::Path; use std::sync::LazyLock; /// Serializes the complete Git-create/rebind/release transition for one session. @@ -38,7 +35,11 @@ static SESSION_BINDING_LOCKS: LazyLock = LazyLock::new(KeyedAsyn pub struct WorktreeSessionBindingRequest { pub request_id: String, pub session_id: String, - /// Stable owner path used to locate view-only or evicted persisted sessions. + /// Owning project workspace ID used to locate view-only or evicted + /// persisted sessions. Authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, + /// Legacy owner path for peers that predate workspace IDs. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_workspace_path: Option, /// `true` moves the session into a managed worktree, `false` back to the project. @@ -48,6 +49,8 @@ pub struct WorktreeSessionBindingRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeSessionBindingResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, pub session_id: String, pub workspace_path: String, pub project_workspace_path: String, @@ -61,6 +64,8 @@ pub struct WorktreeSessionBindingResult { /// Session facts the binding decision depends on. struct SessionBindingContext { + workspace_id: Option, + project_workspace_id: Option, project_workspace_path: String, execution_target: SessionExecutionTarget, /// Why an actual transition is forbidden. An already-satisfied binding @@ -95,8 +100,15 @@ async fn load_binding_context( let session_manager = coordinator.get_session_manager(); let session = session_manager.get_session(&request.session_id); - let (workspace_path, project_workspace_path, execution_target, mut transition_blocker) = - if let Some(session) = session { + let workspace_id; + let project_workspace_id; + let (workspace_path, project_workspace_path, execution_target, transition_blocker) = + if let Some(mut session) = session { + crate::agentic::workspace::normalize_session_workspace(&mut session.config) + .await + .map_err(|error_| error(WorktreeErrorCode::IoFailed, error_.to_string()))?; + workspace_id = session.config.workspace_id.clone(); + project_workspace_id = session.config.project_workspace_id.clone(); let transition_blocker = if !session.dialog_turn_ids.is_empty() { Some(error( WorktreeErrorCode::WorktreeBusy, @@ -107,7 +119,7 @@ async fn load_binding_context( WorktreeErrorCode::WorktreeBusy, "Worktree isolation cannot be changed while the session is processing", )) - } else if session.config.remote_connection_id.is_some() { + } else if session.config.is_remote_workspace() { Some(error( WorktreeErrorCode::RemoteUnsupported, "Managed worktrees are not supported for remote SSH workspaces yet", @@ -139,23 +151,59 @@ async fn load_binding_context( transition_blocker, ) } else { - let project_workspace_path = request - .project_workspace_path + let requested_project_workspace_id = request + .project_workspace_id .as_deref() .map(str::trim) - .filter(|path| !path.is_empty()) - .ok_or_else(|| { - error( - WorktreeErrorCode::WorktreeNotFound, - format!( - "Session not found: {}. The project workspace path is required to restore historical sessions", - request.session_id - ), - ) - })? - .to_string(); + .filter(|id| !id.is_empty()); + let (project_workspace_path, session_storage_root) = + match requested_project_workspace_id { + Some(requested_project_workspace_id) => { + use openbitfun_runtime_ports::SessionStorePort; + let workspace = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| { + error( + WorktreeErrorCode::IoFailed, + "Workspace service is not initialized", + ) + })? + .require_workspace(requested_project_workspace_id) + .await + .map_err(|workspace_error| { + error(WorktreeErrorCode::InvalidPath, workspace_error.to_string()) + })?; + let storage = crate::agentic::session::session_store_port::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map_err(|storage_error| { + error(WorktreeErrorCode::IoFailed, storage_error.to_string()) + })?; + ( + workspace.root_path.to_string_lossy().into_owned(), + storage.effective_storage_path, + ) + } + None => { + let path = request + .project_workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + format!( + "Session not found: {}. The project workspace ID is required to restore historical sessions", + request.session_id + ), + ) + })? + .to_string(); + (path.clone(), std::path::PathBuf::from(path)) + } + }; let metadata = session_manager - .load_session_metadata(Path::new(&project_workspace_path), &request.session_id) + .load_session_metadata(&session_storage_root, &request.session_id) .await .map_err(|metadata_error| { error( @@ -169,15 +217,30 @@ async fn load_binding_context( format!("Session not found: {}", request.session_id), ) })?; + let mut config = crate::agentic::core::SessionConfig { + workspace_id: metadata.workspace_id.clone(), + project_workspace_id: metadata.project_workspace_id.clone(), + workspace_path: metadata + .workspace_path + .clone() + .or_else(|| Some(project_workspace_path.clone())), + project_workspace_path: metadata.project_workspace_path.clone(), + remote_ssh_host: metadata.workspace_hostname.clone(), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .map_err(|error_| error(WorktreeErrorCode::IoFailed, error_.to_string()))?; + workspace_id = config.workspace_id.clone(); + project_workspace_id = config.project_workspace_id.clone(); let transition_blocker = if metadata.turn_count > 0 { Some(error( WorktreeErrorCode::WorktreeBusy, "Worktree isolation can only be changed before the session's first message", )) - } else if persisted_session_is_remote(metadata.workspace_hostname.as_deref()) { - // The persisted record carries the remote marker itself, so a - // build without the SSH registry still refuses here instead - // of creating a controller-local worktree for a remote path. + } else if config.is_remote_workspace() { + // The owning workspace record determines location after the + // historical metadata has crossed the upgrade adapter. Some(error( WorktreeErrorCode::RemoteUnsupported, "Managed worktrees are not supported for remote SSH workspaces yet", @@ -206,16 +269,6 @@ async fn load_binding_context( ) }; - if transition_blocker.is_none() - && crate::service::remote_ssh::workspace_state::is_remote_path(&project_workspace_path) - .await - { - transition_blocker = Some(error( - WorktreeErrorCode::RemoteUnsupported, - "Managed worktrees are not supported for remote SSH workspaces yet", - )); - } - if workspace_path.trim().is_empty() { return Err(error( WorktreeErrorCode::InvalidPath, @@ -224,6 +277,8 @@ async fn load_binding_context( } Ok(SessionBindingContext { + workspace_id, + project_workspace_id, project_workspace_path, execution_target, transition_blocker, @@ -232,12 +287,6 @@ async fn load_binding_context( /// A persisted session names its workspace host; anything but the local host /// marks the session as remote even when no SSH connection is registered. -fn persisted_session_is_remote(workspace_hostname: Option<&str>) -> bool { - workspace_hostname - .map(str::trim) - .is_some_and(|host| !host.is_empty() && host != LOCAL_WORKSPACE_SSH_HOST) -} - fn binding_action( context: &SessionBindingContext, enabled: bool, @@ -258,17 +307,12 @@ fn binding_action( }) } -async fn current_workspace_id(root_path: &str) -> Option { - get_global_workspace_service()? - .get_workspace_by_path(Path::new(root_path)) - .await - .map(|workspace| workspace.id) -} - async fn rebind( session_id: &str, project_workspace_path: &str, execution_target: SessionExecutionTarget, + workspace_id: Option, + project_workspace_id: Option, ) -> Result { let coordinator = get_global_coordinator().ok_or_else(|| { error( @@ -276,7 +320,12 @@ async fn rebind( "Session coordinator is not initialized", ) })?; - let workspace_id = current_workspace_id(&execution_target.root_path).await; + let workspace_id = Some(workspace_id.ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Workspace ID is required for a session binding", + ) + })?); coordinator .get_session_manager() @@ -304,6 +353,7 @@ async fn rebind( })?; Ok(WorktreeSessionBindingResult { + project_workspace_id, session_id: session_id.to_string(), workspace_path: execution_target.root_path.clone(), project_workspace_path: project_workspace_path.to_string(), @@ -329,10 +379,11 @@ impl WorktreeService { SessionBindingAction::AlreadyBound => { // Already in the requested state; report it rather than churn Git. Ok(WorktreeSessionBindingResult { + project_workspace_id: context.project_workspace_id.clone(), session_id: request.session_id, workspace_path: context.execution_target.root_path.clone(), project_workspace_path: context.project_workspace_path, - workspace_id: current_workspace_id(&context.execution_target.root_path).await, + workspace_id: context.workspace_id.clone(), execution_target: context.execution_target, retained_worktree_path: None, }) @@ -351,6 +402,7 @@ impl WorktreeService { let settings = Self::settings().await; let created = Self::create(WorktreeCreateRequest { request_id: request.request_id.clone(), + project_workspace_id: None, project_workspace_path: context.project_workspace_path.clone(), source_workspace_path: Some(context.execution_target.root_path.clone()), base_ref: None, @@ -365,6 +417,8 @@ impl WorktreeService { &request.session_id, &created.worktree.project_workspace_path, created.execution_target, + created.worktree.workspace_id.clone(), + context.project_workspace_id.clone(), ) .await { @@ -410,10 +464,13 @@ impl WorktreeService { &request.session_id, &context.project_workspace_path, SessionExecutionTarget::local(context.project_workspace_path.clone()), + context.project_workspace_id.clone(), + context.project_workspace_id.clone(), ) .await?; let removable = Self::list(WorktreeListRequest { + project_workspace_id: None, project_workspace_path: context.project_workspace_path.clone(), }) .await @@ -436,6 +493,7 @@ impl WorktreeService { if removable { match Self::remove(WorktreeRemoveRequest { request_id: request.request_id.clone(), + project_workspace_id: None, project_workspace_path: context.project_workspace_path.clone(), worktree_id, force: false, @@ -457,21 +515,12 @@ impl WorktreeService { #[cfg(test)] mod tests { use super::{ - binding_action, error, persisted_session_is_remote, SessionBindingAction, - SessionBindingContext, WorktreeSessionBindingRequest, SESSION_BINDING_LOCKS, + binding_action, error, SessionBindingAction, SessionBindingContext, + WorktreeSessionBindingRequest, SESSION_BINDING_LOCKS, }; use openbitfun_core_types::{SessionExecutionTarget, WorktreeErrorCode}; use std::time::Duration; - #[test] - fn persisted_workspace_hostname_marks_remote_sessions() { - assert!(!persisted_session_is_remote(None)); - assert!(!persisted_session_is_remote(Some("localhost"))); - assert!(!persisted_session_is_remote(Some(" "))); - assert!(persisted_session_is_remote(Some("build.example.test"))); - assert!(persisted_session_is_remote(Some("_unresolved"))); - } - #[test] fn binding_request_keeps_legacy_callers_compatible() { let request: WorktreeSessionBindingRequest = serde_json::from_value(serde_json::json!({ @@ -503,6 +552,8 @@ mod tests { #[test] fn already_satisfied_binding_is_a_no_op_after_the_first_message() { let context = SessionBindingContext { + workspace_id: Some("workspace".into()), + project_workspace_id: Some("workspace".into()), project_workspace_path: "/repo".to_string(), execution_target: SessionExecutionTarget::local("/repo"), transition_blocker: Some(error( diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 3fcb223853..038d24afab 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -35,8 +35,7 @@ use openbitfun_runtime_ports::{ AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, AgentSessionRollbackToTurnRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentThreadGoalManagementPort, AgentTurnCancellationPort, AgentUserShellCommandPort, - AgentWorkspaceReferencePort, PortError, PortErrorKind, PortResult, SessionStoragePathRequest, - SessionStorePort, + AgentWorkspaceReferencePort, PortError, PortErrorKind, PortResult, SessionStorePort, }; #[cfg(feature = "remote-connect")] use openbitfun_services_integrations::remote_connect::{ @@ -60,7 +59,6 @@ use openbitfun_services_integrations::remote_connect::{ RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; -use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -102,14 +100,14 @@ struct ConfiguredPluginSubmissionPort { #[cfg(feature = "opencode-plugin-host")] impl ConfiguredPluginSubmissionPort { async fn try_ensure_workspace(request: &AgentSessionCreateRequest) -> PortResult<()> { - let Some(execution_root) = configured_plugin_execution_root(request)? else { + let Some(_execution_root) = configured_plugin_execution_root(request).await? else { return Ok(()); }; crate::plugin_host::ensure_configured_plugin_instance( crate::plugin_host::PluginHostLaunchPolicy::Enabled, - execution_root.clone(), - execution_root, - request.workspace_id.clone(), + request.workspace_id.as_deref().ok_or_else(|| { + PortError::new(PortErrorKind::InvalidRequest, "Workspace ID is required") + })?, ) .await .map(|_| ()) @@ -118,14 +116,9 @@ impl ConfiguredPluginSubmissionPort { async fn ensure_workspace(request: &AgentSessionCreateRequest) { if let Err(error) = Self::try_ensure_workspace(request).await { - let workspace = request - .execution_target - .as_ref() - .map(|target| Path::new(&target.root_path)) - .or_else(|| request.workspace_path.as_deref().map(Path::new)); crate::plugin_host::report_configured_plugin_activation_failure( "session creation", - workspace, + request.workspace_id.as_deref(), error, ) .await; @@ -140,20 +133,19 @@ impl ConfiguredPluginSubmissionPort { else { return Ok(()); }; - let Some(execution_root) = configured_plugin_root_from_session_facts( + let Some(_execution_root) = configured_plugin_root_from_session_facts( session.config.workspace_path.as_deref(), session.config.execution_target.as_ref(), - session.config.remote_connection_id.as_deref(), - session.config.remote_ssh_host.as_deref(), + session.config.is_remote_workspace(), )? else { return Ok(()); }; crate::plugin_host::ensure_configured_plugin_instance( crate::plugin_host::PluginHostLaunchPolicy::Enabled, - execution_root.clone(), - execution_root, - session.config.workspace_id.clone(), + session.config.workspace_id.as_deref().ok_or_else(|| { + PortError::new(PortErrorKind::InvalidRequest, "Workspace ID is required") + })?, ) .await .map(|_| ()) @@ -173,14 +165,24 @@ impl ConfiguredPluginSubmissionPort { } #[cfg(feature = "opencode-plugin-host")] -fn configured_plugin_execution_root( +async fn configured_plugin_execution_root( request: &AgentSessionCreateRequest, ) -> PortResult> { + let mut config = crate::agentic::core::SessionConfig { + workspace_id: request.workspace_id.clone(), + workspace_path: request.workspace_path.clone(), + project_workspace_path: request.project_workspace_path.clone(), + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: request.remote_ssh_host.clone(), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; configured_plugin_root_from_session_facts( request.workspace_path.as_deref(), request.execution_target.as_ref(), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), + config.is_remote_workspace(), ) } @@ -188,10 +190,9 @@ fn configured_plugin_execution_root( fn configured_plugin_root_from_session_facts( workspace_path: Option<&str>, execution_target: Option<&openbitfun_core_types::SessionExecutionTarget>, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, + is_remote: bool, ) -> PortResult> { - if remote_connection_id.is_some() || remote_ssh_host.is_some() { + if is_remote { return Ok(None); } execution_target @@ -369,19 +370,6 @@ fn configured_plugin_dialog_turn_port( } } -#[cfg(feature = "remote-connect")] -fn session_storage_request_from_binding(binding: &WorkspaceBinding) -> SessionStoragePathRequest { - SessionStoragePathRequest { - workspace_path: binding.logical_workspace_path().to_path_buf(), - remote_connection_id: binding.connection_id().map(ToOwned::to_owned), - remote_ssh_host: if binding.is_remote() { - Some(binding.session_identity.hostname.clone()).filter(|value| !value.trim().is_empty()) - } else { - None - }, - } -} - #[cfg(feature = "remote-connect")] fn remote_workspace_kind( kind: crate::service::workspace::WorkspaceKind, @@ -460,6 +448,7 @@ pub(crate) async fn remote_opened_workspace_catalog( .await .into_iter() .map(|workspace| RemoteRecentWorkspaceFacts { + workspace_id: workspace.id.clone(), name: remote_workspace_display_name(&workspace).to_string(), path: workspace.root_path.to_string_lossy().to_string(), last_opened: workspace.last_accessed.to_rfc3339(), @@ -487,6 +476,7 @@ async fn current_remote_workspace_facts() -> Option { .map(|workspace| { let root_path = workspace.root_path.clone(); RemoteWorkspaceFacts { + workspace_id: workspace.id.clone(), path: root_path.to_string_lossy().to_string(), name: workspace.name, git_branch: git_branch_for_workspace_path(&root_path), @@ -518,7 +508,7 @@ async fn open_workspace_with_snapshot( let workspace_service = crate::service::workspace::get_global_workspace_service() .ok_or_else(|| "Workspace service not available".to_string())?; let info = coordinator - .open_workspace_with_runtime_ownership( + .upgrade_legacy_workspace_with_runtime_ownership( workspace_service.as_ref(), std::path::PathBuf::from(path), remote_connection_id, @@ -531,6 +521,7 @@ async fn open_workspace_with_snapshot( let remote_ssh_host = remote_workspace_metadata(&info.workspace_kind, &info.metadata, "sshHost"); Ok(RemoteWorkspaceUpdate { + workspace_id: info.id.clone(), path: info.root_path.to_string_lossy().to_string(), name: info.name, remote_connection_id, @@ -539,45 +530,50 @@ async fn open_workspace_with_snapshot( } #[cfg(feature = "remote-connect")] -async fn ensure_remote_workspace_runtime_ownership( +async fn ensure_remote_binding_runtime_ownership( coordinator: &ConversationCoordinator, - workspace_path: &std::path::Path, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, + binding: &WorkspaceBinding, ) -> Result<(), String> { - if let Some(connection_id) = remote_connection_id { - let workspace_service = crate::service::workspace::get_global_workspace_service() - .ok_or_else(|| "Workspace service not available".to_string())?; - coordinator - .ensure_known_remote_workspace_runtime_ownership( - workspace_service.as_ref(), - workspace_path, - connection_id, - remote_ssh_host, - ) - .await - .map_err(|error| error.to_string()) - } else { - coordinator - .ensure_workspace_runtime_ownership(workspace_path, None, remote_ssh_host) - .map_err(|error| error.to_string()) - } + // The persisted binding names its workspace by ID; the path and SSH + // projection only serves sessions written before workspace IDs. + coordinator + .ensure_workspace_runtime_ownership_for_reference( + binding.workspace_id.as_deref(), + &binding.logical_workspace_path_string(), + binding.connection_id(), + binding + .is_remote() + .then_some(binding.session_identity.hostname.as_str()), + ) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) } +/// Project a workspace record into the remote wire facts. Only the ID +/// identifies the workspace; the path and SSH fields are IO projections. #[cfg(feature = "remote-connect")] -async fn ensure_remote_binding_runtime_ownership( - coordinator: &ConversationCoordinator, - binding: &WorkspaceBinding, -) -> Result<(), String> { - ensure_remote_workspace_runtime_ownership( - coordinator, - binding.logical_workspace_path(), - binding.connection_id(), - binding - .is_remote() - .then_some(binding.session_identity.hostname.as_str()), - ) - .await +fn remote_workspace_facts_from_record( + workspace: &crate::service::workspace::WorkspaceInfo, +) -> RemoteWorkspaceFacts { + RemoteWorkspaceFacts { + workspace_id: workspace.id.clone(), + path: workspace.root_path.to_string_lossy().into_owned(), + name: workspace.name.clone(), + git_branch: None, + kind: remote_workspace_kind(workspace.workspace_kind.clone()), + assistant_id: workspace.assistant_id.clone(), + remote_connection_id: remote_workspace_metadata( + &workspace.workspace_kind, + &workspace.metadata, + "connectionId", + ), + remote_ssh_host: remote_workspace_metadata( + &workspace.workspace_kind, + &workspace.metadata, + "sshHost", + ), + } } #[cfg(feature = "remote-connect")] @@ -586,18 +582,23 @@ async fn load_remote_session_metadata_for_workspace( workspace_identity: RemoteSessionWorkspaceIdentity, ) -> Result, String> { let workspace_path_display = workspace_path.to_string_lossy().to_string(); + // Remote handlers translate pre-ID references before reaching this + // loader, so the record ID is the only storage key accepted here; the + // path is display data for diagnostics. + let workspace_id = workspace_identity + .workspace_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + format!("Workspace ID is required to list sessions for {workspace_path_display}") + })?; let session_storage_dir = CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: workspace_path.to_path_buf(), - remote_connection_id: workspace_identity.remote_connection_id, - remote_ssh_host: workspace_identity.remote_ssh_host, - }) + .resolve_workspace_storage(&workspace_id) .await - .map(|resolution| resolution.effective_storage_path) - .map_err(|error| { - debug!("Session storage path resolution failed for {workspace_path_display}: {error}"); - format!("Failed to resolve session storage for workspace: {error}") - })?; + .map_err(|error| format!("Failed to resolve session storage for workspace: {error}"))? + .effective_storage_path; let path_manager = crate::infrastructure::PathManager::new() .map_err(|_| "Failed to initialize path manager".to_string())?; let path_manager = std::sync::Arc::new(path_manager); @@ -639,6 +640,10 @@ async fn load_remote_session_metadata_for_workspace( let parent_session_id = relationship.and_then(|relationship| relationship.parent_session_id); RemoteSessionMetadata { + workspace_id: session + .workspace_id + .clone() + .or_else(|| Some(workspace_id.clone())), session_id: session.session_id, name: session.session_name, agent_type: session.agent_type, @@ -952,32 +957,40 @@ impl AgentModeCatalogPort for CoreAgentModeCatalogPort { &self, query: AgentModeCatalogQuery, ) -> openbitfun_runtime_ports::PortResult> { - let workspace = query.workspace_root.as_deref().map(Path::new); - #[cfg(feature = "external-sources")] - let external_supported = if query.include_external { - if let Some(workspace) = workspace { - !crate::service::remote_ssh::workspace_state::is_remote_path( - &workspace.to_string_lossy(), + let record = + crate::service::workspace::legacy_compat::upgrade_optional_workspace_reference( + query.workspace_id.as_deref(), + query.workspace_root.as_deref(), + ) + .await + .map_err(|error| { + openbitfun_runtime_ports::PortError::new( + openbitfun_runtime_ports::PortErrorKind::InvalidRequest, + error, ) - .await - } else { - false - } - } else { - false - }; + })?; + #[cfg(feature = "external-sources")] + let external_supported = query.include_external + && record.as_ref().is_some_and(|record| { + record.workspace_kind != crate::service::workspace::WorkspaceKind::Remote + }); #[cfg(not(feature = "external-sources"))] let external_supported = false; #[cfg(feature = "external-sources")] if external_supported { - if let Err(error) = - crate::external_sources::ensure_external_source_workspace_snapshot(workspace).await + if let Err(error) = crate::external_sources::ensure_external_source_workspace_snapshot( + record.as_ref().map(|record| record.id.as_str()), + ) + .await { log::warn!("Failed to initialize external agent sources for mode catalog: {error}"); } } let modes = crate::agentic::agents::get_agent_registry() - .get_modes_info_for_workspace(workspace, external_supported) + .get_modes_info_for_workspace( + record.as_ref().map(|record| record.id.as_str()), + external_supported, + ) .await; Ok(modes .into_iter() @@ -1017,7 +1030,14 @@ impl ScheduledSessionManagementPort { message, ) })?; - if request.remote_connection_id.is_some() || request.remote_ssh_host.is_some() { + let workspace = resolve_history_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + if workspace.is_remote() { return Err(openbitfun_runtime_ports::PortError::new( openbitfun_runtime_ports::PortErrorKind::NotAvailable, "Session undo and redo are unavailable for remote workspaces", @@ -1025,6 +1045,7 @@ impl ScheduledSessionManagementPort { } self.coordinator .local_revert_workspace(&request.session_id) + .await .map_err(|error| { if matches!(&error, crate::util::errors::OpenBitFunError::Validation(message) if message == "Session undo and redo are unavailable for remote workspaces") { @@ -1037,11 +1058,12 @@ impl ScheduledSessionManagementPort { } })?; let storage_path = CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(&request.workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - }) + .resolve_workspace_storage( + workspace + .workspace_id + .as_deref() + .expect("resolved workspace ID"), + ) .await .map(|resolution| resolution.effective_storage_path)?; let session_manager = self.coordinator.get_session_manager(); @@ -1130,18 +1152,26 @@ impl AgentSessionRevertPort for ScheduledSessionManagementPort { message, ) })?; - if request.remote_connection_id.is_some() || request.remote_ssh_host.is_some() { + let workspace = resolve_history_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + if workspace.is_remote() { return Err(openbitfun_runtime_ports::PortError::new( openbitfun_runtime_ports::PortErrorKind::NotAvailable, "Session rollback is unavailable for remote workspaces", )); } let storage_path = CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(&request.workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - }) + .resolve_workspace_storage( + workspace + .workspace_id + .as_deref() + .expect("resolved workspace ID"), + ) .await .map(|resolution| resolution.effective_storage_path)?; let session_manager = self.coordinator.get_session_manager(); @@ -1156,6 +1186,7 @@ impl AgentSessionRevertPort for ScheduledSessionManagementPort { } self.coordinator .local_revert_workspace(&request.session_id) + .await .map_err(map_session_close_error)?; session_manager .validate_session_storage_path_binding(&request.session_id, &storage_path) @@ -1314,11 +1345,12 @@ impl AgentSessionManagementPort for ScheduledSessionManagementPort { ) })?; let storage_path = CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(&request.workspace_path), - remote_connection_id: request.remote_connection_id.clone(), - remote_ssh_host: request.remote_ssh_host.clone(), - }) + .resolve_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + ) .await .map(|resolution| resolution.effective_storage_path) .map_err(|error| { @@ -1425,11 +1457,12 @@ impl ScheduledSessionManagementPort { ) })?; let storage_path = CoreSessionStorePort::default() - .resolve_session_storage_path(SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(&request.workspace_path), - remote_connection_id: request.remote_connection_id.clone(), - remote_ssh_host: request.remote_ssh_host.clone(), - }) + .resolve_storage_for_reference( + request.workspace_id.as_deref(), + &request.workspace_path, + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + ) .await? .effective_storage_path; let session_manager = self.coordinator.get_session_manager(); @@ -1664,6 +1697,7 @@ impl CoreServiceAgentRuntime { async fn scoped_remote_file_target( path: &str, session_id: Option<&str>, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, ) -> Result< @@ -1673,6 +1707,7 @@ impl CoreServiceAgentRuntime { Self::scoped_remote_file_target_with_identity( path, session_id, + workspace_id, workspace_path, remote_connection_id, ) @@ -1680,10 +1715,15 @@ impl CoreServiceAgentRuntime { .map(|(target, _)| target) } + /// Resolve an explicit file workspace. The workspace ID is authoritative: + /// the record's kind and saved connection route the IO. `workspace_path` + /// + `remote_connection_id` is the legacy projection for pre-ID + /// controllers and is only consulted when no ID is supplied. #[cfg(feature = "remote-connect")] pub(crate) async fn scoped_remote_file_target_with_identity( path: &str, session_id: Option<&str>, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, ) -> Result< @@ -1696,12 +1736,53 @@ impl CoreServiceAgentRuntime { // An empty connection ID is the explicit local-provider marker shared // with directory/CRUD calls; absence is also local for scoped files. let remote_connection_id = remote_connection_id.filter(|id| !id.is_empty()); + let workspace_id = workspace_id.map(str::trim).filter(|id| !id.is_empty()); if session_id.is_some() { - if workspace_path.is_some() || remote_connection_id.is_some() { + if workspace_id.is_some() || workspace_path.is_some() || remote_connection_id.is_some() + { return Err("Use either a session or an explicit file workspace".into()); } return Self::remote_file_target_with_identity(path, session_id).await; } + if let Some(workspace_id) = workspace_id { + let config = crate::agentic::core::SessionConfig { + workspace_id: Some(workspace_id.to_string()), + ..Default::default() + }; + let binding = ConversationCoordinator::build_workspace_binding(&config) + .await + .ok_or_else(|| format!("File workspace {} cannot be resolved", workspace_id))?; + if let Some(expected) = remote_connection_id { + if binding.connection_id() != Some(expected) { + return Err( + "Explicit file workspace provider does not match its connection identity" + .into(), + ); + } + } + #[cfg(feature = "ssh-remote")] + if let Some(connection_id) = binding.connection_id() { + let state = + crate::service::remote_ssh::workspace_state::ensure_saved_connection_services() + .await?; + let ssh = state + .get_ssh_manager() + .await + .ok_or("SSH connection manager is unavailable")?; + ssh.ensure_connected(connection_id) + .await + .map_err(|error| error.to_string())?; + } + let target_id = binding + .connection_id() + .map(str::to_string) + .unwrap_or_else(|| "local".to_string()); + let target = Self::file_target_for_binding(path, None, binding).await?; + if target.remote && target_id == "local" { + return Err("Remote file target has no connection identity".into()); + } + return Ok((target, target_id)); + } let workspace_path = workspace_path .filter(|value| !value.trim().is_empty()) .ok_or("File workspace identity is required when no session is supplied")?; @@ -1738,18 +1819,9 @@ impl CoreServiceAgentRuntime { remote_connection_id: remote_connection_id.map(str::to_string), ..Default::default() }; - let binding = if remote_connection_id.is_some() { - ConversationCoordinator::build_workspace_binding(&config) - .await - .ok_or("Explicit file workspace cannot be resolved")? - } else { - // An explicit local provider must not infer SSH from a same-named - // open remote workspace or the runtime's currently selected root. - WorkspaceBinding::new( - ConversationCoordinator::resolve_workspace_id_for_config(&config).await, - std::path::PathBuf::from(workspace_path), - ) - }; + let binding = ConversationCoordinator::build_workspace_binding(&config) + .await + .ok_or("Explicit file workspace cannot be resolved")?; if binding.connection_id() != remote_connection_id { return Err( "Explicit file workspace provider does not match its connection identity".into(), @@ -2066,10 +2138,7 @@ impl CoreServiceAgentRuntime { .is_none() { coordinator - .restore_session_for_workspace( - session_storage_request_from_binding(&binding), - session_id, - ) + .restore_session_for_workspace_binding(&binding, session_id) .await .map_err(|e| format!("Failed to restore session: {e}"))?; } @@ -2773,6 +2842,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { .resolve_session_workspace_binding(session_id) .await .map(|binding| RemoteDialogWorkspaceBinding { + workspace_id: binding.workspace_id.clone(), workspace_path: binding.logical_workspace_path_string(), remote_connection_id: binding.connection_id().map(ToOwned::to_owned), remote_ssh_host: if binding.is_remote() { @@ -2797,20 +2867,14 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { session_id: &str, workspace: RemoteDialogWorkspaceBinding, ) -> Result<(), String> { - ensure_remote_workspace_runtime_ownership( - self.coordinator.as_ref(), - std::path::Path::new(&workspace.workspace_path), - workspace.remote_connection_id.as_deref(), - workspace.remote_ssh_host.as_deref(), - ) - .await?; + // The binding's workspace ID selects the record, its runtime scope, and + // its storage; the legacy path fields only serve pre-ID bindings. self.coordinator - .restore_session_for_workspace( - SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(workspace.workspace_path), - remote_connection_id: workspace.remote_connection_id, - remote_ssh_host: workspace.remote_ssh_host, - }, + .restore_session_for_workspace_reference( + workspace.workspace_id.as_deref(), + &workspace.workspace_path, + workspace.remote_connection_id.as_deref(), + workspace.remote_ssh_host.as_deref(), session_id, ) .await @@ -2829,13 +2893,13 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { else { return; }; - if session.config.remote_connection_id.is_some() || session.config.remote_ssh_host.is_some() - { + if session.config.is_remote_workspace() { // SSH execution prepares its terminal through RemoteExecPort. The // local terminal binding has no target identity and must not run here. return; } + let workspace_id = session.config.workspace_id.clone(); let sid = request.session_id; let binding_workspace_for_terminal = request.binding_workspace; tokio::spawn(async move { @@ -2852,6 +2916,10 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { .get_or_create( &sid, TerminalBindingOptions { + owner: workspace_id.map(|id| terminal_core::session::SessionOwner { + id, + owner_type: terminal_core::session::OwnerType::Workspace, + }), working_directory: workspace, session_id: Some(sid.clone()), session_name: Some(name), @@ -2884,6 +2952,9 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { .collect(); let binding_workspace = submission.binding_workspace; + let workspace_id = binding_workspace + .as_ref() + .and_then(|binding| binding.workspace_id.clone()); let workspace_path = binding_workspace .as_ref() .map(|binding| binding.workspace_path.clone()); @@ -2894,13 +2965,15 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { .as_ref() .and_then(|binding| binding.remote_ssh_host.clone()); if let Some(path) = workspace_path.as_deref() { - ensure_remote_workspace_runtime_ownership( - self.coordinator.as_ref(), - std::path::Path::new(path), - remote_connection_id.as_deref(), - remote_ssh_host.as_deref(), - ) - .await?; + self.coordinator + .ensure_workspace_runtime_ownership_for_reference( + workspace_id.as_deref(), + path, + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await + .map_err(|error| error.to_string())?; } self.runtime @@ -2913,6 +2986,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { execution: Default::default(), agent_type: submission.resolved_agent_type, workspace_path, + workspace_id, remote_connection_id, remote_ssh_host, policy, @@ -2955,6 +3029,7 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { &self, path: &str, session_id: Option<&str>, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, offset: u64, @@ -2963,6 +3038,7 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { CoreServiceAgentRuntime::scoped_remote_file_target( path, session_id, + workspace_id, workspace_path, remote_connection_id, ) @@ -2976,12 +3052,14 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { &self, path: &str, session_id: Option<&str>, + workspace_id: Option<&str>, workspace_path: Option<&str>, remote_connection_id: Option<&str>, ) -> Result, String> { CoreServiceAgentRuntime::scoped_remote_file_target( path, session_id, + workspace_id, workspace_path, remote_connection_id, ) @@ -3009,6 +3087,7 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { .await .into_iter() .map(|workspace| RemoteRecentWorkspaceFacts { + workspace_id: workspace.id.clone(), path: workspace.root_path.to_string_lossy().to_string(), name: workspace.name.clone(), last_opened: workspace.last_accessed.to_rfc3339(), @@ -3035,6 +3114,32 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { )) } + async fn select_workspace(&self, workspace_id: &str) -> Result { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let coordinator = get_global_coordinator() + .ok_or_else(|| "Conversation coordinator not initialized".to_string())?; + let workspace = coordinator + .select_workspace_with_runtime_ownership(&service, workspace_id) + .await + .map_err(|error| error.to_string())?; + Ok(RemoteWorkspaceUpdate { + workspace_id: workspace.id.clone(), + path: workspace.root_path.to_string_lossy().into_owned(), + name: workspace.name.clone(), + remote_connection_id: remote_workspace_metadata( + &workspace.workspace_kind, + &workspace.metadata, + "connectionId", + ), + remote_ssh_host: remote_workspace_metadata( + &workspace.workspace_kind, + &workspace.metadata, + "sshHost", + ), + }) + } + async fn open_workspace( &self, path: &str, @@ -3050,6 +3155,22 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { .await } + async fn select_assistant_workspace( + &self, + workspace_id: &str, + ) -> Result { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service is unavailable".to_string())?; + let record = service + .require_workspace(workspace_id) + .await + .map_err(|error| error.to_string())?; + if record.workspace_kind != crate::service::workspace::WorkspaceKind::Assistant { + return Err("Selected workspace is not an assistant workspace".to_string()); + } + self.select_workspace(workspace_id).await + } + async fn assistant_workspaces(&self) -> Vec { let Some(workspace_service) = crate::service::workspace::get_global_workspace_service() else { @@ -3060,6 +3181,7 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { .await .into_iter() .map(|workspace| RemoteAssistantWorkspaceFacts { + workspace_id: workspace.id.clone(), path: workspace.root_path.to_string_lossy().to_string(), name: workspace.name, assistant_id: workspace.assistant_id, @@ -3091,6 +3213,16 @@ impl RemoteInitialSyncRuntimeHost for CoreRemoteWorkspaceRuntimeHost { #[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { + async fn workspace_by_id(&self, workspace_id: &str) -> Result { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let workspace = service + .require_workspace(workspace_id) + .await + .map_err(|error| error.to_string())?; + Ok(remote_workspace_facts_from_record(&workspace)) + } + async fn list_session_metadata( &self, workspace_path: &std::path::Path, @@ -3099,18 +3231,37 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { load_remote_session_metadata_for_workspace(workspace_path, workspace_identity).await } - async fn resolve_default_assistant_workspace_path(&self) -> Result { - let workspace_service = crate::service::workspace::get_global_workspace_service() + async fn resolve_legacy_workspace( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Result, String> { + let service = crate::service::workspace::get_global_workspace_service() .ok_or_else(|| "Workspace service not available".to_string())?; - if let Some(primary_workspace) = workspace_service.get_primary_assistant_workspace().await { - return Ok(primary_workspace.root_path.to_string_lossy().to_string()); - } - - workspace_service - .create_assistant_workspace(None) + let workspace = service + .resolve_legacy_workspace_reference( + None, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) .await - .map(|workspace| workspace.root_path.to_string_lossy().to_string()) - .map_err(|error| format!("Failed to create assistant workspace: {}", error)) + .map_err(|error| error.to_string())?; + Ok(workspace.as_ref().map(remote_workspace_facts_from_record)) + } + + async fn resolve_default_assistant_workspace(&self) -> Result { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let workspace = match workspace_service.get_primary_assistant_workspace().await { + Some(primary_workspace) => primary_workspace, + None => workspace_service + .create_assistant_workspace(None) + .await + .map_err(|error| format!("Failed to create assistant workspace: {}", error))?, + }; + Ok(remote_workspace_facts_from_record(&workspace)) } async fn create_session(&self, request: AgentSessionCreateRequest) -> Result { @@ -3161,10 +3312,7 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { } self.coordinator - .restore_session_for_workspace( - session_storage_request_from_binding(&binding), - session_id, - ) + .restore_session_for_workspace_binding(&binding, session_id) .await .map(|_| ()) .map_err(|error| format!("Failed to restore session: {error}")) @@ -3405,10 +3553,7 @@ impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { })?; ensure_remote_binding_runtime_ownership(self.coordinator.as_ref(), &binding).await?; self.coordinator - .restore_session_for_workspace( - session_storage_request_from_binding(&binding), - session_id, - ) + .restore_session_for_workspace_binding(&binding, session_id) .await .map(|_| ()) .map_err(|error| error.to_string()) @@ -3549,18 +3694,22 @@ mod tests { assert!(remote_opened_workspace_catalog(&service).await.is_empty()); } + /// Builds a session create request bound to a registered workspace record. + /// The record is authoritative for local/remote; paths are IO projections. #[cfg(feature = "opencode-plugin-host")] - fn plugin_session_request() -> AgentSessionCreateRequest { + fn plugin_session_request( + workspace: &crate::service::workspace::WorkspaceInfo, + ) -> AgentSessionCreateRequest { AgentSessionCreateRequest { session_name: "session".to_string(), agent_type: "Code".to_string(), agent_route_key: None, - workspace_path: Some("project".to_string()), + workspace_path: Some(workspace.root_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: Some(openbitfun_core_types::SessionExecutionTarget::local( "project-worktree", )), - workspace_id: Some("workspace-a".to_string()), + workspace_id: Some(workspace.id.clone()), remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -3568,47 +3717,81 @@ mod tests { } } + /// Registers a temporary local workspace; the guard keeps its directory + /// alive for the calling test. #[cfg(feature = "opencode-plugin-host")] - #[test] - fn configured_plugins_bind_to_the_session_execution_root() { - let request = plugin_session_request(); + async fn local_plugin_workspace( + ) -> (tempfile::TempDir, crate::service::workspace::WorkspaceInfo) { + let directory = tempfile::tempdir().expect("plugin workspace"); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + directory.path(), + None, + ) + .await; + (directory, record) + } + + #[cfg(feature = "opencode-plugin-host")] + #[tokio::test] + async fn configured_plugins_bind_to_the_session_execution_root() { + let (_workspace_dir, workspace) = local_plugin_workspace().await; + let request = plugin_session_request(&workspace); assert_eq!( - configured_plugin_execution_root(&request).expect("local execution root"), + configured_plugin_execution_root(&request) + .await + .expect("local execution root"), Some(std::path::PathBuf::from("project-worktree")) ); } #[cfg(feature = "opencode-plugin-host")] - #[test] - fn configured_plugins_do_not_execute_for_remote_sessions() { - let mut request = plugin_session_request(); - request.remote_connection_id = Some("remote-a".to_string()); + #[tokio::test] + async fn configured_plugins_do_not_execute_for_remote_sessions() { + let remote = crate::service::workspace::legacy_compat::register_remote_fixture( + &format!("/srv/plugin-remote/{}", uuid::Uuid::new_v4()), + "remote-a", + "remote.example", + ) + .await; + let request = plugin_session_request(&remote); assert_eq!( - configured_plugin_execution_root(&request).expect("remote session is supported"), + configured_plugin_execution_root(&request) + .await + .expect("remote session is supported"), None ); } + #[cfg(feature = "opencode-plugin-host")] + #[tokio::test] + async fn configured_plugins_ignore_stale_transport_hints_on_a_local_record() { + let (_workspace_dir, workspace) = local_plugin_workspace().await; + let mut request = plugin_session_request(&workspace); + request.remote_connection_id = Some("stale-remote".to_string()); + + assert_eq!( + configured_plugin_execution_root(&request) + .await + .expect("the workspace record decides local vs remote"), + Some(std::path::PathBuf::from("project-worktree")) + ); + } + #[cfg(feature = "opencode-plugin-host")] #[test] fn configured_plugins_recover_from_the_persisted_session_execution_root() { let target = openbitfun_core_types::SessionExecutionTarget::local("restored-worktree"); assert_eq!( - configured_plugin_root_from_session_facts(Some("project"), Some(&target), None, None,) + configured_plugin_root_from_session_facts(Some("project"), Some(&target), false,) .expect("restored local execution root"), Some(std::path::PathBuf::from("restored-worktree")) ); assert_eq!( - configured_plugin_root_from_session_facts( - Some("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/remote/project"), - None, - Some("remote-a"), - None, - ) - .expect("remote session remains outside the local Host"), + configured_plugin_root_from_session_facts(Some("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/remote/project"), None, true,) + .expect("remote session remains outside the local Host"), None ); } @@ -3616,7 +3799,8 @@ mod tests { #[cfg(feature = "opencode-plugin-host")] #[tokio::test] async fn configured_plugin_failure_does_not_block_native_session_creation() { - let mut request = plugin_session_request(); + let (_workspace_dir, workspace) = local_plugin_workspace().await; + let mut request = plugin_session_request(&workspace); request.workspace_path = None; request.execution_target = None; @@ -3644,9 +3828,11 @@ mod tests { .nth(1) .and_then(|source| source.split("impl AgentSessionManagementPort").next()) .expect("targeted rollback implementation"); + // Storage is resolved from the owning workspace ID, never from a path. + assert!(!body.contains("resolve_session_storage_path")); let resolve_storage = body - .find("resolve_session_storage_path") - .expect("storage path resolution"); + .find("resolve_workspace_storage") + .expect("storage resolution by workspace ID"); let restore_session = body .find("restore_session_from_storage_path") .expect("disk Session restore"); @@ -3692,8 +3878,8 @@ mod tests { .next() }) .expect("remote workspace open helper"); - assert!(open_workspace.contains("open_workspace_with_runtime_ownership")); - assert!(!open_workspace.contains("open_workspace_resolving_known")); + assert!(open_workspace.contains("upgrade_legacy_workspace_with_runtime_ownership")); + assert!(!open_workspace.contains("upgrade_legacy_workspace_open")); assert!(!open_workspace.contains("initialize_snapshot_manager_for_workspace")); for (start, end) in [ @@ -4194,3 +4380,72 @@ mod tests { } } } + +async fn resolve_history_workspace( + workspace_id: Option<&str>, + path: &str, + connection_id: Option<&str>, + ssh_host: Option<&str>, +) -> PortResult { + let mut config = crate::agentic::core::SessionConfig { + workspace_id: workspace_id.map(str::to_owned), + workspace_path: Some(path.to_owned()), + remote_connection_id: connection_id.map(str::to_owned), + remote_ssh_host: ssh_host.map(str::to_owned), + ..Default::default() + }; + crate::agentic::workspace::normalize_session_workspace(&mut config) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; + WorkspaceBinding::resolve( + config.workspace_id.as_deref().ok_or_else(|| { + PortError::new(PortErrorKind::InvalidRequest, "Workspace ID is required") + })?, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string())) +} + +#[cfg(test)] +mod history_workspace_identity_tests { + use super::resolve_history_workspace; + use crate::service::workspace::legacy_compat::{ + register_local_fixture, register_remote_fixture, + }; + + #[tokio::test] + async fn history_routing_uses_ids_even_with_colliding_roots_and_stale_transport_fields() { + let temp = tempfile::tempdir().unwrap(); + let local = register_local_fixture(temp.path(), None).await; + let remote = register_remote_fixture( + &local.root_path.to_string_lossy(), + "history-test-ssh", + "history.example", + ) + .await; + let binding = resolve_history_workspace( + Some(&local.id), + "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/obsolete/path", + Some("history-test-ssh"), + Some("history.example"), + ) + .await + .unwrap(); + assert!(!binding.is_remote()); + assert_eq!(binding.root_path, local.root_path); + assert_eq!(binding.workspace_id.as_deref(), Some(local.id.as_str())); + let binding = resolve_history_workspace(Some(&remote.id), "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/obsolete/path", None, None) + .await + .unwrap(); + assert!(binding.is_remote()); + assert_eq!(binding.connection_id(), Some("history-test-ssh")); + assert!(resolve_history_workspace( + Some("unavailable-id"), + &local.root_path.to_string_lossy(), + None, + None + ) + .await + .is_err()); + } +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 3f4de3e3ed..72c1828a87 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -52,3 +52,6 @@ pub use worktree::{ WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, }; + +pub mod workspace; +pub use workspace::WorkspaceKind; diff --git a/src/crates/contracts/core-types/src/workspace.rs b/src/crates/contracts/core-types/src/workspace.rs new file mode 100644 index 0000000000..bbc2720596 --- /dev/null +++ b/src/crates/contracts/core-types/src/workspace.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// Workspace lifecycle kind. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKind { + #[default] + Normal, + Assistant, + Remote, +} diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs index 5fed97ce36..ecbe833fd5 100644 --- a/src/crates/contracts/core-types/src/worktree.rs +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -105,6 +105,8 @@ impl Default for WorktreeSettings { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeSessionSummary { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub session_id: String, pub session_name: String, pub status: String, @@ -116,6 +118,8 @@ pub struct WorktreeSessionSummary { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeSummary { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub worktree_id: String, pub project_workspace_path: String, pub path: String, diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 4ef884d545..05435480ad 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:14f5edbe63a604b8", + "digest": "fnv1a64:5fd9f0407289f440", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -24,6 +24,7 @@ "cancel_tool", "tool_catalog", "chat_mcp_catalog_v1", + "workspace_id_references_v1", "user_question_response", "user_question_interaction_v1" ], @@ -43,6 +44,7 @@ "cancel_tool", "tool_catalog", "chat_mcp_catalog_v1", + "workspace_id_references_v1", "user_question_response", "user_question_interaction_v1" ], @@ -57,6 +59,7 @@ "cancel_tool", "tool_catalog", "chat_mcp_catalog_v1", + "workspace_id_references_v1", "user_question_response", "user_question_interaction_v1" ] diff --git a/src/crates/contracts/product-domains/src/product_search.rs b/src/crates/contracts/product-domains/src/product_search.rs index b0457b4c4e..06f2c28570 100644 --- a/src/crates/contracts/product-domains/src/product_search.rs +++ b/src/crates/contracts/product-domains/src/product_search.rs @@ -18,6 +18,10 @@ fn default_search_limit() -> usize { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct SessionContentSearchRequest { + /// Owning workspace ID. Authoritative when present; `workspace_path` is + /// then only a legacy projection for older peers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -154,6 +158,7 @@ mod tests { #[test] fn request_limit_is_bounded_at_the_contract_boundary() { let request = SessionContentSearchRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 3df6f068cc..1dab574540 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -53,6 +53,8 @@ pub enum PeerHostCapability { ToolCatalog, /// Mode/workspace-scoped `get_chat_mcp_catalog` is implemented. ChatMcpCatalogV1, + /// Workspace references can carry the owning host's persisted ID. + WorkspaceIdReferencesV1, /// `submit_user_answers` is implemented for Runtime-owned questions. UserQuestionResponse, /// First human interaction disables the unattended question timeout. @@ -77,6 +79,7 @@ impl PeerHostCapability { Self::CancelTool, Self::ToolCatalog, Self::ChatMcpCatalogV1, + Self::WorkspaceIdReferencesV1, Self::UserQuestionResponse, Self::UserQuestionInteractionV1, ]; @@ -99,6 +102,7 @@ impl PeerHostCapability { Self::CancelTool => "cancel_tool", Self::ToolCatalog => "tool_catalog", Self::ChatMcpCatalogV1 => "chat_mcp_catalog_v1", + Self::WorkspaceIdReferencesV1 => "workspace_id_references_v1", Self::UserQuestionResponse => "user_question_response", Self::UserQuestionInteractionV1 => "user_question_interaction_v1", } @@ -125,6 +129,7 @@ const CLI_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::CancelTool, PeerHostCapability::ToolCatalog, PeerHostCapability::ChatMcpCatalogV1, + PeerHostCapability::WorkspaceIdReferencesV1, PeerHostCapability::UserQuestionResponse, PeerHostCapability::UserQuestionInteractionV1, ]; diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index aa1a9b52c6..45b50bc523 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -73,6 +73,10 @@ impl AgentSessionCreateResult { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID wire input. New producers send workspace_id. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -105,6 +109,10 @@ pub struct AgentSessionSummary { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionDeleteRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only protocol field. Current callers provide workspace_id. + #[serde(default)] pub workspace_path: String, pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -117,6 +125,10 @@ pub struct AgentSessionDeleteRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionRenameRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only protocol field. Current callers provide workspace_id. + #[serde(default)] pub workspace_path: String, pub session_id: String, pub session_name: String, @@ -129,6 +141,10 @@ pub struct AgentSessionRenameRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionArchiveRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only protocol field. Current callers provide workspace_id. + #[serde(default)] pub workspace_path: String, pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -145,6 +161,10 @@ pub struct AgentSessionArchiveRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionArchiveStateRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only protocol field. Current callers provide workspace_id. + #[serde(default)] pub workspace_path: String, pub session_id: String, pub archived: bool, @@ -242,6 +262,10 @@ pub struct AgentSessionModeUpdateRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentModeCatalogQuery { + /// Authoritative workspace identity. Current callers must supply this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only input from pre-ID clients; removed after the compatibility sunset. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_root: Option, #[serde(default)] @@ -337,6 +361,11 @@ pub trait AgentInteractionResponsePort: Send + Sync { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkRequest { + /// Owning-host workspace ID. Normal callers must supply this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Temporary pre-ID wire input; interpreted only by the upgrade adapter. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub source_session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -353,6 +382,11 @@ pub struct AgentSessionForkRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkAtTurnRequest { + /// Owning-host workspace ID. Normal callers must supply this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Temporary pre-ID wire input; interpreted only by the upgrade adapter. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub source_session_id: String, pub source_turn_id: String, @@ -371,6 +405,11 @@ pub struct AgentSessionForkAtTurnRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct AgentSessionForkBeforeTurnRequest { + /// Owning-host workspace ID. Normal callers must supply this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Temporary pre-ID wire input; interpreted only by the upgrade adapter. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub source_session_id: String, pub source_turn_id: String, @@ -393,6 +432,10 @@ pub struct AgentSessionForkResult { #[serde(rename_all = "camelCase")] pub struct AgentSessionUsageRequest { pub session_id: String, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. New producers send workspace_id. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -438,6 +481,10 @@ pub struct AgentSessionWorkspaceRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionWorkspaceBinding { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, pub workspace_path: String, @@ -622,8 +669,13 @@ pub struct AgentDialogTurnRequest { #[serde(default, skip_serializing_if = "AgentDialogTurnExecution::is_standard")] pub execution: AgentDialogTurnExecution, pub agent_type: String, + /// Execution root projection retained for pre-ID clients; never a workspace key. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Owning workspace ID used to locate the session when it is not loaded. + /// Paths and SSH fields below are upgrade-only projections. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -650,6 +702,9 @@ pub struct AgentDialogTurnRecoveryRequest { pub execution_generation: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Owning workspace ID; when present it is the only identity compared. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1364,6 +1419,7 @@ pub trait AgentSessionManagementPort: Send + Sync { if request.archived { return self .archive_session(AgentSessionArchiveRequest { + workspace_id: request.workspace_id, workspace_path: request.workspace_path, session_id: request.session_id, remote_connection_id: request.remote_connection_id, @@ -1416,6 +1472,10 @@ pub struct AgentSessionLineageEntry { /// Parent-scoped semantic handle assigned when this subagent was launched. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_id: Option, + /// Owning workspace ID; authoritative when present. Paths below are + /// location projections only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1439,6 +1499,10 @@ pub struct AgentSessionLineageSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionLineageRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID protocol field. New callers must provide workspace_id. + #[serde(default)] pub workspace_path: String, pub anchor_session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1450,6 +1514,10 @@ pub struct AgentSessionLineageRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionLineageTranscriptRequest { + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. New producers send workspace_id. pub workspace_path: String, pub root_session_id: String, pub session_id: String, @@ -1475,6 +1543,10 @@ pub struct AgentSessionLineageInspection { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionLineageCancellationRequest { + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. New producers send workspace_id. pub workspace_path: String, pub root_session_id: String, pub session_id: String, @@ -1538,6 +1610,9 @@ pub trait AgentWorkspaceReferencePort: Send + Sync { /// The lifecycle method decides whether persisted storage is preserved. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentSessionReleaseRequest { + /// Owning workspace ID; authoritative when present. + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. New producers send workspace_id. pub workspace_path: String, pub session_id: String, pub remote_connection_id: Option, @@ -1639,6 +1714,10 @@ pub trait AgentSessionCompactionPort: Send + Sync { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionRevertRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only path projection for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1650,13 +1729,15 @@ pub struct AgentSessionRevertRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionRollbackToTurnRequest { + /// Upgrade-only path projection for pre-ID clients. + #[serde(default)] pub workspace_path: String, /// Stable workspace identity supplied by newer product surfaces. This is /// optional so older clients keep using the path-based compatibility path. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, - /// Persisted workspace host identity. `localhost` is the local sentinel; - /// any other non-empty value identifies a remote workspace. + /// Legacy host projection. Never infer workspace kind from this or an ID prefix; + /// the host must resolve workspace_id to its authoritative workspace object. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_hostname: Option, pub session_id: String, @@ -1677,46 +1758,6 @@ pub enum AgentSessionWorkspaceLocation { Remote, } -impl AgentSessionRollbackToTurnRequest { - /// Returns the structured workspace location when the request declares one. - /// Explicit remote facts win over conflicting local facts so malformed - /// requests cannot bypass remote snapshot restrictions. - pub fn explicit_workspace_location(&self) -> Option { - let workspace_id = self - .workspace_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let workspace_hostname = self - .workspace_hostname - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - if self - .remote_connection_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .remote_ssh_host - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || workspace_id.is_some_and(|value| value.starts_with("remote_")) - || workspace_hostname.is_some_and(|value| !value.eq_ignore_ascii_case("localhost")) - { - return Some(AgentSessionWorkspaceLocation::Remote); - } - - if workspace_id.is_some_and(|value| value.starts_with("local_")) - || workspace_hostname.is_some_and(|value| value.eq_ignore_ascii_case("localhost")) - { - return Some(AgentSessionWorkspaceLocation::Local); - } - - None - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AgentSessionComposerUpdate { @@ -2089,6 +2130,25 @@ mod tests { use super::*; + #[test] + fn workspace_binding_reads_legacy_shape_without_guessing_kind() { + let legacy = + serde_json::json!({"workspacePath":"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/same/path", "remoteConnectionId":"ssh-1"}); + let binding: AgentSessionWorkspaceBinding = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(binding.workspace_kind, None); + assert_eq!(binding.workspace_id, None); + assert_eq!(binding.project_workspace_id, None); + assert_eq!(serde_json::to_value(binding).unwrap(), legacy); + let current = serde_json::json!({"workspacePath":"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/same/path", "workspaceId":"Workspace-A", "projectWorkspaceId":"Project-A", "workspaceKind":"normal", "remoteConnectionId":"stale-ssh"}); + let binding: AgentSessionWorkspaceBinding = + serde_json::from_value(current.clone()).unwrap(); + assert_eq!( + binding.workspace_kind, + Some(openbitfun_core_types::WorkspaceKind::Normal) + ); + assert_eq!(serde_json::to_value(binding).unwrap(), current); + } + #[test] fn turn_settlement_result_serializes_authoritative_terminal_facts() { let result = AgentTurnSettlementResult { @@ -2188,25 +2248,18 @@ mod tests { .expect("deserialize pre-workspace-identity rollback request"); assert_eq!(legacy_request.workspace_id, None); assert_eq!(legacy_request.workspace_hostname, None); - assert_eq!(legacy_request.explicit_workspace_location(), None); - - let local_request = AgentSessionRollbackToTurnRequest { - workspace_id: Some("local_workspace-1".to_string()), - workspace_hostname: None, - ..legacy_request.clone() - }; - assert_eq!( - local_request.explicit_workspace_location(), - Some(AgentSessionWorkspaceLocation::Local) - ); - let conflicting_request = AgentSessionRollbackToTurnRequest { - remote_connection_id: Some("connection-1".to_string()), - ..local_request - }; + // IDs are opaque. SSH metadata is an old projection, not type authority. + let request_with_id: AgentSessionRollbackToTurnRequest = + serde_json::from_value(serde_json::json!({ + "workspaceId": "opaque-workspace-id", "remoteConnectionId": "stale-projection", + "sessionId": "session-1", "targetTurnId": "turn-7" + })) + .unwrap(); assert_eq!( - conflicting_request.explicit_workspace_location(), - Some(AgentSessionWorkspaceLocation::Remote) + request_with_id.workspace_id.as_deref(), + Some("opaque-workspace-id") ); + assert_eq!(request_with_id.workspace_path, ""); let completed = AgentSessionRollbackToTurnOutcome::Completed { result: AgentSessionRevertResult { @@ -2414,6 +2467,7 @@ mod tests { fn archive_state_request(archived: bool) -> AgentSessionArchiveStateRequest { AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), archived, @@ -2449,12 +2503,39 @@ mod tests { assert_eq!(provider.archived_requests.lock().unwrap().len(), 1); } + #[test] + fn fork_workspace_id_wire_contract_preserves_100_payloads() { + let id_only = serde_json::json!({"workspaceId":"workspace-1","sourceSessionId":"session-1","sourceTurnId":"turn-1"}); + let latest: AgentSessionForkRequest = serde_json::from_value(id_only.clone()).unwrap(); + let at: AgentSessionForkAtTurnRequest = serde_json::from_value(id_only.clone()).unwrap(); + let before: AgentSessionForkBeforeTurnRequest = serde_json::from_value(id_only).unwrap(); + assert_eq!(latest.workspace_id.as_deref(), Some("workspace-1")); + assert!(latest.workspace_path.is_empty()); + assert!(at.workspace_path.is_empty()); + assert!(before.workspace_path.is_empty()); + let legacy = serde_json::json!({"workspacePath":"/srv/project","sourceSessionId":"session-1","sourceTurnId":"turn-1","remoteConnectionId":"ssh-1"}); + let latest: AgentSessionForkRequest = serde_json::from_value(legacy.clone()).unwrap(); + let at: AgentSessionForkAtTurnRequest = serde_json::from_value(legacy.clone()).unwrap(); + let before: AgentSessionForkBeforeTurnRequest = serde_json::from_value(legacy).unwrap(); + assert!(latest.workspace_id.is_none()); + for value in [ + serde_json::to_value(latest).unwrap(), + serde_json::to_value(at).unwrap(), + serde_json::to_value(before).unwrap(), + ] { + assert_eq!(value["workspacePath"], "/srv/project"); + assert_eq!(value["remoteConnectionId"], "ssh-1"); + assert!(value.get("workspaceId").is_none()); + } + } + #[tokio::test] async fn exact_turn_fork_default_preserves_latest_turn_only_provider_compatibility() { let provider = LatestTurnForkOnlyProvider; let error = AgentSessionForkPort::fork_session_at_turn( &provider, AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session_1".to_string(), source_turn_id: "turn_1".to_string(), @@ -2474,6 +2555,7 @@ mod tests { let error = AgentSessionForkPort::fork_session_before_turn( &provider, AgentSessionForkBeforeTurnRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session_1".to_string(), source_turn_id: "turn_1".to_string(), @@ -2487,9 +2569,30 @@ mod tests { assert_eq!(error.kind, PortErrorKind::NotAvailable); } + #[test] + fn session_mutations_accept_ids_without_paths_and_preserve_old_payloads() { + let id_only = serde_json::json!({"workspaceId":"workspace-1","sessionId":"session-1"}); + let delete: AgentSessionDeleteRequest = serde_json::from_value(id_only.clone()).unwrap(); + assert_eq!(delete.workspace_id.as_deref(), Some("workspace-1")); + assert!(delete.workspace_path.is_empty()); + let archive: AgentSessionArchiveRequest = serde_json::from_value(id_only).unwrap(); + assert_eq!(archive.workspace_id.as_deref(), Some("workspace-1")); + let old = serde_json::json!({"workspacePath":"/project","sessionId":"session-1"}); + let legacy: AgentSessionDeleteRequest = serde_json::from_value(old.clone()).unwrap(); + assert!(legacy.workspace_id.is_none()); + assert_eq!(serde_json::to_value(legacy).unwrap(), old); + let lineage: AgentSessionLineageRequest = serde_json::from_value(serde_json::json!({ + "workspaceId":"workspace-1", "anchorSessionId":"session-1" + })) + .unwrap(); + assert_eq!(lineage.workspace_id.as_deref(), Some("workspace-1")); + assert!(lineage.workspace_path.is_empty()); + } + #[test] fn agent_session_create_request_keeps_rust_literal_compatible() { let request = AgentSessionCreateRequest { + agent_route_key: None, session_name: "Generated session".to_string(), agent_type: "Standard".to_string(), workspace_path: Some("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/workspace/project".to_string()), @@ -3027,6 +3130,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), policy: DialogSubmissionPolicy::new( @@ -3268,6 +3372,7 @@ mod tests { turn_id: "turn_1".to_string(), execution_generation: 1, workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, }; @@ -3284,6 +3389,7 @@ mod tests { #[test] fn session_lineage_contracts_serialize_provider_neutral_facts() { let request = AgentSessionLineageRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), anchor_session_id: "child_1".to_string(), remote_connection_id: Some("conn-1".to_string()), @@ -3310,6 +3416,7 @@ mod tests { }], }; let transcript_request = AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), root_session_id: "root_1".to_string(), session_id: "child_1".to_string(), @@ -3318,6 +3425,7 @@ mod tests { remote_ssh_host: None, }; let cancellation_request = AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), root_session_id: "root_1".to_string(), session_id: "child_1".to_string(), @@ -3394,9 +3502,36 @@ mod tests { assert_eq!(inspection_json["activeTurnId"], "turn-live"); } + #[test] + fn session_list_accepts_legacy_payload_and_emits_id_only_requests() { + let legacy: AgentSessionListRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "/project", "remoteConnectionId": "connection-1" + })) + .unwrap(); + assert!(legacy.workspace_id.is_none()); + assert_eq!(legacy.workspace_path, "/project"); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&legacy).unwrap() + ) + .unwrap(), + legacy + ); + let current: AgentSessionListRequest = serde_json::from_value(serde_json::json!({ + "workspaceId": "workspace-1" + })) + .unwrap(); + assert!(current.workspace_path.is_empty()); + assert_eq!( + serde_json::to_value(current).unwrap(), + serde_json::json!({"workspaceId": "workspace-1"}) + ); + } + #[test] fn agent_session_management_contracts_serialize_stable_shape() { let list_request = AgentSessionListRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), @@ -3414,12 +3549,14 @@ mod tests { last_active_at_ms: 2000, }; let delete_request = AgentSessionDeleteRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), }; let rename_request = AgentSessionRenameRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), session_name: "Renamed".to_string(), @@ -3427,12 +3564,14 @@ mod tests { remote_ssh_host: Some("host-1".to_string()), }; let archive_request = AgentSessionArchiveRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), }; let archive_state_request = AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), archived: false, @@ -3440,12 +3579,14 @@ mod tests { remote_ssh_host: Some("host-1".to_string()), }; let fork_request = AgentSessionForkRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session_1".to_string(), remote_connection_id: None, remote_ssh_host: None, }; let fork_at_turn_request = AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session_1".to_string(), source_turn_id: "turn_2".to_string(), @@ -3453,6 +3594,7 @@ mod tests { remote_ssh_host: Some("host-1".to_string()), }; let fork_before_turn_request = AgentSessionForkBeforeTurnRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session_1".to_string(), source_turn_id: "turn_2".to_string(), @@ -3464,6 +3606,7 @@ mod tests { model_id: "provider/model".to_string(), }; let mode_request = AgentSessionModeUpdateRequest { + agent_route_key: None, session_id: "session_1".to_string(), mode_id: "Standard".to_string(), }; @@ -3471,6 +3614,8 @@ mod tests { session_id: "session_1".to_string(), }; let workspace_binding = AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace_1".to_string()), workspace_path: "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/workspace/project".to_string(), project_workspace_path: None, diff --git a/src/crates/contracts/runtime-ports/src/local_workspace_snapshot.rs b/src/crates/contracts/runtime-ports/src/local_workspace_snapshot.rs index a014941f0d..9106775349 100644 --- a/src/crates/contracts/runtime-ports/src/local_workspace_snapshot.rs +++ b/src/crates/contracts/runtime-ports/src/local_workspace_snapshot.rs @@ -10,7 +10,7 @@ use crate::PortResult; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalWorkspaceSnapshotSessionRequest { - pub workspace_path: PathBuf, + pub workspace_id: String, pub session_id: String, /// Exclusive visible turn end supplied by the session lifecycle owner. /// `None` exposes the complete snapshot history. @@ -19,7 +19,7 @@ pub struct LocalWorkspaceSnapshotSessionRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalWorkspaceSnapshotTurnRequest { - pub workspace_path: PathBuf, + pub workspace_id: String, pub session_id: String, pub turn_index: usize, } @@ -35,7 +35,7 @@ pub struct LocalWorkspaceSnapshotStats { #[async_trait::async_trait] pub trait LocalWorkspaceSnapshotPort: Send + Sync { /// Prepares the existing local snapshot owner for a workspace. - async fn prepare_local_workspace(&self, workspace_path: PathBuf) -> PortResult<()>; + async fn prepare_local_workspace(&self, workspace_id: String) -> PortResult<()>; async fn get_session_files( &self, @@ -61,17 +61,16 @@ mod tests { LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, }; - use std::path::PathBuf; #[test] fn local_snapshot_contracts_keep_workspace_and_session_identity_explicit() { let session = LocalWorkspaceSnapshotSessionRequest { - workspace_path: PathBuf::from("workspace"), + workspace_id: String::from("workspace"), session_id: "session-1".to_string(), max_turn_exclusive: Some(5), }; let turn = LocalWorkspaceSnapshotTurnRequest { - workspace_path: session.workspace_path.clone(), + workspace_id: session.workspace_id.clone(), session_id: session.session_id.clone(), turn_index: 4, }; @@ -82,7 +81,7 @@ mod tests { total_changes: 7, }; - assert_eq!(turn.workspace_path, session.workspace_path); + assert_eq!(turn.workspace_id, session.workspace_id); assert_eq!(turn.session_id, session.session_id); assert_eq!(turn.turn_index, 4); assert_eq!(stats.total_changes, 7); diff --git a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs index 0c02c883e7..67674bb7a4 100644 --- a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs @@ -22,6 +22,8 @@ impl RemoteWorkspaceKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteWorkspaceFacts { + /// Owning-host identity. Paths are presentation and IO data only. + pub workspace_id: String, pub path: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -37,6 +39,7 @@ pub struct RemoteWorkspaceFacts { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RemoteSessionWorkspaceIdentity { + pub workspace_id: Option, pub remote_connection_id: Option, pub remote_ssh_host: Option, } @@ -44,6 +47,7 @@ pub struct RemoteSessionWorkspaceIdentity { impl RemoteSessionWorkspaceIdentity { pub fn new(remote_connection_id: Option, remote_ssh_host: Option) -> Self { Self { + workspace_id: None, remote_connection_id, remote_ssh_host, } @@ -54,16 +58,26 @@ impl RemoteSessionWorkspaceIdentity { workspace.remote_connection_id.clone(), workspace.remote_ssh_host.clone(), ) + .with_workspace_id(Some(workspace.workspace_id.clone())) + } + + pub fn with_workspace_id(mut self, workspace_id: Option) -> Self { + self.workspace_id = workspace_id; + self } pub fn is_empty(&self) -> bool { - self.remote_connection_id.is_none() && self.remote_ssh_host.is_none() + self.workspace_id.is_none() + && self.remote_connection_id.is_none() + && self.remote_ssh_host.is_none() } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteRecentWorkspaceFacts { + /// Owning-host identity. Paths are presentation and IO data only. + pub workspace_id: String, pub path: String, pub name: String, pub last_opened: String, @@ -77,6 +91,8 @@ pub struct RemoteRecentWorkspaceFacts { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteAssistantWorkspaceFacts { + /// Owning-host identity. Paths are presentation and IO data only. + pub workspace_id: String, pub path: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -86,6 +102,8 @@ pub struct RemoteAssistantWorkspaceFacts { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteWorkspaceUpdate { + /// Owning-host identity. Paths are presentation and IO data only. + pub workspace_id: String, pub path: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -97,6 +115,8 @@ pub struct RemoteWorkspaceUpdate { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteSessionMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub session_id: String, pub name: String, pub agent_type: String, @@ -163,12 +183,23 @@ pub trait RemoteWorkspaceRuntimeHost: Send + Sync { async fn opened_workspaces(&self) -> Result>, String> { Ok(None) } + /// ID-based selection; legacy providers must advertise unsupported. + async fn select_workspace(&self, _workspace_id: &str) -> Result { + Err("Host does not support workspace ID selection".to_string()) + } + /// Upgrade-only path ingress. New clients must use `select_workspace`. async fn open_workspace( &self, path: &str, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> Result; + async fn select_assistant_workspace( + &self, + _workspace_id: &str, + ) -> Result { + Err("Host does not support assistant workspace ID selection".to_string()) + } async fn assistant_workspaces(&self) -> Vec; async fn open_assistant_workspace(&self, path: &str) -> Result; } @@ -206,10 +237,14 @@ pub trait RemoteWorkspaceFileRuntimeHost: Send + Sync { Ok(None) } + /// Explicit file workspace identity is the workspace ID when the + /// controller supplies one; `workspace_path` + `remote_connection_id` is + /// the legacy projection for pre-ID controllers. async fn read_remote_file_chunk( &self, _path: &str, _session_id: Option<&str>, + _workspace_id: Option<&str>, _workspace_path: Option<&str>, _remote_connection_id: Option<&str>, _offset: u64, @@ -222,6 +257,7 @@ pub trait RemoteWorkspaceFileRuntimeHost: Send + Sync { &self, _path: &str, _session_id: Option<&str>, + _workspace_id: Option<&str>, _workspace_path: Option<&str>, _remote_connection_id: Option<&str>, ) -> Result, String> { @@ -244,6 +280,7 @@ mod tests { #[test] fn remote_workspace_contracts_preserve_workspace_and_session_facts() { let workspace = RemoteWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/workspace/project".to_string(), name: "project".to_string(), git_branch: Some("main".to_string()), @@ -253,6 +290,7 @@ mod tests { remote_ssh_host: Some("host-1".to_string()), }; let session = RemoteSessionMetadata { + workspace_id: None, session_id: "session_1".to_string(), name: "Research".to_string(), agent_type: "CodeAgent".to_string(), diff --git a/src/crates/contracts/runtime-ports/src/workspace_ports.rs b/src/crates/contracts/runtime-ports/src/workspace_ports.rs index 7e5a183d72..b4452c55a5 100644 --- a/src/crates/contracts/runtime-ports/src/workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/workspace_ports.rs @@ -120,6 +120,19 @@ pub struct SessionViewRestoreTiming { #[async_trait::async_trait] pub trait SessionStorePort: RuntimeServicePort { + /// Resolve storage from an owning-host workspace ID. An execution path is + /// never accepted as an identity or as an already-resolved storage directory. + async fn resolve_workspace_storage( + &self, + _workspace_id: &str, + ) -> PortResult { + Err(PortError::new( + PortErrorKind::InvalidRequest, + "Workspace ID storage lookup is not supported", + )) + } + + /// Legacy upgrade ingress. New product callers must use workspace IDs. async fn resolve_session_storage_path( &self, request: SessionStoragePathRequest, diff --git a/src/crates/execution/agent-runtime/src/agents.rs b/src/crates/execution/agent-runtime/src/agents.rs index 27689547b1..62bf17a0de 100644 --- a/src/crates/execution/agent-runtime/src/agents.rs +++ b/src/crates/execution/agent-runtime/src/agents.rs @@ -4,7 +4,6 @@ use crate::prompt::UserContextPolicy; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashSet; -use std::path::Path; pub const STANDARD_HARNESS_PROMPT_TEMPLATE: &str = "agentic_mode"; pub const STANDARD_HARNESS_CONFIG_ID: &str = "Standard"; @@ -253,7 +252,7 @@ pub enum SubagentListScope { #[derive(Debug, Clone)] pub struct SubagentQueryContext<'a> { pub parent_agent_type: Option<&'a str>, - pub workspace_root: Option<&'a Path>, + pub workspace_id: Option<&'a str>, pub list_scope: SubagentListScope, pub include_disabled: bool, /// False for remote workspaces until an explicit remote source provider is diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index bbde0e63bb..08d825de72 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -4,7 +4,6 @@ //! runtime owners. Client-facing SDK consumers should use `crate::sdk`, which //! does not expose raw PluginRuntimeClient contracts. -use std::path::Path; use std::sync::{Arc, Mutex}; use openbitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; @@ -119,6 +118,10 @@ impl RuntimeError { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionRestoreRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID wire field. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub session_id: String, #[serde(default)] @@ -197,7 +200,7 @@ impl AgentEventStream { #[derive(Debug, Clone, Copy, Default)] pub struct RuntimeAgentRegistryQuery<'a> { - pub workspace_root: Option<&'a Path>, + pub workspace_id: Option<&'a str>, } pub trait RuntimeAgentRegistry: Send + Sync { @@ -740,6 +743,9 @@ pub enum SessionSelector { Create { session_name: String, agent_type: String, + /// Owning workspace ID; authoritative when present. + workspace_id: Option, + /// Legacy execution root for pre-ID callers. workspace_path: Option, metadata: serde_json::Map, }, @@ -760,11 +766,26 @@ impl SessionSelector { Self::Create { session_name: session_name.into(), agent_type: agent_type.into(), + workspace_id: None, workspace_path, metadata: serde_json::Map::new(), } } + /// Select the owning workspace by ID. The path, when also present, is + /// only an IO projection for hosts that still record it. + pub fn with_workspace_id(mut self, workspace_id: Option) -> Self { + if let Self::Create { + workspace_id: slot, .. + } = &mut self + { + *slot = workspace_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + } + self + } + pub fn with_metadata(mut self, metadata: serde_json::Map) -> Self { if let Self::Create { metadata: existing, .. @@ -1825,6 +1846,7 @@ impl AgentRuntime { SessionSelector::Create { session_name, agent_type, + workspace_id, workspace_path, metadata, } => { @@ -1836,7 +1858,7 @@ impl AgentRuntime { workspace_path, project_workspace_path: None, execution_target: None, - workspace_id: None, + workspace_id, remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -2121,6 +2143,8 @@ mod tests { .unwrap() .push(request); Ok(Some(AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace_1".to_string()), workspace_path: "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/workspace/project".to_string(), project_workspace_path: None, @@ -2324,6 +2348,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, agent_id: None, + workspace_id: Some("workspace-project".to_string()), workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -2341,6 +2366,7 @@ mod tests { parent_tool_call_id: Some("tool_1".to_string()), subagent_type: Some("explore".to_string()), agent_id: Some("child-agent".to_string()), + workspace_id: Some("workspace-project".to_string()), workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -2623,6 +2649,7 @@ mod tests { .build() .expect("runtime"); let request = AgentSessionForkBeforeTurnRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), source_session_id: "session-1".to_string(), source_turn_id: "turn-2".to_string(), @@ -2651,6 +2678,7 @@ mod tests { .build() .expect("runtime"); let request = AgentSessionRevertRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session-1".to_string(), remote_connection_id: None, @@ -2952,6 +2980,34 @@ mod tests { assert_eq!(ports.interrupted_turns.lock().unwrap().len(), 1); } + #[test] + fn restore_request_reads_legacy_payload_and_round_trips_id_only_scope() { + let legacy: AgentSessionRestoreRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "/project", "sessionId": "session-1" + })) + .unwrap(); + assert!(legacy.workspace_id.is_none()); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&legacy).unwrap() + ) + .unwrap(), + legacy + ); + let current: AgentSessionRestoreRequest = serde_json::from_value(serde_json::json!({ + "workspaceId": "workspace-1", "sessionId": "session-1" + })) + .unwrap(); + assert!(current.workspace_path.is_empty()); + let json = serde_json::to_value(¤t).unwrap(); + assert!(json.get("workspacePath").is_none()); + assert_eq!(json["workspaceId"], "workspace-1"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + current + ); + } + #[tokio::test] async fn session_management_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); @@ -2962,6 +3018,7 @@ mod tests { let err = runtime .list_sessions(AgentSessionListRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -2983,6 +3040,7 @@ mod tests { let sessions = runtime .list_sessions(AgentSessionListRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -2991,6 +3049,7 @@ mod tests { .expect("list sessions"); runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), remote_connection_id: None, @@ -3000,6 +3059,7 @@ mod tests { .expect("delete session"); runtime .rename_session(AgentSessionRenameRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), session_name: "Renamed".to_string(), @@ -3010,6 +3070,7 @@ mod tests { .expect("rename session"); runtime .archive_session(AgentSessionArchiveRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), remote_connection_id: None, @@ -3019,6 +3080,7 @@ mod tests { .expect("archive session"); runtime .set_session_archived(AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), archived: false, @@ -3255,6 +3317,7 @@ mod tests { let restored = runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), include_internal: false, @@ -3288,6 +3351,7 @@ mod tests { let snapshot = runtime .get_session_lineage(AgentSessionLineageRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), anchor_session_id: "child_1".to_string(), remote_connection_id: None, @@ -3298,6 +3362,7 @@ mod tests { .expect("lineage"); let inspection = runtime .read_lineage_session_transcript(AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), root_session_id: "root_1".to_string(), session_id: "child_1".to_string(), @@ -3326,6 +3391,7 @@ mod tests { let result = runtime .cancel_lineage_session(AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), root_session_id: "root_1".to_string(), session_id: "child_1".to_string(), @@ -3348,6 +3414,7 @@ mod tests { let error = runtime .cancel_lineage_session(AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), root_session_id: "root_1".to_string(), session_id: "outside".to_string(), @@ -3428,6 +3495,7 @@ mod tests { #[test] fn session_restore_contract_serializes_runtime_owned_state() { let request = AgentSessionRestoreRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), include_internal: true, @@ -3472,6 +3540,7 @@ mod tests { let error = runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "session_1".to_string(), include_internal: false, @@ -3545,6 +3614,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::new( @@ -3601,6 +3671,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::new( @@ -3676,6 +3747,7 @@ mod tests { turn_id: "turn_1".to_string(), execution_generation: 0, workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, }) @@ -3721,6 +3793,7 @@ mod tests { execution: Default::default(), agent_type: "Standard".to_string(), workspace_path: Some("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/workspace/project".to_string()), + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::SdkHost), diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index 77d29ca00b..e30a051fca 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -207,10 +207,13 @@ pub struct SessionConfig { /// Stable workspace id for resolving workspace-scoped metadata such as related directories. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, - /// SSH workspace: required for remote tool I/O (file/shell). When set, `workspace_path` is - /// interpreted as the path on that host; when unset, the workspace is always local regardless - /// of string shape (avoids inferring remote from path alone). Also disambiguates the same - /// `workspace_path` on different hosts (e.g. two `/` roots). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, + /// Projection of the owning workspace record; legacy sessions resolve this on load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_kind: Option, + /// Saved connection projected from the workspace record. `workspace_kind` + /// determines locality; absence of credentials must never select local I/O. #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, /// SSH config `host` for locating `~/.openbitfun/remote_ssh/{host}/.../sessions` when disconnected. @@ -298,6 +301,8 @@ impl Default for SessionConfig { project_workspace_path: None, execution_target: None, workspace_id: None, + project_workspace_id: None, + workspace_kind: None, remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -708,3 +713,9 @@ mod tests { assert!(rewritten.get("execution_profile").is_none()); } } + +impl SessionConfig { + pub fn is_remote_workspace(&self) -> bool { + self.workspace_kind == Some(openbitfun_core_types::WorkspaceKind::Remote) + } +} diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index 23038cdf49..6fc5fc1d7f 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -95,7 +94,7 @@ fn sdk_facade_exposes_versioned_preview_compatibility_contract() { impl RuntimeAgentRegistry for FakeSdkAgentRegistry { fn agent_ids(&self, query: RuntimeAgentRegistryQuery<'_>) -> Vec { - if query.workspace_root.is_some() { + if query.workspace_id.is_some() { self.workspace_agent_ids.clone() } else { self.agent_ids.clone() @@ -347,6 +346,7 @@ async fn sdk_facade_delegates_mode_catalog_queries_to_the_injected_owner() { .expect("sdk runtime"); let query = AgentModeCatalogQuery { + workspace_id: None, workspace_root: Some("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/workspace/project".to_string()), include_external: true, }; @@ -369,6 +369,7 @@ async fn sdk_facade_fails_closed_without_a_mode_catalog_owner() { let error = runtime .list_agent_modes(AgentModeCatalogQuery { + workspace_id: None, workspace_root: None, include_external: false, }) @@ -428,7 +429,7 @@ async fn sdk_facade_accepts_fake_services_tools_and_hooks_without_core() { ); assert_eq!( runtime.registered_agent_ids(RuntimeAgentRegistryQuery { - workspace_root: Some(Path::new("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/workspace/project")), + workspace_id: Some("workspace-project"), }), vec![ "Standard".to_string(), @@ -466,6 +467,7 @@ async fn sdk_facade_delegates_connection_scoped_session_discard() { .build() .expect("sdk runtime"); let request = AgentTransientSessionDiscardRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "sdk-session-1".to_string(), remote_connection_id: None, @@ -492,6 +494,7 @@ async fn sdk_facade_delegates_persisted_session_unload() { .build() .expect("sdk runtime"); let request = AgentTransientSessionDiscardRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "sdk-session-1".to_string(), remote_connection_id: None, @@ -520,6 +523,7 @@ async fn sdk_facade_reports_missing_session_close_capability() { let error = runtime .discard_transient_session(AgentTransientSessionDiscardRequest { + workspace_id: None, workspace_path: "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/workspace/project".to_string(), session_id: "sdk-session-1".to_string(), remote_connection_id: None, diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_operation_ports.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_operation_ports.rs index 1abd59927b..5ac1965b19 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_operation_ports.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_operation_ports.rs @@ -120,6 +120,7 @@ async fn runtime_delegates_narrow_session_operations_to_registered_ports() { let fork = runtime .fork_session_at_turn(AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), source_session_id: "session-1".to_string(), source_turn_id: "turn-1".to_string(), @@ -132,6 +133,7 @@ async fn runtime_delegates_narrow_session_operations_to_registered_ports() { let latest_turn_fork = runtime .fork_session(AgentSessionForkRequest { + workspace_id: None, workspace_path: "D:/workspace/project".to_string(), source_session_id: "session-1".to_string(), remote_connection_id: None, @@ -143,6 +145,7 @@ async fn runtime_delegates_narrow_session_operations_to_registered_ports() { let report = runtime .generate_session_usage(AgentSessionUsageRequest { + workspace_id: None, session_id: "session-1".to_string(), workspace_path: Some("D:/workspace/project".to_string()), remote_connection_id: None, diff --git a/src/crates/execution/runtime-services/src/test_support.rs b/src/crates/execution/runtime-services/src/test_support.rs index 022f083c36..2caeb57eaf 100644 --- a/src/crates/execution/runtime-services/src/test_support.rs +++ b/src/crates/execution/runtime-services/src/test_support.rs @@ -179,6 +179,7 @@ fn fake_remote_exec_error() -> PortError { impl RemoteWorkspaceRuntimeHost for FakeRuntimePort { async fn current_workspace(&self) -> Option { Some(RemoteWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/remote/project".to_string(), name: "project".to_string(), git_branch: Some("main".to_string()), @@ -200,6 +201,7 @@ impl RemoteWorkspaceRuntimeHost for FakeRuntimePort { _remote_ssh_host: Option<&str>, ) -> Result { Ok(RemoteWorkspaceUpdate { + workspace_id: "test-workspace".to_string(), path: path.to_string(), name: "project".to_string(), remote_connection_id: None, @@ -213,6 +215,7 @@ impl RemoteWorkspaceRuntimeHost for FakeRuntimePort { async fn open_assistant_workspace(&self, path: &str) -> Result { Ok(RemoteWorkspaceUpdate { + workspace_id: "test-workspace".to_string(), path: path.to_string(), name: "assistant".to_string(), remote_connection_id: None, diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 92cea0edd4..b140d7aa14 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -102,6 +102,9 @@ pub struct AcpClientPermissionResponse { pub struct SetAcpSessionModelRequest { pub client_id: String, pub session_id: String, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -116,6 +119,9 @@ pub struct SetAcpSessionModelRequest { pub struct SetAcpSessionConfigOptionRequest { pub client_id: String, pub session_id: String, + /// Workspace identity; authoritative when present. + #[serde(default)] + pub workspace_id: Option, #[serde(default)] pub workspace_path: Option, #[serde(default)] @@ -232,9 +238,12 @@ impl AcpClientService { })) } + /// `workspace_id` is the persisted identity of the owning workspace; + /// `workspace_path` is its execution root recorded as an IO projection. pub async fn create_flow_session_record( &self, session_storage_path: &Path, + workspace_id: &str, workspace_path: &str, client_id: &str, session_name: Option, @@ -242,6 +251,7 @@ impl AcpClientService { self.session_persistence .create_flow_session_record( session_storage_path, + workspace_id, workspace_path, client_id, session_name, diff --git a/src/crates/interfaces/acp/src/client/session_persistence.rs b/src/crates/interfaces/acp/src/client/session_persistence.rs index 11aba85472..14380d8191 100644 --- a/src/crates/interfaces/acp/src/client/session_persistence.rs +++ b/src/crates/interfaces/acp/src/client/session_persistence.rs @@ -43,6 +43,7 @@ impl AcpSessionPersistence { pub(super) async fn create_flow_session_record( &self, session_storage_path: &Path, + workspace_id: &str, workspace_path: &str, client_id: &str, session_name: Option, @@ -59,6 +60,7 @@ impl AcpSessionPersistence { agent_type.clone(), "primary".to_string(), ); + metadata.workspace_id = Some(workspace_id.to_string()); metadata.workspace_path = Some(workspace_path.to_string()); metadata.custom_metadata = Some(json!({ "kind": "normal", diff --git a/src/crates/interfaces/acp/src/runtime.rs b/src/crates/interfaces/acp/src/runtime.rs index 7fcdc35966..a556b94527 100644 --- a/src/crates/interfaces/acp/src/runtime.rs +++ b/src/crates/interfaces/acp/src/runtime.rs @@ -40,6 +40,9 @@ pub struct OpenBitFunAcpRuntime { pub(crate) struct AcpSessionState { pub(crate) acp_session_id: String, pub(crate) openbitfun_session_id: String, + /// Owning workspace record ID; the authoritative session workspace. + pub(crate) workspace_id: String, + /// ACP `cwd` operand, kept as the storage/IO projection of that workspace. pub(crate) cwd: String, pub(crate) mode_id: String, pub(crate) model_id: String, diff --git a/src/crates/interfaces/acp/src/runtime/prompt.rs b/src/crates/interfaces/acp/src/runtime/prompt.rs index ce2d580bbf..c55b399220 100644 --- a/src/crates/interfaces/acp/src/runtime/prompt.rs +++ b/src/crates/interfaces/acp/src/runtime/prompt.rs @@ -110,6 +110,7 @@ fn dialog_turn_request(session: &AcpSessionState, prompt: ParsedPrompt) -> Agent execution: Default::default(), agent_type: session.mode_id.clone(), workspace_path: Some(session.cwd.clone()), + workspace_id: Some(session.workspace_id.clone()), remote_connection_id: None, remote_ssh_host: None, policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), @@ -433,6 +434,7 @@ mod tests { AcpSessionState { acp_session_id: "acp-session".to_string(), openbitfun_session_id: "openbitfun-session".to_string(), + workspace_id: "workspace-1".to_string(), cwd: "/workspace".to_string(), mode_id: "Standard".to_string(), model_id: "primary".to_string(), diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index b289335202..d482ae441b 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -13,6 +13,7 @@ use openbitfun_agent_runtime::sdk::{ AgentSessionModeUpdateRequest, SessionStoragePathRequest, }; use openbitfun_core::agentic::agents::get_agent_registry; +use openbitfun_core::service::workspace::{get_global_workspace_service, WorkspaceInfo}; use super::model::{ build_session_config_options, build_session_model_state, normalize_session_model_id, @@ -30,6 +31,33 @@ impl OpenBitFunAcpRuntime { Ok(()) } + /// ACP names a workspace by its `cwd` directory operand. This is the one + /// place that external reference is translated into the owning workspace + /// record; every later runtime call carries the record ID. + /// + /// The record is registered hidden: an ACP client's `cwd` must not become + /// an opened, recent, or current workspace of the desktop that shares the + /// same catalog. + async fn open_cwd_workspace(cwd: &str) -> Result { + let service = get_global_workspace_service() + .ok_or_else(|| Error::internal_error().data("Workspace service is unavailable"))?; + service + .register_local_workspace_record(Path::new(cwd).to_path_buf()) + .await + .map_err(|error| Error::invalid_params().data(error.to_string())) + } + + /// Read-only translation for listing: a directory no record owns has no + /// sessions, so it answers `None` instead of opening a workspace. + async fn resolve_cwd_workspace(cwd: &str) -> Result> { + let service = get_global_workspace_service() + .ok_or_else(|| Error::internal_error().data("Workspace service is unavailable"))?; + service + .resolve_legacy_workspace_reference(None, cwd, None, None) + .await + .map_err(|error| Error::invalid_params().data(error.to_string())) + } + pub(super) async fn create_session( &self, request: NewSessionRequest, @@ -43,6 +71,7 @@ impl OpenBitFunAcpRuntime { let config_options = build_session_config_options(None, Some("Standard")).await?; let session_id = uuid::Uuid::new_v4().to_string(); Self::validate_session_target(&session_id, Path::new(&cwd))?; + let workspace = Self::open_cwd_workspace(&cwd).await?; let _session_transition = self.claim_session_transition(&session_id)?; let mcp_server_ids = self .provision_mcp_servers( @@ -61,7 +90,7 @@ impl OpenBitFunAcpRuntime { workspace_path: Some(cwd.clone()), project_workspace_path: None, execution_target: None, - workspace_id: None, + workspace_id: Some(workspace.id.clone()), remote_connection_id: None, remote_ssh_host: None, model_id: None, @@ -83,6 +112,7 @@ impl OpenBitFunAcpRuntime { let core_cleaned = if core_cleanup_required { self.delete_failed_new_core_session( &session_id, + &workspace.id, &cwd, "Core session creation rollback", ) @@ -112,6 +142,7 @@ impl OpenBitFunAcpRuntime { let acp_session = AcpSessionState { acp_session_id: session.session_id.clone(), openbitfun_session_id: session.session_id.clone(), + workspace_id: workspace.id, cwd, mode_id: session.agent_type.clone(), model_id: normalize_session_model_id(None), @@ -158,6 +189,7 @@ impl OpenBitFunAcpRuntime { let mcp_servers = request.mcp_servers; self.validate_mcp_servers(&mcp_servers)?; Self::validate_session_target(&session_id, Path::new(&cwd))?; + let workspace = Self::open_cwd_workspace(&cwd).await?; let _session_transition = self.claim_session_transition(&session_id)?; if self.sessions.contains_key(&session_id) { return Err(Error::invalid_params().data("session is already active")); @@ -176,7 +208,7 @@ impl OpenBitFunAcpRuntime { .compatibility .restore_session_with_turns_for_workspace( SessionStoragePathRequest { - workspace_path: Path::new(&cwd).to_path_buf(), + workspace_path: workspace.root_path.clone(), remote_connection_id: None, remote_ssh_host: None, }, @@ -202,6 +234,7 @@ impl OpenBitFunAcpRuntime { let acp_session = AcpSessionState { acp_session_id: session.session_id.clone(), openbitfun_session_id: session.session_id.clone(), + workspace_id: workspace.id, cwd, mode_id: session.agent_type.clone(), model_id: normalize_session_model_id(session.config.model_id.as_deref()), @@ -339,7 +372,12 @@ impl OpenBitFunAcpRuntime { ) -> (bool, bool) { let mcp_cleaned = self.cleanup_failed_session_setup(session, stage).await; let core_cleaned = self - .delete_failed_new_core_session(&session.openbitfun_session_id, &session.cwd, stage) + .delete_failed_new_core_session( + &session.openbitfun_session_id, + &session.workspace_id, + &session.cwd, + stage, + ) .await; (mcp_cleaned, core_cleaned) } @@ -347,12 +385,14 @@ impl OpenBitFunAcpRuntime { async fn delete_failed_new_core_session( &self, session_id: &str, + workspace_id: &str, cwd: &str, stage: &str, ) -> bool { if let Err(error) = self .agent_runtime .delete_session(AgentSessionDeleteRequest { + workspace_id: Some(workspace_id.to_string()), workspace_path: cwd.to_string(), session_id: session_id.to_string(), remote_connection_id: None, @@ -443,10 +483,18 @@ impl OpenBitFunAcpRuntime { .as_deref() .and_then(|value| value.parse::().ok()); + // A directory that no workspace record owns has no sessions yet; + // `session/new` is what opens it. Answer with an empty page instead of + // an error so clients can list before their first session. + let Some(workspace) = Self::resolve_cwd_workspace(&cwd.to_string_lossy()).await? else { + return Ok(ListSessionsResponse::new(Vec::new())); + }; + let mut summaries = self .agent_runtime .list_sessions(AgentSessionListRequest { - workspace_path: cwd.to_string_lossy().to_string(), + workspace_id: Some(workspace.id), + workspace_path: String::new(), remote_connection_id: None, remote_ssh_host: None, }) diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs index 95f8046c93..e983dec5ab 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs @@ -26,6 +26,8 @@ macro_rules! unit_response { #[cfg_attr(feature = "rpc", request(method = "agent/listModes", response = ListAgentModesResponse))] #[serde(rename_all = "camelCase")] pub struct ListAgentModesRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default)] @@ -131,6 +133,10 @@ pub struct SubmitDialogTurnBody { #[serde(default, skip_serializing_if = "AgentDialogTurnExecution::is_standard")] pub execution: AgentDialogTurnExecution, pub agent_type: String, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID storage selector. Current clients send `workspace_id`. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -158,6 +164,7 @@ impl SubmitDialogTurnBody { execution: self.execution, agent_type: self.agent_type, workspace_path: self.workspace_path, + workspace_id: self.workspace_id, remote_connection_id: self.remote_connection_id, remote_ssh_host: self.remote_ssh_host, policy: self.policy.unwrap_or(default_policy), @@ -181,6 +188,7 @@ impl From for SubmitDialogTurnRequest { turn_id: request.turn_id, execution: request.execution, agent_type: request.agent_type, + workspace_id: request.workspace_id, workspace_path: request.workspace_path, remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, @@ -270,6 +278,10 @@ pub enum RunSessionSpec { Create { session_name: String, agent_type: String, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + /// Legacy execution root for pre-ID clients. #[serde(default, skip_serializing_if = "Option::is_none")] workspace_path: Option, }, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs index a64dac7029..9e3e128252 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs @@ -24,6 +24,10 @@ pub use openbitfun_product_domains::external_sources::ExternalSourceConflictPref #[cfg_attr(feature = "rpc", request(method = "externalSource/snapshot", response = ExternalSourceSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub force_refresh: bool, } @@ -43,6 +47,10 @@ pub struct ExternalSourceSnapshotResponse { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceEventNotification { pub cursor: crate::event::EventCursor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub snapshot: ExternalSourcePublicSnapshot, } @@ -52,6 +60,10 @@ pub struct ExternalSourceEventNotification { #[cfg_attr(feature = "rpc", request(method = "externalSource/control", response = ExternalSourceControlResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceControlRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub request: ExternalSourceControlRequestV1, } @@ -69,6 +81,10 @@ pub struct ExternalSourceControlResponse { #[cfg_attr(feature = "rpc", request(method = "externalSource/review", response = ExternalSourceReviewResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceReviewRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub operation_id: String, pub action: ExternalSourceReviewAction, @@ -136,6 +152,10 @@ pub struct ExternalSourceReviewResponse(pub ExternalSourceSnapshotResponse); )] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetNativeCommandChoiceRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub operation_id: String, pub native_commands: Vec, @@ -156,6 +176,10 @@ pub struct SetNativeCommandChoiceResponse { #[cfg_attr(feature = "rpc", request(method = "externalSource/expandCommand", response = ExpandExternalCommandResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpandExternalCommandRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub operation_id: String, pub command_name: String, @@ -230,6 +254,7 @@ mod tests { #[test] fn command_debug_redacts_workspace_and_arguments() { let request = ExpandExternalCommandRequest { + workspace_id: Some("workspace-1".to_string()), workspace_path: "C:/secret/project".to_string(), operation_id: "operation-1".to_string(), command_name: "review".to_string(), diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs b/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs index 46d3d25256..d22a076ef5 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs @@ -22,6 +22,10 @@ pub use openbitfun_product_domains::native_hooks::{ #[cfg_attr(feature = "rpc", request(method = "nativeHook/overview", response = NativeHookOverviewResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NativeHookOverviewRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, } @@ -57,6 +61,10 @@ impl std::fmt::Debug for NativeHookOverviewResponse { #[cfg_attr(feature = "rpc", request(method = "externalHook/snapshot", response = ExternalHookSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookSnapshotRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub refresh_updates: bool, } @@ -80,6 +88,10 @@ pub struct ExternalHookSnapshotResponse(pub ExternalHookImportSnapshotV1); #[cfg_attr(feature = "rpc", request(method = "externalHook/plan", response = ExternalHookPlanResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookPlanRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub source: SourceKey, } @@ -103,6 +115,10 @@ pub struct ExternalHookPlanResponse(pub ExternalHookImportPlanV1); #[cfg_attr(feature = "rpc", request(method = "externalHook/apply", response = ExternalHookApplyResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookApplyRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub operation_id: String, pub import_request: ExternalHookImportApplyRequestV1, @@ -129,6 +145,10 @@ pub struct ExternalHookApplyResponse(pub ExternalHookImportApplyResultV1); #[cfg_attr(feature = "rpc", request(method = "externalHook/mutate", response = ExternalHookMutationResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookMutationRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only field for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub operation_id: String, pub mutation: ExternalHookImportMutationRequestV1, @@ -160,6 +180,7 @@ mod tests { #[test] fn hook_request_debug_redacts_workspace_paths() { let request = ExternalHookMutationRequest { + workspace_id: Some("workspace-1".to_string()), workspace_path: "C:/secret/workspace".to_string(), operation_id: "hook-operation-1".to_string(), mutation: ExternalHookImportMutationRequestV1 { diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs b/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs index eea0e0efef..5600489c75 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs @@ -24,6 +24,10 @@ macro_rules! unit_response { #[cfg_attr(feature = "rpc", request(method = "mcp/list", response = ListMcpServersResponse))] #[serde(rename_all = "camelCase")] pub struct ListMcpServersRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only reference. + #[serde(default)] pub workspace_path: String, } @@ -78,6 +82,10 @@ unit_response!(DeleteMcpServerResponse); #[cfg_attr(feature = "rpc", request(method = "mcp/externalDecision", response = ExternalMcpDecisionResponse))] #[serde(rename_all = "camelCase")] pub struct ExternalMcpDecisionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only reference. + #[serde(default)] pub workspace_path: String, pub candidate_id: String, pub decision_key: String, @@ -93,6 +101,10 @@ unit_response!(ExternalMcpDecisionResponse); #[cfg_attr(feature = "rpc", request(method = "mcp/conflictChoice", response = McpConflictChoiceResponse))] #[serde(rename_all = "camelCase")] pub struct McpConflictChoiceRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only reference. + #[serde(default)] pub workspace_path: String, pub conflict_key: String, pub candidate_id: String, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs index 923a2231a3..a527508e44 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs @@ -30,6 +30,10 @@ macro_rules! unit_response { #[cfg_attr(feature = "rpc", request(method = "session/sync", response = SyncSessionResponse))] #[serde(rename_all = "camelCase")] pub struct SyncSessionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID wire field. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub session_id: String, #[serde(default)] @@ -90,6 +94,10 @@ pub enum SessionProcessingPhase { #[cfg_attr(feature = "rpc", request(method = "session/restore", response = RestoreSessionResponse))] #[serde(rename_all = "camelCase")] pub struct RestoreSessionMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Upgrade-only pre-ID wire field. + #[serde(default, skip_serializing_if = "String::is_empty")] pub workspace_path: String, pub session_id: String, #[serde(default)] diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs b/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs index 5bef370b40..58eb57fba9 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs @@ -11,6 +11,10 @@ pub use openbitfun_product_domains::agent_catalog::SkillSummary; #[cfg_attr(feature = "rpc", request(method = "skill/list", response = ListSkillsResponse))] #[serde(rename_all = "camelCase")] pub struct ListSkillsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub mode_id: String, #[serde(default)] @@ -28,6 +32,10 @@ pub struct ListSkillsResponse { #[cfg_attr(feature = "rpc", request(method = "skill/setEnabled", response = SetSkillEnabledResponse))] #[serde(rename_all = "camelCase")] pub struct SetSkillEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub mode_id: String, pub skill_key: String, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs index 8154665991..970a38cfaf 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs @@ -11,6 +11,10 @@ pub use openbitfun_product_domains::agent_catalog::SubagentSummary; #[cfg_attr(feature = "rpc", request(method = "subagent/list", response = ListSubagentsResponse))] #[serde(rename_all = "camelCase")] pub struct ListSubagentsRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub parent_mode_id: String, #[serde(default)] @@ -30,6 +34,10 @@ pub struct ListSubagentsResponse { #[cfg_attr(feature = "rpc", request(method = "subagent/setEnabled", response = SetSubagentEnabledResponse))] #[serde(rename_all = "camelCase")] pub struct SetSubagentEnabledRequest { + #[serde(default)] + pub workspace_id: Option, + /// Upgrade-only input for pre-ID clients. + #[serde(default)] pub workspace_path: String, pub parent_mode_id: String, pub subagent_id: String, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs b/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs index 26d624172a..0ec608ddab 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs @@ -52,6 +52,9 @@ pub struct WorktreeRepositoryStatusResponse { pub struct WorktreeBindSessionRequest { pub operation_id: String, pub session_id: String, + /// Owning project workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -85,6 +88,9 @@ impl WorktreeBindSessionRequest { pub struct WorktreeReleaseSessionRequest { pub operation_id: String, pub session_id: String, + /// Owning project workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +197,7 @@ mod tests { let bind = WorktreeBindSessionRequest { operation_id: "worktree-1".to_string(), session_id: "session-1".to_string(), + project_workspace_id: Some("workspace-1".to_string()), project_workspace_path: Some(path.to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -198,6 +205,7 @@ mod tests { let release = WorktreeReleaseSessionRequest { operation_id: "worktree-2".to_string(), session_id: "session-1".to_string(), + project_workspace_id: Some("workspace-1".to_string()), project_workspace_path: Some(path.to_string()), remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index 48a69e4058..903a6eb246 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1,7 +1,7 @@ //! App Server management adapter over the existing product owners. use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; @@ -117,9 +117,9 @@ impl AppManagementService { async fn ensure_external_source_subscription( &self, - workspace: &Path, + workspace: &str, ) -> AppManagementResult<()> { - let workspace_path = workspace.to_string_lossy().to_string(); + let workspace_path = workspace.to_owned(); { let mut subscriptions = self .external_source_subscriptions @@ -354,7 +354,7 @@ async fn external_source_preferences() -> AppManagementResult AppManagementResult { let surface = openbitfun_core::external_sources::get_external_source_control_snapshot( @@ -851,10 +851,16 @@ impl AppManagementService { &self, request: NativeHookOverviewRequest, ) -> AppManagementResult { - let workspace = Path::new(&request.workspace_path); - let overview = openbitfun_core::native_hooks::overview(Some(workspace)).await; + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let workspace = record.id.as_str(); + let overview = openbitfun_core::native_hooks::overview(Some(workspace)) + .await + .map_err(AppManagementError::invalid_request)?; Ok(NativeHookOverviewResponse(project_native_hook_overview( - overview, workspace, + overview, + &record.root_path, ))) } @@ -863,7 +869,14 @@ impl AppManagementService { request: ExternalHookSnapshotRequest, ) -> AppManagementResult { openbitfun_core::external_hook_import::external_hook_import_snapshot( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), request.refresh_updates, ) .await @@ -876,7 +889,14 @@ impl AppManagementService { request: ExternalHookPlanRequest, ) -> AppManagementResult { openbitfun_core::external_hook_import::plan_external_hook_import( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), request.source, ) .await @@ -890,7 +910,14 @@ impl AppManagementService { ) -> AppManagementResult { validate_external_operation(&request.operation_id)?; openbitfun_core::external_hook_import::apply_external_hook_import( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), request.import_request, ) .await @@ -904,7 +931,14 @@ impl AppManagementService { ) -> AppManagementResult { validate_external_operation(&request.operation_id)?; openbitfun_core::external_hook_import::mutate_external_hook_import( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), request.mutation, ) .await @@ -916,7 +950,10 @@ impl AppManagementService { &self, request: ExternalSourceSnapshotRequest, ) -> AppManagementResult { - let workspace = Path::new(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let workspace = record.id.as_str(); self.ensure_external_source_subscription(workspace).await?; external_source_snapshot_response(workspace, request.force_refresh).await } @@ -929,7 +966,10 @@ impl AppManagementService { .request .validate() .map_err(AppManagementError::invalid_request)?; - let workspace = Path::new(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let workspace = record.id.as_str(); let surface = openbitfun_core::external_sources::apply_external_source_control_action( Some(workspace), request.request, @@ -947,7 +987,10 @@ impl AppManagementService { request: ExternalSourceReviewRequest, ) -> AppManagementResult { validate_external_operation(&request.operation_id)?; - let workspace = Path::new(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let workspace = record.id.as_str(); let operation_id = request.operation_id.clone(); let result = match request.action { ExternalSourceReviewAction::Refresh => { @@ -1059,7 +1102,14 @@ impl AppManagementService { let operation_id = request.operation_id.clone(); let conflicts = openbitfun_core::external_sources::set_native_prompt_command_conflict_choice( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), request.native_commands, &request.selected_candidate_id, request.expected_preference_revision, @@ -1079,7 +1129,14 @@ impl AppManagementService { validate_external_operation(&request.operation_id)?; let operation_id = request.operation_id.clone(); openbitfun_core::external_sources::expand_external_prompt_command( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), &request.command_name, &request.arguments, request.native_commands, @@ -1098,11 +1155,25 @@ impl AppManagementService { &self, request: ListAgentModesRequest, ) -> AppManagementResult { - let workspace = request.workspace_path.map(PathBuf::from); - if request.include_external { + let record = if request.workspace_id.is_some() || request.workspace_path.is_some() { + Some( + resolve_management_workspace( + request.workspace_id.as_deref(), + request.workspace_path.as_deref().unwrap_or_default(), + ) + .await?, + ) + } else { + None + }; + let include_external = request.include_external + && record.as_ref().is_some_and(|record| { + record.workspace_kind != openbitfun_core::service::workspace::WorkspaceKind::Remote + }); + if include_external { if let Err(error) = openbitfun_core::external_sources::ensure_external_source_workspace_snapshot( - workspace.as_deref(), + record.as_ref().map(|record| record.id.as_str()), ) .await { @@ -1110,7 +1181,10 @@ impl AppManagementService { } } let modes = openbitfun_core::agentic::agents::get_agent_registry() - .get_modes_info_for_workspace(workspace.as_deref(), request.include_external) + .get_modes_info_for_workspace( + record.as_ref().map(|record| record.id.as_str()), + include_external, + ) .await .into_iter() .map(|mode| AgentModeSummary { @@ -1243,12 +1317,27 @@ impl AppManagementService { &self, request: ListSkillsRequest, ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let remote = + record.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; + if remote { + return Err(AppManagementError::invalid_request( + "This management operation is unsupported for remote workspaces", + )); + } + let workspace = openbitfun_core::agentic::workspace::WorkspaceBinding::resolve(&record.id) + .await + .map_err(core_error)?; let registry = openbitfun_core::agentic::tools::implementations::skills::get_skill_registry(); let skills = if request.manageable { registry - .get_mode_skill_infos_for_workspace(Some(&workspace), &request.mode_id) + .get_mode_skill_infos_for_workspace( + openbitfun_core::agentic::tools::implementations::skills::mode_overrides::SkillPolicyWorkspace::from_binding(&workspace), + &request.mode_id, + ) .await .into_iter() .map(skill_from_mode_info) @@ -1268,7 +1357,16 @@ impl AppManagementService { &self, request: SetSkillEnabledRequest, ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let remote = + record.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; + if remote { + return Err(AppManagementError::invalid_request( + "This management operation is unsupported for remote workspaces", + )); + } match request.level.as_str() { "user" => { let _ = openbitfun_core::agentic::tools::implementations::skills::mode_overrides::set_user_mode_skill_state( @@ -1281,7 +1379,7 @@ impl AppManagementService { .map_err(core_error)?; } "project" => { - let mut document = openbitfun_core::agentic::tools::implementations::skills::mode_overrides::load_project_mode_skills_document_local(&workspace) + let mut document = openbitfun_core::agentic::tools::implementations::skills::mode_overrides::load_project_mode_skills_document_local(&record.root_path) .await .map_err(core_error)?; openbitfun_core::agentic::tools::implementations::skills::mode_overrides::set_mode_skill_disabled_in_document( @@ -1292,7 +1390,7 @@ impl AppManagementService { ) .map_err(core_error)?; openbitfun_core::agentic::tools::implementations::skills::mode_overrides::save_project_mode_skills_document_local( - &workspace, + &record.root_path, &document, ) .await @@ -1311,7 +1409,11 @@ impl AppManagementService { &self, request: ListSubagentsRequest, ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let remote = + record.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; let scope = if request.management { openbitfun_core::agentic::agents::SubagentListScope::RegistryManagement } else { @@ -1320,10 +1422,10 @@ impl AppManagementService { let values = openbitfun_core::agentic::agents::get_agent_registry() .get_subagents_for_query(&openbitfun_core::agentic::agents::SubagentQueryContext { parent_agent_type: Some(&request.parent_mode_id), - workspace_root: Some(&workspace), + workspace_id: (!remote).then_some(record.id.as_str()), list_scope: scope, include_disabled: request.management, - external_sources_supported: true, + external_sources_supported: !remote, }) .await; let has_external = values.iter().any(|info| { @@ -1347,13 +1449,22 @@ impl AppManagementService { &self, request: SetSubagentEnabledRequest, ) -> AppManagementResult { - let workspace = PathBuf::from(&request.workspace_path); + let record = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; + let remote = + record.workspace_kind == openbitfun_core::service::workspace::WorkspaceKind::Remote; + if remote { + return Err(AppManagementError::invalid_request( + "This management operation is unsupported for remote workspaces", + )); + } openbitfun_core::agentic::agents::get_agent_registry() .update_subagent_override( &request.parent_mode_id, &request.subagent_id, request.enabled, - Some(&workspace), + Some(&record.id), ) .await .map_err(core_error)?; @@ -1368,9 +1479,11 @@ impl AppManagementService { .mcp .as_ref() .ok_or_else(|| AppManagementError::unsupported("The Host MCP owner is unavailable"))?; - let workspace = PathBuf::from(request.workspace_path); + let workspace = + resolve_management_workspace(request.workspace_id.as_deref(), &request.workspace_path) + .await?; let external = - openbitfun_core::external_sources::external_source_snapshot(Some(&workspace), false) + openbitfun_core::external_sources::external_source_snapshot(Some(&workspace.id), false) .await .map_err(|error| AppManagementError::internal(sanitize_management_error(error)))?; let tool_registry = openbitfun_core::agentic::tools::registry::get_global_tool_registry(); @@ -1558,7 +1671,14 @@ impl AppManagementService { request: ExternalMcpDecisionRequest, ) -> AppManagementResult { openbitfun_core::external_sources::set_external_mcp_server_decision( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), &request.candidate_id, &request.decision_key, request.approved, @@ -1575,7 +1695,14 @@ impl AppManagementService { request: McpConflictChoiceRequest, ) -> AppManagementResult { openbitfun_core::external_sources::choose_external_mcp_conflict( - Some(Path::new(&request.workspace_path)), + Some( + &resolve_management_workspace( + request.workspace_id.as_deref(), + &request.workspace_path, + ) + .await? + .id, + ), &request.conflict_key, &request.candidate_id, request.approve_external, @@ -1661,6 +1788,7 @@ fn bounded_error(message: String) -> String { mod tests { use super::*; use crate::AppManagementErrorKind; + use std::path::PathBuf; fn native_overview_with_sensitive_paths() -> openbitfun_core::native_hooks::NativeHookOverview { openbitfun_core::native_hooks::NativeHookOverview { @@ -1891,3 +2019,17 @@ mod tests { } } } + +/// Old wire references are converted at ingress, then only the record ID is used. +async fn resolve_management_workspace( + workspace_id: Option<&str>, + legacy_path: &str, +) -> AppManagementResult { + let service = openbitfun_core::service::workspace::get_global_workspace_service() + .ok_or_else(|| AppManagementError::invalid_request("Workspace service is unavailable"))?; + service + .resolve_legacy_workspace_reference(workspace_id, legacy_path, None, None) + .await + .map_err(|error| AppManagementError::invalid_request(error.to_string()))? + .ok_or_else(|| AppManagementError::invalid_request("Unknown workspace reference")) +} diff --git a/src/crates/interfaces/app-server/src/management/worktree.rs b/src/crates/interfaces/app-server/src/management/worktree.rs index a3553333d0..30f5a7d97f 100644 --- a/src/crates/interfaces/app-server/src/management/worktree.rs +++ b/src/crates/interfaces/app-server/src/management/worktree.rs @@ -42,6 +42,7 @@ pub(crate) async fn bind_session( request.is_remote(), request.operation_id, request.session_id, + request.project_workspace_id, request.project_workspace_path, true, ) @@ -55,6 +56,7 @@ pub(crate) async fn release_session( request.is_remote(), request.operation_id, request.session_id, + request.project_workspace_id, request.project_workspace_path, false, ) @@ -65,6 +67,7 @@ async fn transition( remote: bool, operation_id: String, session_id: String, + project_workspace_id: Option, project_workspace_path: Option, enabled: bool, ) -> AppManagementResult { @@ -81,6 +84,7 @@ async fn transition( let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { request_id: operation_id.clone(), session_id, + project_workspace_id, project_workspace_path, enabled, }) @@ -89,6 +93,8 @@ async fn transition( let execution_target = result.execution_target.clone(); Ok(WorktreeBindingResponse { workspace_binding: AgentSessionWorkspaceBinding { + workspace_kind: Some(openbitfun_core::service::workspace::WorkspaceKind::Normal), + project_workspace_id: result.project_workspace_id, workspace_id: result.workspace_id, workspace_path: result.workspace_path, project_workspace_path: Some(result.project_workspace_path), diff --git a/src/crates/interfaces/app-server/src/server/event_forwarder.rs b/src/crates/interfaces/app-server/src/server/event_forwarder.rs index 108733f005..20a1d0985e 100644 --- a/src/crates/interfaces/app-server/src/server/event_forwarder.rs +++ b/src/crates/interfaces/app-server/src/server/event_forwarder.rs @@ -137,9 +137,15 @@ pub(super) async fn run( None => {} }, recv = external_source_recv => match recv { - Some(Ok((workspace_path, snapshot))) => { + Some(Ok((workspace_id, snapshot))) => { + let workspace_path = match openbitfun_core::service::workspace::get_global_workspace_service() { + Some(service) => service.require_workspace(&workspace_id).await.ok() + .map(|record| record.root_path.to_string_lossy().into_owned()).unwrap_or_default(), + None => String::new(), + }; if let Err(error) = cx.send_notification(ExternalSourceEventNotification { cursor: event_state.next_cursor(EventStream::ExternalSource), + workspace_id: Some(workspace_id), workspace_path, snapshot, }) { diff --git a/src/crates/interfaces/app-server/src/server/handlers/session.rs b/src/crates/interfaces/app-server/src/server/handlers/session.rs index 40376a5ffb..463270c192 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/session.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/session.rs @@ -10,7 +10,6 @@ use openbitfun_app_server_protocol::session::{ SessionUsageRequest, SessionUsageResponse, SyncSessionRequest, SyncSessionResponse, UndoSessionRequest, WaitForSettlementRequest, WaitForSettlementResponse, }; -use openbitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; use crate::agent::{runtime_call, OpenBitFunAppRuntime}; use crate::role::{AppClient, AppServer}; @@ -168,10 +167,10 @@ pub(in crate::server) fn builder( let runtime = runtime.clone(); async move |request: SyncSessionRequest, responder, _cx| { let session_id = request.session_id.clone(); - let workspace_path = request.workspace_path.clone(); let restored = runtime .runtime() .restore_session(AgentSessionRestoreRequest { + workspace_id: request.workspace_id, workspace_path: request.workspace_path, session_id: request.session_id, include_internal: request.include_internal, @@ -203,7 +202,9 @@ pub(in crate::server) fn builder( ) .await, )? - .unwrap_or_else(|| fallback_workspace_binding(workspace_path)); + .ok_or_else(|| { + Error::internal_error().data("Session workspace binding is unavailable") + })?; let pending_permissions = runtime .runtime() .pending_permission_requests() @@ -406,14 +407,3 @@ pub(in crate::server) fn builder( agent_client_protocol::on_receive_request!(), ) } - -fn fallback_workspace_binding(workspace_path: String) -> AgentSessionWorkspaceBinding { - AgentSessionWorkspaceBinding { - workspace_id: None, - workspace_path: workspace_path.clone(), - project_workspace_path: Some(workspace_path.clone()), - execution_target: Some(SessionExecutionTarget::local(workspace_path)), - remote_connection_id: None, - remote_ssh_host: None, - } -} diff --git a/src/crates/interfaces/app-server/src/server/wire.rs b/src/crates/interfaces/app-server/src/server/wire.rs index be1078ca44..e61b868eae 100644 --- a/src/crates/interfaces/app-server/src/server/wire.rs +++ b/src/crates/interfaces/app-server/src/server/wire.rs @@ -55,8 +55,10 @@ pub(super) fn run_request(request: &RunMessage) -> AgentRunRequest { RunSessionSpec::Create { session_name, agent_type, + workspace_id, workspace_path, - } => SessionSelector::create(session_name, agent_type, workspace_path.clone()), + } => SessionSelector::create(session_name, agent_type, workspace_path.clone()) + .with_workspace_id(workspace_id.clone()), }; let mut runtime_request = AgentRunRequest::new(session, &request.message); if let Some(turn_id) = &request.turn_id { @@ -81,6 +83,7 @@ pub(super) fn restore_session_request( request: RestoreSessionMessage, ) -> AgentSessionRestoreRequest { AgentSessionRestoreRequest { + workspace_id: request.workspace_id, workspace_path: request.workspace_path, session_id: request.session_id, include_internal: request.include_internal, @@ -288,6 +291,7 @@ mod tests { turn_id: None, execution: AgentDialogTurnExecution::Standard, agent_type: "general".to_string(), + workspace_id: None, workspace_path: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index ab0f2af64c..bc7f365858 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -362,6 +362,8 @@ impl ports::AgentSessionManagementPort for Phase2Provider { _request: ports::AgentSessionWorkspaceRequest, ) -> PortResult> { Ok(Some(ports::AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: Some("workspace-1".to_string()), workspace_path: "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/authoritative/workspace".to_string(), project_workspace_path: Some("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/authoritative/workspace".to_string()), @@ -595,6 +597,7 @@ impl ports::AgentSessionLineagePort for Phase2Provider { parent_tool_call_id: None, subagent_type: None, agent_id: None, + workspace_id: None, workspace_path: Some("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/authoritative/workspace".to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -795,6 +798,7 @@ async fn phase2_sync_aggregates_authoritative_session_state() { .expect("connect app server client"); let response = client .sync_session(protocol_session::SyncSessionRequest { + workspace_id: None, workspace_path: "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/requested/workspace".to_string(), session_id: "session-1".to_string(), include_internal: true, @@ -886,6 +890,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { let undone = client .undo_session(protocol_session::UndoSessionRequest( ports::AgentSessionRevertRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), session_id: "session-1".to_string(), remote_connection_id: None, @@ -898,6 +903,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { client .redo_session(protocol_session::RedoSessionRequest( ports::AgentSessionRevertRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), session_id: "session-1".to_string(), remote_connection_id: None, @@ -944,6 +950,7 @@ async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() let usage = client .session_usage(protocol_session::SessionUsageRequest( ports::AgentSessionUsageRequest { + workspace_id: None, session_id: "session-1".to_string(), workspace_path: Some("/workspace".to_string()), remote_connection_id: None, @@ -989,6 +996,7 @@ async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() let lineage = client .session_lineage(protocol_session::SessionLineageRequest( ports::AgentSessionLineageRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), anchor_session_id: "session-1".to_string(), remote_connection_id: None, @@ -1001,6 +1009,7 @@ async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() let inspection = client .inspect_lineage(protocol_session::InspectLineageRequest( ports::AgentSessionLineageTranscriptRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), root_session_id: "session-1".to_string(), session_id: "session-1".to_string(), @@ -1015,6 +1024,7 @@ async fn phase2_read_models_cover_usage_settlement_references_lineage_and_diff() let cancelled = client .cancel_lineage(protocol_session::CancelLineageRequest( ports::AgentSessionLineageCancellationRequest { + workspace_id: None, workspace_path: "/workspace".to_string(), root_session_id: "session-1".to_string(), session_id: "session-1".to_string(), @@ -1233,6 +1243,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { .connect_with(client_transport, async |cx: ConnectionTo| { let RenameSessionResponse {} = recv(cx.send_request(RenameSessionMessage( AgentSessionRenameRequest { + workspace_id: None, workspace_path: "/repo".to_string(), session_id: "session-1".to_string(), session_name: "Renamed".to_string(), @@ -1243,6 +1254,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { .await?; let SetSessionArchivedResponse {} = recv(cx.send_request( SetSessionArchivedMessage(AgentSessionArchiveStateRequest { + workspace_id: None, workspace_path: "/repo".to_string(), session_id: "session-1".to_string(), archived: false, @@ -1268,6 +1280,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { .await?; let ForkSessionResponse(forked) = recv(cx.send_request( ForkSessionAtTurnMessage(AgentSessionForkAtTurnRequest { + workspace_id: None, workspace_path: "/repo".to_string(), source_session_id: "session-1".to_string(), source_turn_id: "turn-2".to_string(), @@ -1279,6 +1292,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { assert_eq!(forked.session_id, "forked-session"); let restored = recv(cx.send_request(RestoreSessionMessage { + workspace_id: None, workspace_path: "/repo".to_string(), session_id: "session-1".to_string(), include_internal: true, @@ -1331,6 +1345,7 @@ async fn run_round_trips_through_create_and_submit() { session: RunSessionSpec::Create { session_name: "Example SDK Session".to_string(), agent_type: "Standard".to_string(), + workspace_id: None, workspace_path: None, }, message: "hello from an app-server client".to_string(), @@ -1375,6 +1390,7 @@ async fn submit_dialog_turn_carries_agent_type_and_starts() { turn_id: None, execution: Default::default(), agent_type: "Standard".to_string(), + workspace_id: None, workspace_path: None, remote_connection_id: None, remote_ssh_host: None, @@ -1525,6 +1541,7 @@ async fn list_sessions_maps_missing_port_to_internal_error() { .connect_with(client_transport, async |cx: ConnectionTo| { let result = recv(cx.send_request(ListSessionsMessage( AgentSessionListRequest { + workspace_id: None, workspace_path: ".".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -1557,6 +1574,7 @@ async fn delete_session_maps_missing_port_to_internal_error() { .connect_with(client_transport, async |cx: ConnectionTo| { let result = recv(cx.send_request(DeleteSessionMessage( AgentSessionDeleteRequest { + workspace_id: None, workspace_path: ".".to_string(), session_id: "example-session".to_string(), remote_connection_id: None, diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index 257f3dc2ac..fa437702c9 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -171,6 +171,9 @@ enum QueryReservationError { #[derive(Clone)] struct SessionLease { + /// Owning workspace record ID as reported by the runtime. Authoritative for + /// every later runtime call; `workspace_path` stays the cwd operand. + workspace_id: Option, workspace_path: String, remote_connection_id: Option, remote_ssh_host: Option, @@ -208,6 +211,8 @@ fn release_session_task_reservation( #[derive(Clone)] struct SessionCleanup { session_id: String, + /// Known once the runtime has reported the owning workspace record. + workspace_id: Option, workspace_path: String, release_kind: SessionReleaseKind, } @@ -638,6 +643,7 @@ impl SdkHostConnection { connection .release_runtime_session( session_id.clone(), + session.workspace_id, session.workspace_path, session.remote_connection_id, session.remote_ssh_host, @@ -798,6 +804,7 @@ impl SdkHostConnection { cleanup_timeout, self.release_runtime_session( cleanup.session_id.clone(), + cleanup.workspace_id, cleanup.workspace_path, None, None, @@ -833,6 +840,7 @@ impl SdkHostConnection { async fn release_runtime_session( &self, session_id: String, + workspace_id: Option, workspace_path: String, remote_connection_id: Option, remote_ssh_host: Option, @@ -844,6 +852,7 @@ impl SdkHostConnection { self.inner .runtime .discard_transient_session(AgentSessionReleaseRequest { + workspace_id: workspace_id.clone(), workspace_path, session_id, remote_connection_id, @@ -856,6 +865,7 @@ impl SdkHostConnection { self.inner .runtime .unload_persisted_session(AgentSessionReleaseRequest { + workspace_id: workspace_id.clone(), workspace_path, session_id, remote_connection_id, @@ -868,6 +878,7 @@ impl SdkHostConnection { .inner .runtime .delete_session(AgentSessionDeleteRequest { + workspace_id, workspace_path, session_id, remote_connection_id, @@ -1369,6 +1380,7 @@ impl SdkHostConnection { execution: Default::default(), agent_type, workspace_path: Some(session.workspace_path.clone()), + workspace_id: session.workspace_id.clone(), remote_connection_id: session.remote_connection_id.clone(), remote_ssh_host: session.remote_ssh_host.clone(), policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::SdkHost), @@ -2051,6 +2063,7 @@ impl SdkHostConnection { let release_kind = session.release_kind(); let operation = self.release_runtime_session( session_id, + session.workspace_id, session.workspace_path, session.remote_connection_id, session.remote_ssh_host, @@ -2213,6 +2226,7 @@ impl SdkHostConnection { task_state.lock().await.sessions.insert( created.session_id.clone(), SessionLease { + workspace_id: created.workspace_id.clone(), workspace_path, remote_connection_id: None, remote_ssh_host: None, @@ -2233,6 +2247,7 @@ impl SdkHostConnection { .push(PendingSessionTask { session_cleanup: Some(SessionCleanup { session_id, + workspace_id: None, workspace_path: cleanup_workspace_path, release_kind: match lifetime { SessionLifetime::Connection => SessionReleaseKind::DiscardTransient, @@ -2287,6 +2302,7 @@ impl SdkHostConnection { let restoration = tokio::spawn(async move { let restored = runtime .restore_session(AgentSessionRestoreRequest { + workspace_id: None, workspace_path: task_workspace_path.clone(), session_id: task_session_id.clone(), include_internal: false, @@ -2329,6 +2345,7 @@ impl SdkHostConnection { if prepared.is_err() { let release = runtime .unload_persisted_session(AgentSessionReleaseRequest { + workspace_id: None, workspace_path: task_workspace_path.clone(), session_id: task_session_id.clone(), remote_connection_id: None, @@ -2341,6 +2358,7 @@ impl SdkHostConnection { task_session_id.clone(), SessionCleanup { session_id: task_session_id.clone(), + workspace_id: None, workspace_path: task_workspace_path.clone(), release_kind: SessionReleaseKind::UnloadPersisted, }, @@ -2363,6 +2381,7 @@ impl SdkHostConnection { connection_state.sessions.insert( restored.session_id.clone(), SessionLease { + workspace_id: restored.workspace_id.clone(), workspace_path, remote_connection_id: None, remote_ssh_host: None, @@ -2381,6 +2400,7 @@ impl SdkHostConnection { .push(PendingSessionTask { session_cleanup: Some(SessionCleanup { session_id: session_id.clone(), + workspace_id: None, workspace_path: cleanup_workspace_path, release_kind: SessionReleaseKind::UnloadPersisted, }), @@ -2547,6 +2567,7 @@ impl SdkHostConnection { Duration::from_millis(5_000), self.release_runtime_session( session_id.to_string(), + lease.workspace_id, lease.workspace_path, lease.remote_connection_id, lease.remote_ssh_host, diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index 09cacf9c66..210872f19c 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -621,6 +621,8 @@ impl AgentSessionManagementPort for FakeOwner { return Ok(None); } Ok(Some(AgentSessionWorkspaceBinding { + workspace_kind: None, + project_workspace_id: None, workspace_id: None, workspace_path: "D:/workspace/project".to_string(), project_workspace_path: None, diff --git a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs index f6329a1aad..9d69da43ac 100644 --- a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs +++ b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs @@ -629,6 +629,7 @@ fn merge_chat_state(source: &BotChatStateRecord, target: &mut BotChatStateRecord } if target.current_assistant.is_none() { target.current_assistant = source.current_assistant.clone(); + target.current_assistant_id = source.current_assistant_id.clone(); imported_source_context |= target.current_assistant.is_some(); } if target.current_assistant_name.is_none() { @@ -1432,11 +1433,13 @@ mod tests { chat_id: "chat-1".to_string(), paired: true, current_workspace: workspace.map(|path| owner::BotWorkspaceRefRecord { + workspace_id: None, path: path.to_string(), remote_connection_id: Some("ssh-user@example.invalid:22".to_string()), remote_ssh_host: Some("example.invalid".to_string()), }), current_assistant: None, + current_assistant_id: None, current_assistant_name: None, current_session_id: None, display_mode: owner::BotDisplayModeRecord::Assistant, diff --git a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs index b167821f15..18b48d007f 100644 --- a/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs +++ b/src/crates/services/legacy-migration-adapters/src/workspace_sessions.rs @@ -20,11 +20,12 @@ use openbitfun_services_core::session::{ use openbitfun_services_core::session_projection_format::validate_runtime_event_log; use openbitfun_services_core::workspace_identity::build_project_runtime_slug; use openbitfun_services_core::workspace_identity::{ - canonicalize_local_workspace_root, normalize_remote_workspace_path, LOCAL_WORKSPACE_SSH_HOST, + canonicalize_local_workspace_root, local_workspace_stable_storage_id, + normalize_remote_workspace_path, remote_workspace_stable_id, LOCAL_WORKSPACE_SSH_HOST, }; use openbitfun_services_core::workspace_persistence::{ - current_workspace_storage_id, validate_workspace_persistence_data, WorkspacePersistenceData, - WORKSPACE_PERSISTENCE_FORMAT_VERSION, + unsupported_workspace_persistence, validate_workspace_persistence_data, + WorkspacePersistenceData, WORKSPACE_PERSISTENCE_FORMAT_VERSION, }; use openbitfun_services_core::workspace_records::{ PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind, @@ -860,7 +861,7 @@ fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult openbitfun_services_core::storage_error::StorageResult { + match workspace.workspace_kind { + WorkspaceKind::Remote => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing sshHost", + workspace.id + )) + })?; + workspace.remote_ssh_connection_id().ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing connectionId", + workspace.id + )) + })?; + + let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); + let normalized_root = normalize_remote_workspace_path(&stored_root); + if !normalized_root.starts_with('/') { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' does not use an absolute POSIX root", + workspace.id + ))); + } + if stored_root != normalized_root { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' rootPath is not normalized", + workspace.id + ))); + } + Ok(remote_workspace_stable_id(ssh_host, &normalized_root)) + } + WorkspaceKind::Normal | WorkspaceKind::Assistant => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim); + if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { + return Err(unsupported_workspace_persistence(format!( + "local workspace '{}' does not declare sshHost=localhost", + workspace.id + ))); + } + expected_persisted_local_workspace_id(&workspace.root_path).map_err(|error| { + unsupported_workspace_persistence(format!( + "local workspace '{}' is not canonical: {error}", + workspace.id + )) + }) + } + } +} + +fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { + if !root_path.is_absolute() { + return Err(format!( + "local workspace rootPath is not absolute: {}", + root_path.display() + )); + } + + let normalized_root = if root_path.exists() { + let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; + if canonical_root != root_path { + return Err(format!( + "local workspace rootPath is not canonical: {}", + root_path.display() + )); + } + normalized_root + } else { + root_path.to_string_lossy().replace('\\', "/") + }; + + Ok(local_workspace_stable_storage_id(&normalized_root)) +} diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 1110b496b3..57cf93286a 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -125,3 +125,9 @@ For bounded workspace upload ownership, offset recovery, conflict detection, and ```bash cargo test -p openbitfun-services-core --no-default-features --features workspace-transfer,workspace-runtime --lib workspace_transfer::tests ``` + +Workspace catalog identity and upgrade deserialization (without live filesystem/SSH activation): + +```bash +cargo test -p openbitfun-services-core --no-default-features --features workspace-persistence --lib workspace_persistence::tests +``` diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index 8a62e49664..c4382151ba 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -25,6 +25,8 @@ pub struct SessionMetadataBuildFacts<'a> { pub project_workspace_path: Option<&'a str>, pub execution_target: Option<&'a SessionExecutionTarget>, pub workspace_hostname: Option<&'a str>, + pub workspace_id: Option<&'a str>, + pub project_workspace_id: Option<&'a str>, pub new_session_memory_mode: SessionMemoryMode, pub existing: Option<&'a SessionMetadata>, } @@ -89,6 +91,14 @@ pub fn build_session_metadata(facts: SessionMetadataBuildFacts<'_>) -> SessionMe .cloned() .or_else(|| existing.and_then(|value| value.execution_target.clone())), workspace_hostname: facts.workspace_hostname.map(str::to_string), + project_workspace_id: facts + .project_workspace_id + .map(str::to_owned) + .or_else(|| existing.and_then(|value| value.project_workspace_id.clone())), + workspace_id: facts + .workspace_id + .map(str::to_owned) + .or_else(|| existing.and_then(|value| value.workspace_id.clone())), unread_completion: existing.and_then(|value| value.unread_completion.clone()), needs_user_attention: existing.and_then(|value| value.needs_user_attention.clone()), } @@ -694,6 +704,8 @@ mod tests { project_workspace_path: None, execution_target: None, workspace_hostname: Some("host"), + workspace_id: Some("workspace-1"), + project_workspace_id: Some("workspace-1"), new_session_memory_mode: crate::session::SessionMemoryMode::Enabled, existing: Some(&existing), }); @@ -751,6 +763,8 @@ mod tests { project_workspace_path: None, execution_target: None, workspace_hostname: None, + workspace_id: None, + project_workspace_id: None, new_session_memory_mode: crate::session::SessionMemoryMode::Disabled, existing: None, }); @@ -789,6 +803,8 @@ mod tests { project_workspace_path: Some("/repo"), execution_target: Some(&execution_target), workspace_hostname: None, + workspace_id: None, + project_workspace_id: None, new_session_memory_mode: crate::session::SessionMemoryMode::Enabled, existing: None, }); diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 52e44c37a6..56ff92a9fc 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -288,6 +288,16 @@ pub struct SessionMetadata { )] pub workspace_hostname: Option, + /// Owning-host workspace ID. Paths above are location projections only. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "workspace_id" + )] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_id: Option, + /// Unread completion status for the session. /// 'completed' → green dot, 'error' → red dot. /// Cleared after the user switches to the session and the content renders. @@ -1184,6 +1194,8 @@ impl SessionMetadata { project_workspace_path: None, execution_target: None, workspace_hostname: None, + workspace_id: None, + project_workspace_id: None, unread_completion: None, needs_user_attention: None, last_turn: None, diff --git a/src/crates/services/services-core/src/workspace_identity.rs b/src/crates/services/services-core/src/workspace_identity.rs index 6e7ff3285d..616dbc0f08 100644 --- a/src/crates/services/services-core/src/workspace_identity.rs +++ b/src/crates/services/services-core/src/workspace_identity.rs @@ -9,6 +9,7 @@ pub const LOCAL_WORKSPACE_SSH_HOST: &str = "localhost"; /// Unified workspace identity used to resolve session persistence for local and remote workspaces. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct WorkspaceSessionIdentity { + pub workspace_kind: openbitfun_core_types::WorkspaceKind, pub hostname: String, /// Canonical local root or normalized remote root used to identify the logical workspace. pub logical_workspace_path: String, @@ -17,7 +18,7 @@ pub struct WorkspaceSessionIdentity { impl WorkspaceSessionIdentity { pub fn is_remote(&self) -> bool { - self.hostname != LOCAL_WORKSPACE_SSH_HOST + self.workspace_kind == openbitfun_core_types::WorkspaceKind::Remote } pub fn logical_workspace_path(&self) -> &str { @@ -246,6 +247,7 @@ pub fn workspace_session_identity( .filter(|s| !s.is_empty()) .map(str::to_string)?; return Some(WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, hostname, logical_workspace_path: normalize_remote_workspace_path(workspace_path), remote_connection_id: Some(connection_id), @@ -255,6 +257,7 @@ pub fn workspace_session_identity( let local_root = normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; Some(WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Normal, hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), logical_workspace_path: local_root, remote_connection_id: None, diff --git a/src/crates/services/services-core/src/workspace_persistence.rs b/src/crates/services/services-core/src/workspace_persistence.rs index 38d8648091..0e9a6034c1 100644 --- a/src/crates/services/services-core/src/workspace_persistence.rs +++ b/src/crates/services/services-core/src/workspace_persistence.rs @@ -1,10 +1,6 @@ //! Current Workspace registry persistence contract and validation. use crate::storage_error::{StorageError as OpenBitFunError, StorageResult as OpenBitFunResult}; -use crate::workspace_identity::{ - canonicalize_local_workspace_root, local_workspace_stable_storage_id, - normalize_remote_workspace_path, remote_workspace_stable_id, LOCAL_WORKSPACE_SSH_HOST, -}; use crate::workspace_records::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; use openbitfun_core_types::product_identity::product_id; use serde::{Deserialize, Serialize}; @@ -32,66 +28,6 @@ pub struct WorkspacePersistenceData { pub saved_at: chrono::DateTime, } -pub fn current_workspace_storage_id(workspace: &WorkspaceInfo) -> OpenBitFunResult { - match workspace.workspace_kind { - WorkspaceKind::Remote => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - unsupported_workspace_persistence(format!( - "remote workspace '{}' is missing sshHost", - workspace.id - )) - })?; - workspace.remote_ssh_connection_id().ok_or_else(|| { - unsupported_workspace_persistence(format!( - "remote workspace '{}' is missing connectionId", - workspace.id - )) - })?; - - let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); - let normalized_root = normalize_remote_workspace_path(&stored_root); - if !normalized_root.starts_with('/') { - return Err(unsupported_workspace_persistence(format!( - "remote workspace '{}' does not use an absolute POSIX root", - workspace.id - ))); - } - if stored_root != normalized_root { - return Err(unsupported_workspace_persistence(format!( - "remote workspace '{}' rootPath is not normalized", - workspace.id - ))); - } - Ok(remote_workspace_stable_id(ssh_host, &normalized_root)) - } - WorkspaceKind::Normal | WorkspaceKind::Assistant => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim); - if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { - return Err(unsupported_workspace_persistence(format!( - "local workspace '{}' does not declare sshHost=localhost", - workspace.id - ))); - } - expected_persisted_local_workspace_id(&workspace.root_path).map_err(|error| { - unsupported_workspace_persistence(format!( - "local workspace '{}' is not canonical: {error}", - workspace.id - )) - }) - } - } -} - pub fn validate_workspace_persistence_data( data: &WorkspacePersistenceData, miniapps_root: &Path, @@ -117,11 +53,13 @@ pub fn validate_workspace_persistence_data( workspace.id ))); } - let expected_id = current_workspace_storage_id(workspace)?; - if storage_id != &expected_id { - return Err(unsupported_workspace_persistence(format!( - "workspace id '{storage_id}' is not canonical; expected '{expected_id}'" - ))); + // A persisted ID is opaque and stable. Recomputing it from rootPath + // makes relocation (or an offline SSH profile) corrupt the whole catalog. + // IO validation belongs to activation, not registry deserialization. + if storage_id.trim().is_empty() { + return Err(unsupported_workspace_persistence( + "workspace id must not be empty", + )); } } validate_workspace_reference_list( @@ -177,30 +115,6 @@ pub fn validate_workspace_persistence_data( Ok(()) } -fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { - if !root_path.is_absolute() { - return Err(format!( - "local workspace rootPath is not absolute: {}", - root_path.display() - )); - } - - let normalized_root = if root_path.exists() { - let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; - if canonical_root != root_path { - return Err(format!( - "local workspace rootPath is not canonical: {}", - root_path.display() - )); - } - normalized_root - } else { - root_path.to_string_lossy().replace('\\', "/") - }; - - Ok(local_workspace_stable_storage_id(&normalized_root)) -} - fn validate_workspace_reference_list( workspaces: &HashMap, ids: &[String], @@ -228,3 +142,69 @@ pub fn unsupported_workspace_persistence(detail: impl AsRef) -> OpenBitFunE detail.as_ref() )) } + +#[cfg(test)] +mod tests { + use super::*; + + fn legacy_catalog() -> WorkspacePersistenceData { + // Existing 1.0.0 record shape: no new identity or availability fields. + serde_json::from_value(serde_json::json!({ + "format_version": 1, "product_id": product_id(), + "workspaces": { + "saved-id": { + "id": "saved-id", "name": "Offline workspace", "rootPath": "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/offline/repo", + "workspaceType": "Other", "workspaceKind": "remote", "status": "Inactive", + "languages": [], "openedAt": "2026-09-16T00:00:00Z", "lastAccessed": "2026-09-16T00:00:00Z", + "description": null, "tags": [], "statistics": null, "metadata": {} + } + }, + "opened_workspace_ids": [], "current_workspace_id": null, + "recent_workspaces": ["saved-id"], "saved_at": "2026-09-16T00:00:00Z" + })).unwrap() + } + + #[test] + fn old_remote_record_without_credentials_survives_registry_round_trip() { + let data = legacy_catalog(); + validate_workspace_persistence_data(&data, Path::new("/miniapps")).unwrap(); + let round_trip: WorkspacePersistenceData = + serde_json::from_value(serde_json::to_value(&data).unwrap()).unwrap(); + let record = &round_trip.workspaces["saved-id"]; + assert_eq!(record.workspace_kind, WorkspaceKind::Remote); + assert!( + record.filesystem_connection_id().is_err(), + "activation must remain unavailable, never local" + ); + assert_eq!(record.id, "saved-id"); + assert_eq!(round_trip.recent_workspaces, vec!["saved-id"]); + } + + #[test] + fn persisted_id_does_not_depend_on_path_or_stale_ssh_metadata() { + let mut data = legacy_catalog(); + let record = data.workspaces.get_mut("saved-id").unwrap(); + record.workspace_kind = WorkspaceKind::Normal; + record.root_path = "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/moved/repo".into(); + record + .metadata + .insert("connectionId".into(), serde_json::json!("stale-ssh")); + validate_workspace_persistence_data(&data, Path::new("/miniapps")).unwrap(); + assert_eq!( + data.workspaces["saved-id"] + .filesystem_connection_id() + .unwrap(), + None + ); + assert_eq!(data.workspaces["saved-id"].id, "saved-id"); + } + + #[test] + fn mismatched_record_id_is_rejected_without_modifying_input() { + let mut data = legacy_catalog(); + data.workspaces.get_mut("saved-id").unwrap().id = "another-id".into(); + let before = serde_json::to_value(&data).unwrap(); + assert!(validate_workspace_persistence_data(&data, Path::new("/miniapps")).is_err()); + assert_eq!(serde_json::to_value(&data).unwrap(), before); + } +} diff --git a/src/crates/services/services-core/src/workspace_records.rs b/src/crates/services/services-core/src/workspace_records.rs index d25dc8e885..ea335cfd49 100644 --- a/src/crates/services/services-core/src/workspace_records.rs +++ b/src/crates/services/services-core/src/workspace_records.rs @@ -29,15 +29,7 @@ pub enum WorkspaceStatus { Archived, } -/// Workspace lifecycle kind. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum WorkspaceKind { - #[default] - Normal, - Assistant, - Remote, -} +pub use openbitfun_core_types::WorkspaceKind; /// Stable identity of the assistant workspace that owns the primary role. /// @@ -95,6 +87,9 @@ pub struct WorkspaceIdentity { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceWorktreeInfo { + /// Authoritative project relationship. `main_repo_path` remains an IO projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub main_workspace_id: Option, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub branch: Option, @@ -138,6 +133,39 @@ pub struct WorkspaceInfo { } impl WorkspaceInfo { + /// Workspace owning this execution workspace's sessions. + pub fn project_workspace_id(&self) -> Result<&str, String> { + if self.workspace_kind != WorkspaceKind::Remote { + if let Some(tree) = self.worktree.as_ref().filter(|tree| !tree.is_main) { + return tree + .main_workspace_id + .as_deref() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + format!( + "Workspace '{}' has an unresolved project workspace ID", + self.id + ) + }); + } + } + Ok(&self.id) + } + + /// Select the filesystem provider from the authoritative workspace kind. + /// Paths and stale SSH metadata never change a local workspace into a remote one. + pub fn filesystem_connection_id(&self) -> Result, String> { + if self.workspace_kind != WorkspaceKind::Remote { + return Ok(None); + } + self.remote_ssh_connection_id().map(Some).ok_or_else(|| { + format!( + "Remote workspace '{}' has no saved SSH connection ID", + self.id + ) + }) + } + /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. pub fn remote_ssh_connection_id(&self) -> Option<&str> { self.metadata diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 0a95b3fa93..7a30cef4c6 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -131,7 +131,7 @@ pub fn build_remote_session_create_request( workspace_path: workspace_path.map(Into::into), project_workspace_path: None, execution_target: None, - workspace_id: None, + workspace_id: workspace_identity.workspace_id, remote_connection_id: workspace_identity.remote_connection_id, remote_ssh_host: workspace_identity.remote_ssh_host, model_id: None, @@ -384,6 +384,10 @@ pub struct RemoteTerminalPrewarmRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteDialogWorkspaceBinding { + /// Owning workspace record ID from the persisted session binding. It is + /// the authoritative restore target; the fields below are IO projections + /// kept for bindings that predate workspace IDs. + pub workspace_id: Option, pub workspace_path: String, pub remote_connection_id: Option, pub remote_ssh_host: Option, @@ -392,6 +396,7 @@ pub struct RemoteDialogWorkspaceBinding { impl RemoteDialogWorkspaceBinding { pub fn local(workspace_path: impl Into) -> Self { Self { + workspace_id: None, workspace_path: workspace_path.into(), remote_connection_id: None, remote_ssh_host: None, @@ -555,6 +560,7 @@ pub const REMOTE_CAPABILITY_PLAN_BUILD_V1: &str = "plan_build_v1"; fn remote_host_capabilities() -> Vec { vec![ + "workspace_id_references_v1".to_string(), REMOTE_CAPABILITY_HARNESS_PROFILES_V1.to_string(), REMOTE_CAPABILITY_DIALOG_STEER_V1.to_string(), REMOTE_CAPABILITY_PLAN_BUILD_V1.to_string(), @@ -860,6 +866,7 @@ where RemoteCommand::ReadFileChunk { path, session_id, + workspace_id, workspace_path, remote_connection_id, offset, @@ -869,6 +876,7 @@ where .read_remote_file_chunk( path, session_id.as_deref(), + workspace_id.as_deref(), workspace_path.as_deref(), remote_connection_id.as_deref(), *offset, @@ -878,7 +886,11 @@ where { Ok(Some(file)) => return remote_file_chunk_response(Ok(file)), Err(error) => return remote_file_chunk_response(Err(error)), - Ok(None) if workspace_path.is_some() || remote_connection_id.is_some() => { + Ok(None) + if workspace_id.is_some() + || workspace_path.is_some() + || remote_connection_id.is_some() => + { return RemoteResponse::Error { message: "This host cannot resolve an explicit file workspace".into(), } @@ -896,6 +908,7 @@ where RemoteCommand::GetFileInfo { path, session_id, + workspace_id, workspace_path, remote_connection_id, } => { @@ -903,6 +916,7 @@ where .remote_file_info( path, session_id.as_deref(), + workspace_id.as_deref(), workspace_path.as_deref(), remote_connection_id.as_deref(), ) @@ -910,7 +924,11 @@ where { Ok(Some(file)) => return remote_file_info_response(Ok(file)), Err(error) => return remote_file_info_response(Err(error)), - Ok(None) if workspace_path.is_some() || remote_connection_id.is_some() => { + Ok(None) + if workspace_id.is_some() + || workspace_path.is_some() + || remote_connection_id.is_some() => + { return RemoteResponse::Error { message: "This host cannot resolve an explicit file workspace".into(), } @@ -998,6 +1016,7 @@ pub fn remote_answer_question_response(result: Result<(), String>) -> RemoteResp pub fn remote_workspace_info_response(workspace: Option) -> RemoteResponse { match workspace { Some(workspace) => RemoteResponse::WorkspaceInfo { + workspace_id: Some(workspace.workspace_id), has_workspace: true, path: Some(workspace.path), project_name: Some(workspace.name), @@ -1009,6 +1028,7 @@ pub fn remote_workspace_info_response(workspace: Option) - capabilities: remote_host_capabilities(), }, None => RemoteResponse::WorkspaceInfo { + workspace_id: None, has_workspace: false, path: None, project_name: None, @@ -1033,6 +1053,7 @@ fn remote_workspace_catalog_response( opened_workspaces: Option>, ) -> RemoteResponse { let project = |workspace: RemoteRecentWorkspaceFacts| RecentWorkspaceEntry { + workspace_id: Some(workspace.workspace_id), path: workspace.path, name: workspace.name, last_opened: workspace.last_opened, @@ -1053,6 +1074,7 @@ pub fn remote_assistant_list_response( assistants: assistants .into_iter() .map(|assistant| AssistantEntry { + workspace_id: Some(assistant.workspace_id), path: assistant.path, name: assistant.name, assistant_id: assistant.assistant_id, @@ -1066,6 +1088,7 @@ pub fn remote_workspace_updated_response( ) -> RemoteResponse { match result { Ok(update) => RemoteResponse::WorkspaceUpdated { + workspace_id: Some(update.workspace_id), success: true, path: Some(update.path), project_name: Some(update.name), @@ -1074,6 +1097,7 @@ pub fn remote_workspace_updated_response( error: None, }, Err(message) => RemoteResponse::WorkspaceUpdated { + workspace_id: None, success: false, path: None, project_name: None, @@ -1089,12 +1113,14 @@ pub fn remote_assistant_updated_response( ) -> RemoteResponse { match result { Ok(update) => RemoteResponse::AssistantUpdated { + workspace_id: Some(update.workspace_id), success: true, path: Some(update.path), name: Some(update.name), error: None, }, Err(message) => RemoteResponse::AssistantUpdated { + workspace_id: None, success: false, path: None, name: None, @@ -1109,6 +1135,7 @@ pub fn remote_session_info( workspace_name: Option<&str>, ) -> SessionInfo { SessionInfo { + workspace_id: metadata.workspace_id.clone(), session_id: metadata.session_id.clone(), name: metadata.name.clone(), agent_type: metadata.agent_type.clone(), @@ -1163,6 +1190,7 @@ pub fn remote_initial_sync_response( has_more_sessions: bool, authenticated_user_id: Option, ) -> RemoteResponse { + let workspace_id = workspace.as_ref().map(|record| record.workspace_id.clone()); let ( has_workspace, path, @@ -1192,6 +1220,7 @@ pub fn remote_initial_sync_response( .collect(); RemoteResponse::InitialSync { + workspace_id, has_workspace, path, project_name, @@ -1220,22 +1249,29 @@ where Err(message) => RemoteResponse::Error { message }, }, RemoteCommand::SetWorkspace { + workspace_id, path, remote_connection_id, remote_ssh_host, - } => remote_workspace_updated_response( + } => remote_workspace_updated_response(if let Some(id) = workspace_id { + host.select_workspace(id).await + } else { host.open_workspace( path, remote_connection_id.as_deref(), remote_ssh_host.as_deref(), ) - .await, - ), + .await + }), RemoteCommand::ListAssistants => { remote_assistant_list_response(host.assistant_workspaces().await) } - RemoteCommand::SetAssistant { path } => { - remote_assistant_updated_response(host.open_assistant_workspace(path).await) + RemoteCommand::SetAssistant { workspace_id, path } => { + remote_assistant_updated_response(if let Some(id) = workspace_id { + host.select_assistant_workspace(id).await + } else { + host.open_assistant_workspace(path).await + }) } _ => RemoteResponse::Error { message: "Unknown workspace command".into(), @@ -1293,8 +1329,10 @@ where pub fn remote_session_created_response(session_id: impl Into) -> RemoteResponse { RemoteResponse::SessionCreated { session_id: session_id.into(), + workspace_id: None, workspace_path: None, remote_connection_id: None, + remote_ssh_host: None, } } @@ -1329,12 +1367,26 @@ pub fn remote_session_deleted_response(session_id: impl Into) -> RemoteR #[async_trait::async_trait] pub trait RemoteSessionRuntimeHost: Send + Sync { + async fn workspace_by_id(&self, _workspace_id: &str) -> Result { + Err("Host does not support workspace ID lookup".to_string()) + } + /// Translate a pre-ID `(path, connection, ssh)` reference into the owning + /// workspace record. `Ok(None)` means no record owns the reference; + /// `Err` means it is ambiguous or the host cannot resolve it. The handler + /// never uses the raw path as a storage key. + async fn resolve_legacy_workspace( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Result, String>; async fn list_session_metadata( &self, workspace_path: &Path, workspace_identity: RemoteSessionWorkspaceIdentity, ) -> Result, String>; - async fn resolve_default_assistant_workspace_path(&self) -> Result; + /// The primary assistant workspace record, created on first use. + async fn resolve_default_assistant_workspace(&self) -> Result; async fn create_session(&self, request: AgentSessionCreateRequest) -> Result; async fn load_model_catalog( &self, @@ -1362,12 +1414,57 @@ pub trait RemoteSessionRuntimeHost: Send + Sync { fn remove_tracker(&self, session_id: &str); } +/// Resolve the workspace a session-level command names. The ID is +/// authoritative; a pre-ID `(path, connection, ssh)` reference is translated +/// once through the host's legacy resolver. `Ok(None)` means the command named +/// no workspace at all. +async fn resolve_remote_session_workspace( + host: &H, + workspace_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result, String> +where + H: RemoteSessionRuntimeHost + ?Sized, +{ + let workspace_id = workspace_id.map(str::trim).filter(|id| !id.is_empty()); + if let Some(id) = workspace_id { + return host.workspace_by_id(id).await.map(Some); + } + let Some(path) = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty() && *path != "/") + else { + return Ok(None); + }; + let connection = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let ssh_host = remote_ssh_host + .map(str::trim) + .filter(|value| !value.is_empty()); + match host + .resolve_legacy_workspace(path, connection, ssh_host) + .await? + { + Some(record) => Ok(Some(record)), + None => Err(format!( + "Workspace path {path} is not an open workspace on the remote device; select the workspace by its ID" + )), + } +} + +const NO_REMOTE_WORKSPACE_MESSAGE: &str = + "No workspace is open on the remote device; select a recent workspace or create one first"; + pub async fn handle_remote_session_command(host: &H, command: &RemoteCommand) -> RemoteResponse where H: RemoteSessionRuntimeHost + ?Sized, { match command { RemoteCommand::ListSessions { + workspace_id, workspace_path, remote_connection_id, remote_ssh_host, @@ -1375,29 +1472,34 @@ where offset, query, } => { + let workspace = match resolve_remote_session_workspace( + host, + workspace_id.as_deref(), + workspace_path.as_deref(), + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await + { + Ok(Some(workspace)) => workspace, + Ok(None) => { + return RemoteResponse::Error { + message: NO_REMOTE_WORKSPACE_MESSAGE.to_string(), + }; + } + Err(message) => return RemoteResponse::Error { message }, + }; let page_size = limit.unwrap_or(30).min(100); let page_offset = offset.unwrap_or(0); - let Some(workspace_path) = workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty() && *path != "/") - .map(PathBuf::from) - else { - return RemoteResponse::Error { - message: "No workspace is open on the remote device; select a recent workspace or create one first".to_string(), - }; - }; - - let workspace_path_str = workspace_path.to_string_lossy().to_string(); - let workspace_name = workspace_path - .file_name() - .map(|name| name.to_string_lossy().to_string()); - + // The record's root is the storage/IO projection; the record ID is + // the only key the host lists by. + let workspace_path = PathBuf::from(&workspace.path); let workspace_identity = RemoteSessionWorkspaceIdentity::new( - remote_connection_id.clone(), - remote_ssh_host.clone(), - ); + workspace.remote_connection_id.clone(), + workspace.remote_ssh_host.clone(), + ) + .with_workspace_id(Some(workspace.workspace_id.clone())); match host .list_session_metadata(&workspace_path, workspace_identity) @@ -1416,11 +1518,19 @@ where .as_ref() .is_none_or(|query| session.name.to_lowercase().contains(query)) }) + .map(|mut session| { + // Sessions listed from this workspace's storage are + // owned by it even when their metadata predates IDs. + session + .workspace_id + .get_or_insert_with(|| workspace.workspace_id.clone()); + session + }) .collect(); remote_session_list_response( sessions, - Some(workspace_path_str.as_str()), - workspace_name.as_deref(), + Some(workspace.path.as_str()), + Some(workspace.name.as_str()), page_size, page_offset, ) @@ -1429,6 +1539,7 @@ where } } RemoteCommand::CreateSession { + workspace_id, agent_type, session_name, workspace_path, @@ -1446,59 +1557,52 @@ where _ => "Remote Code Session", }); - let explicit_workspace = workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty() && *path != "/"); - let use_default_assistant = is_claw && explicit_workspace.is_none(); - let binding_workspace = if let Some(path) = explicit_workspace { - Some(path.to_owned()) + let explicit = match resolve_remote_session_workspace( + host, + workspace_id.as_deref(), + workspace_path.as_deref(), + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await + { + Ok(workspace) => workspace, + Err(message) => return RemoteResponse::Error { message }, + }; + let use_default_assistant = is_claw && explicit.is_none(); + let workspace = if let Some(workspace) = explicit { + workspace } else if use_default_assistant { - match host.resolve_default_assistant_workspace_path().await { - Ok(path) => Some(path), + match host.resolve_default_assistant_workspace().await { + Ok(workspace) => workspace, Err(message) => return RemoteResponse::Error { message }, } } else { - None - }; - - let Some(binding_workspace) = binding_workspace else { return RemoteResponse::Error { - message: if is_claw { - "Failed to get or create assistant workspace".to_string() - } else { - "No workspace is open on the remote device; select a recent workspace or create one first".to_string() - }, + message: NO_REMOTE_WORKSPACE_MESSAGE.to_string(), }; }; let request = build_remote_session_create_request( session_name, agent, - Some(binding_workspace.clone()), + Some(workspace.path.clone()), RemoteSessionWorkspaceIdentity::new( - if use_default_assistant { - None - } else { - remote_connection_id.clone() - }, - if use_default_assistant { - None - } else { - remote_ssh_host.clone() - }, - ), + workspace.remote_connection_id.clone(), + workspace.remote_ssh_host.clone(), + ) + .with_workspace_id(Some(workspace.workspace_id.clone())), RemoteConnectSubmissionSource::Relay, ); match host.create_session(request).await { Ok(session_id) => RemoteResponse::SessionCreated { session_id, - workspace_path: Some(binding_workspace), - remote_connection_id: if use_default_assistant { - None - } else { - remote_connection_id.clone().filter(|id| !id.is_empty()) - }, + workspace_id: Some(workspace.workspace_id), + workspace_path: Some(workspace.path), + remote_connection_id: workspace + .remote_connection_id + .filter(|id| !id.is_empty()), + remote_ssh_host: workspace.remote_ssh_host.filter(|host| !host.is_empty()), }, Err(message) => RemoteResponse::Error { message }, } @@ -2020,6 +2124,8 @@ pub struct ImageAttachment { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub session_id: String, pub name: String, #[serde( @@ -2338,6 +2444,8 @@ pub fn build_remote_chat_messages(turns: Vec) -> Vec, pub path: String, pub name: String, pub last_opened: String, @@ -2351,6 +2459,8 @@ pub struct RecentWorkspaceEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AssistantEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub path: String, pub name: String, pub assistant_id: Option, @@ -2421,6 +2531,9 @@ pub enum RemoteCommand { GetWorkspaceInfo, ListRecentWorkspaces, SetWorkspace { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + #[serde(default)] path: String, #[serde(default)] remote_connection_id: Option, @@ -2429,9 +2542,14 @@ pub enum RemoteCommand { }, ListAssistants, SetAssistant { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + #[serde(default)] path: String, }, ListSessions { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, workspace_path: Option, #[serde(default)] remote_connection_id: Option, @@ -2442,6 +2560,8 @@ pub enum RemoteCommand { query: Option, }, CreateSession { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, agent_type: Option, session_name: Option, workspace_path: Option, @@ -2547,6 +2667,10 @@ pub enum RemoteCommand { ReadFileChunk { path: String, session_id: Option, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + /// Legacy explicit file workspace for pre-ID controllers. #[serde(default)] workspace_path: Option, #[serde(default)] @@ -2557,6 +2681,10 @@ pub enum RemoteCommand { GetFileInfo { path: String, session_id: Option, + /// Owning workspace ID; authoritative when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, + /// Legacy explicit file workspace for pre-ID controllers. #[serde(default)] workspace_path: Option, #[serde(default)] @@ -2630,6 +2758,8 @@ pub enum RemoteResponse { key: String, }, WorkspaceInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, has_workspace: bool, path: Option, project_name: Option, @@ -2653,6 +2783,8 @@ pub enum RemoteResponse { opened_workspaces: Option>, }, WorkspaceUpdated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, success: bool, path: Option, project_name: Option, @@ -2666,6 +2798,8 @@ pub enum RemoteResponse { assistants: Vec, }, AssistantUpdated { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, success: bool, path: Option, name: Option, @@ -2677,10 +2811,16 @@ pub enum RemoteResponse { }, SessionCreated { session_id: String, + /// Owning workspace record ID; authoritative for controllers that pin + /// the session to a workspace. Absent only from pre-ID hosts. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + remote_ssh_host: Option, }, ModelCatalog { catalog: RemoteModelCatalog, @@ -2715,6 +2855,8 @@ pub enum RemoteResponse { session_id: String, }, InitialSync { + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, has_workspace: bool, #[serde(skip_serializing_if = "Option::is_none")] path: Option, @@ -2796,6 +2938,10 @@ pub enum RemoteResponse { /// Device-to-device: info response from a peer device. DeviceInfo { device_name: Option, + /// Active workspace record ID. Its presence also tells a controller + /// that this device accepts ID-only workspace references. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] workspace_kind: Option, @@ -4163,6 +4309,7 @@ mod tests { impl RemoteWorkspaceRuntimeHost for FakeWorkspaceHost { async fn current_workspace(&self) -> Option { Some(RemoteWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/workspace/project".to_string(), name: "project".to_string(), git_branch: Some("main".to_string()), @@ -4175,6 +4322,7 @@ mod tests { async fn recent_workspaces(&self) -> Vec { vec![RemoteRecentWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/workspace/project".to_string(), name: "project".to_string(), last_opened: "2026-05-29T00:00:00Z".to_string(), @@ -4188,6 +4336,7 @@ mod tests { &self, ) -> Result>, String> { Ok(Some(vec![RemoteRecentWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/assistant/workspace".into(), name: "Mina".into(), last_opened: "2026-05-29T00:00:00Z".into(), @@ -4204,6 +4353,7 @@ mod tests { _remote_ssh_host: Option<&str>, ) -> Result { Ok(RemoteWorkspaceUpdate { + workspace_id: "test-workspace".to_string(), path: path.to_string(), name: "opened".to_string(), remote_connection_id: None, @@ -4213,6 +4363,7 @@ mod tests { async fn assistant_workspaces(&self) -> Vec { vec![RemoteAssistantWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "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/workspace/assistant".to_string(), name: "assistant".to_string(), assistant_id: None, @@ -4224,6 +4375,7 @@ mod tests { path: &str, ) -> Result { Ok(RemoteWorkspaceUpdate { + workspace_id: "test-workspace".to_string(), path: path.to_string(), name: "assistant".to_string(), remote_connection_id: None, @@ -4270,6 +4422,38 @@ mod tests { ); } + #[tokio::test] + async fn explicit_workspace_id_never_falls_back_to_legacy_path_selection() { + let command: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_workspace", "workspace_id": "missing-id", "path": "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/workspace/project" + })) + .unwrap(); + let response = handle_remote_workspace_command(&FakeWorkspaceHost, &command).await; + assert!(matches!( + response, + RemoteResponse::WorkspaceUpdated { success: false, .. } + )); + let id_only: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_workspace", "workspace_id": "known-id" + })) + .unwrap(); + assert!(matches!( + id_only, + RemoteCommand::SetWorkspace { + workspace_id: Some(_), + .. + } + )); + let legacy: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_workspace", "path": "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/workspace/project" + })) + .unwrap(); + assert!(matches!( + handle_remote_workspace_command(&FakeWorkspaceHost, &legacy).await, + RemoteResponse::WorkspaceUpdated { success: true, .. } + )); + } + #[tokio::test] async fn remote_workspace_handler_preserves_response_shapes() { let host = FakeWorkspaceHost; @@ -4277,6 +4461,7 @@ mod tests { assert_eq!( handle_remote_workspace_command(&host, &RemoteCommand::GetWorkspaceInfo).await, RemoteResponse::WorkspaceInfo { + workspace_id: Some("test-workspace".to_string()), has_workspace: true, path: Some("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/workspace/project".to_string()), project_name: Some("project".to_string()), @@ -4293,6 +4478,7 @@ mod tests { handle_remote_workspace_command( &host, &RemoteCommand::SetWorkspace { + workspace_id: None, path: "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/workspace/next".to_string(), remote_connection_id: None, remote_ssh_host: None, @@ -4300,6 +4486,7 @@ mod tests { ) .await, RemoteResponse::WorkspaceUpdated { + workspace_id: Some("test-workspace".to_string()), success: true, path: Some("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/workspace/next".to_string()), project_name: Some("opened".to_string()), @@ -4319,8 +4506,72 @@ mod tests { history_error: Option, } + fn fake_workspace( + workspace_id: &str, + path: &str, + kind: RemoteWorkspaceKind, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> RemoteWorkspaceFacts { + RemoteWorkspaceFacts { + workspace_id: workspace_id.to_string(), + path: path.to_string(), + name: path.rsplit('/').next().unwrap_or(path).to_string(), + git_branch: None, + kind, + assistant_id: None, + remote_connection_id: remote_connection_id.map(str::to_string), + remote_ssh_host: remote_ssh_host.map(str::to_string), + } + } + #[async_trait::async_trait] impl RemoteSessionRuntimeHost for FakeSessionHost { + async fn workspace_by_id( + &self, + workspace_id: &str, + ) -> Result { + match workspace_id { + "workspace-project" => Ok(fake_workspace( + "workspace-project", + "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/workspace/project", + RemoteWorkspaceKind::Remote, + Some("conn-1"), + Some("host-1"), + )), + _ => Err(format!("Workspace {workspace_id} is unavailable")), + } + } + + async fn resolve_legacy_workspace( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Result, String> { + match (workspace_path, remote_connection_id) { + ("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/workspace/project", Some("conn-1")) => Ok(Some(fake_workspace( + "workspace-project", + "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/workspace/project", + RemoteWorkspaceKind::Remote, + Some("conn-1"), + remote_ssh_host, + ))), + ("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/workspace/selected-assistant", _) => Ok(Some(fake_workspace( + "workspace-selected-assistant", + "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/workspace/selected-assistant", + RemoteWorkspaceKind::Remote, + remote_connection_id, + remote_ssh_host, + ))), + ("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/workspace/ambiguous", _) => Err( + "Workspace path is ambiguous; select a workspace by its ID or saved SSH connection" + .to_string(), + ), + _ => Ok(None), + } + } + async fn list_session_metadata( &self, _workspace_path: &Path, @@ -4332,6 +4583,7 @@ mod tests { .push(workspace_identity); Ok(vec![ RemoteSessionMetadata { + workspace_id: None, session_id: "session-a".to_string(), name: "keep me".to_string(), agent_type: "Standard".to_string(), @@ -4342,6 +4594,7 @@ mod tests { relationship_kind: None, }, RemoteSessionMetadata { + workspace_id: None, session_id: "session-b".to_string(), name: "other".to_string(), agent_type: "Standard".to_string(), @@ -4354,8 +4607,16 @@ mod tests { ]) } - async fn resolve_default_assistant_workspace_path(&self) -> Result { - Ok("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/workspace/assistant".to_string()) + async fn resolve_default_assistant_workspace( + &self, + ) -> Result { + Ok(fake_workspace( + "workspace-assistant", + "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/workspace/assistant", + RemoteWorkspaceKind::Assistant, + None, + None, + )) } async fn create_session( @@ -4465,6 +4726,7 @@ mod tests { let list = handle_remote_session_command( &host, &RemoteCommand::ListSessions { + workspace_id: None, workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), @@ -4484,8 +4746,18 @@ mod tests { sessions[0].workspace_path.as_deref(), Some("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/workspace/project") ); + // Sessions listed from the resolved workspace storage are pinned to + // its record ID even when their metadata predates workspace IDs. + assert_eq!( + sessions[0].workspace_id.as_deref(), + Some("workspace-project") + ); { let list_identities = host.list_identities.lock().unwrap(); + assert_eq!( + list_identities[0].workspace_id.as_deref(), + Some("workspace-project") + ); assert_eq!( list_identities[0].remote_connection_id.as_deref(), Some("conn-1") @@ -4499,6 +4771,7 @@ mod tests { let created = handle_remote_session_command( &host, &RemoteCommand::CreateSession { + workspace_id: None, agent_type: Some("Cowork".to_string()), session_name: None, workspace_path: Some("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/workspace/project".to_string()), @@ -4511,13 +4784,19 @@ mod tests { created, RemoteResponse::SessionCreated { session_id: "created-session".to_string(), + workspace_id: Some("workspace-project".to_string()), workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: Some("host-1".to_string()), } ); let created_requests = host.created_requests.lock().unwrap(); assert_eq!(created_requests[0].session_name, "Remote Cowork Session"); assert_eq!(created_requests[0].agent_type, "Cowork"); + assert_eq!( + created_requests[0].workspace_id.as_deref(), + Some("workspace-project") + ); assert_eq!( created_requests[0].workspace_path.as_deref(), Some("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/workspace/project") @@ -4532,6 +4811,66 @@ mod tests { ); } + #[tokio::test] + async fn path_only_session_commands_resolve_through_the_legacy_workspace_resolver() { + let host = FakeSessionHost::default(); + + // A path no record owns is a loud error, never a raw storage key. + let unknown = handle_remote_session_command( + &host, + &RemoteCommand::ListSessions { + workspace_id: None, + workspace_path: Some("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/workspace/unknown".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + limit: None, + offset: None, + query: None, + }, + ) + .await; + let RemoteResponse::Error { message } = unknown else { + panic!("unknown path must not list sessions"); + }; + assert!(message.contains("not an open workspace"), "{message}"); + assert!(host.list_identities.lock().unwrap().is_empty()); + + // An ambiguous path is refused instead of picking one record. + let ambiguous = handle_remote_session_command( + &host, + &RemoteCommand::CreateSession { + workspace_id: None, + agent_type: None, + session_name: None, + workspace_path: Some("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/workspace/ambiguous".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await; + let RemoteResponse::Error { message } = ambiguous else { + panic!("ambiguous path must not create a session"); + }; + assert!(message.contains("ambiguous"), "{message}"); + assert!(host.created_requests.lock().unwrap().is_empty()); + + // An explicit ID never falls back to the path it is sent with. + let stale = handle_remote_session_command( + &host, + &RemoteCommand::CreateSession { + workspace_id: Some("workspace-missing".to_string()), + agent_type: None, + session_name: None, + workspace_path: Some("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/workspace/project".to_string()), + remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: None, + }, + ) + .await; + assert!(matches!(stale, RemoteResponse::Error { .. })); + assert!(host.created_requests.lock().unwrap().is_empty()); + } + #[tokio::test] async fn claw_creation_preserves_explicit_workspace_and_scopes_default_to_local() { let host = FakeSessionHost::default(); @@ -4539,6 +4878,7 @@ mod tests { let response = handle_remote_session_command( &host, &RemoteCommand::CreateSession { + workspace_id: None, agent_type: Some("Claw".into()), session_name: None, workspace_path: explicit.map(str::to_string), @@ -4548,6 +4888,7 @@ mod tests { ) .await; let RemoteResponse::SessionCreated { + workspace_id, workspace_path, remote_connection_id, .. @@ -4559,6 +4900,14 @@ mod tests { workspace_path.as_deref(), Some(explicit.unwrap_or("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/workspace/assistant")) ); + assert_eq!( + workspace_id.as_deref(), + Some(if explicit.is_some() { + "workspace-selected-assistant" + } else { + "workspace-assistant" + }) + ); assert_eq!( remote_connection_id.as_deref(), explicit.map(|_| "other-workspace-ssh") diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs index 769007656a..3668011411 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs @@ -23,6 +23,9 @@ pub enum BotDisplayMode { /// string. Deserialization still accepts that form and upgrades it in memory. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BotWorkspaceRef { + /// Absent only in pre-ID persisted state or responses from legacy peers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -33,6 +36,7 @@ pub struct BotWorkspaceRef { impl BotWorkspaceRef { pub fn local(path: impl Into) -> Self { Self { + workspace_id: None, path: path.into(), remote_connection_id: None, remote_ssh_host: None, @@ -45,6 +49,7 @@ impl BotWorkspaceRef { remote_ssh_host: Option, ) -> Self { Self { + workspace_id: None, path: path.into(), remote_connection_id: remote_connection_id .map(|value| value.trim().to_string()) @@ -55,6 +60,11 @@ impl BotWorkspaceRef { } } + pub fn with_workspace_id(mut self, workspace_id: Option) -> Self { + self.workspace_id = workspace_id; + self + } + pub fn path(&self) -> &str { &self.path } @@ -97,6 +107,7 @@ where /// One selectable workspace row in the bot `/switch` picker. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BotWorkspaceChoice { + pub workspace_id: Option, pub path: String, pub name: String, pub remote_connection_id: Option, @@ -111,6 +122,7 @@ impl BotWorkspaceChoice { remote_ssh_host: Option, ) -> Self { Self { + workspace_id: None, path: path.into(), name: name.into(), remote_connection_id, @@ -118,12 +130,18 @@ impl BotWorkspaceChoice { } } + pub fn with_workspace_id(mut self, workspace_id: Option) -> Self { + self.workspace_id = workspace_id; + self + } + pub fn to_workspace_ref(&self) -> BotWorkspaceRef { BotWorkspaceRef::with_identity( self.path.clone(), self.remote_connection_id.clone(), self.remote_ssh_host.clone(), ) + .with_workspace_id(self.workspace_id.clone()) } } @@ -137,8 +155,11 @@ pub struct BotChatState { skip_serializing_if = "Option::is_none" )] pub current_workspace: Option, + /// Legacy path retained for old readers; selection uses current_assistant_id. pub current_assistant: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_assistant_name: Option, pub current_session_id: Option, #[serde(default)] @@ -201,12 +222,23 @@ pub struct RemoteBotTarget { } impl BotChatState { + pub fn assistant_workspace_ref(&self) -> Option { + if self.current_assistant_id.is_none() && self.current_assistant.is_none() { + return None; + } + let mut reference = + BotWorkspaceRef::local(self.current_assistant.clone().unwrap_or_default()); + reference.workspace_id = self.current_assistant_id.clone(); + Some(reference) + } + pub fn new(chat_id: String) -> Self { Self { chat_id, paired: false, current_workspace: None, current_assistant: None, + current_assistant_id: None, current_assistant_name: None, current_session_id: None, display_mode: BotDisplayMode::Assistant, @@ -341,6 +373,7 @@ impl BotChatState { fn clear_device_scoped_context(&mut self) { self.current_workspace = None; self.current_assistant = None; + self.current_assistant_id = None; self.current_assistant_name = None; self.current_session_id = None; } @@ -480,6 +513,18 @@ pub type BotMessageSender = mod tests { use super::*; + #[test] + fn assistant_id_survives_storage_and_overrides_stale_path() { + let mut state = BotChatState::new("chat".into()); + state.current_assistant_id = Some("assistant-id".into()); + state.current_assistant = Some("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/old/folder".into()); + let restored: BotChatState = + serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap(); + let reference = restored.assistant_workspace_ref().unwrap(); + assert_eq!(reference.workspace_id.as_deref(), Some("assistant-id")); + assert_eq!(reference.path, "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/old/folder"); + } + #[test] fn active_workspace_path_prefers_workspace_then_assistant() { let mut state = BotChatState::new("chat".into()); diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs index 80f16590c2..ed5fb4d3e8 100644 --- a/src/crates/services/services-integrations/src/remote_persistence.rs +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -369,6 +369,8 @@ pub enum BotDisplayModeRecord { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BotWorkspaceRefRecord { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -392,6 +394,7 @@ where None => Ok(None), Some(Raw::Path(path)) if path.trim().is_empty() => Ok(None), Some(Raw::Path(path)) => Ok(Some(BotWorkspaceRefRecord { + workspace_id: None, path, remote_connection_id: None, remote_ssh_host: None, @@ -411,8 +414,11 @@ pub struct BotChatStateRecord { skip_serializing_if = "Option::is_none" )] pub current_workspace: Option, + /// Legacy path retained for old readers; selection uses current_assistant_id. pub current_assistant: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_assistant_name: Option, pub current_session_id: Option, #[serde(default)] diff --git a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs index 25fc1836c0..d6c8f06ebb 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -809,6 +809,7 @@ impl RemoteFileService { #[derive(Debug, Clone)] pub struct RemoteTerminalSession { + pub workspace_id: Option, pub id: String, pub name: String, pub connection_id: String, @@ -856,6 +857,8 @@ impl RemoteTerminalManager { Err(unsupported()) } + pub async fn set_workspace_id(&self, _session_id: &str, _workspace_id: Option) {} + pub async fn get_session(&self, _session_id: &str) -> Option { None } diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs index 05f33cfc3c..023a5dbe2a 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs @@ -22,6 +22,7 @@ const REMOTE_PWD_PROBE_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] pub struct RemoteTerminalSession { + pub workspace_id: Option, pub id: String, pub name: String, pub connection_id: String, @@ -203,6 +204,7 @@ impl RemoteTerminalManager { let initial_cd = cwd.clone(); let session = RemoteTerminalSession { + workspace_id: None, id: session_id.clone(), name, connection_id: connection_id.to_string(), @@ -377,6 +379,7 @@ impl RemoteTerminalManager { let writer = spawned.writer; let controller = spawned.controller; let session = RemoteTerminalSession { + workspace_id: None, id: session_id.clone(), name, connection_id: connection_id.to_string(), @@ -445,6 +448,12 @@ impl RemoteTerminalManager { Ok(CreateSessionResult { session, output_rx }) } + pub async fn set_workspace_id(&self, session_id: &str, workspace_id: Option) { + if let Some(session) = self.sessions.write().await.get_mut(session_id) { + session.workspace_id = workspace_id; + } + } + pub async fn get_session(&self, session_id: &str) -> Option { self.sessions.read().await.get(session_id).cloned() } diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs index 4302b51378..ea4095805d 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs @@ -44,10 +44,3 @@ impl RemoteWorkspaceSearchService { Err(unsupported()) } } - -pub async fn remote_workspace_search_service_for_path( - _root_path: &str, - _preferred_connection_id: Option, -) -> Result { - Err(unsupported()) -} diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index 879b1a038f..424f04f562 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -508,6 +508,9 @@ pub enum ReviewSubmitEvent { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformCreatePullRequestRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: Option, pub title: String, @@ -520,6 +523,9 @@ pub struct ReviewPlatformCreatePullRequestRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformReplyToThreadRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -530,6 +536,9 @@ pub struct ReviewPlatformReplyToThreadRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformSubmitReviewRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -540,6 +549,9 @@ pub struct ReviewPlatformSubmitReviewRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformResolveThreadRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -550,6 +562,9 @@ pub struct ReviewPlatformResolveThreadRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformApprovalRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, @@ -559,12 +574,51 @@ pub struct ReviewPlatformApprovalRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformRequestChangesRequest { + /// Owning workspace ID; authoritative for local/remote routing when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, pub repository_path: String, pub remote_id: String, pub pull_request_id: String, pub body: String, } +impl ReviewPlatformCreatePullRequestRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + +impl ReviewPlatformReplyToThreadRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + +impl ReviewPlatformSubmitReviewRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + +impl ReviewPlatformResolveThreadRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + +impl ReviewPlatformApprovalRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + +impl ReviewPlatformRequestChangesRequest { + pub fn repository(&self) -> ReviewRepositoryLocator { + ReviewRepositoryLocator::new(self.workspace_id.clone(), self.repository_path.clone()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformActionResult { @@ -616,31 +670,80 @@ pub struct ReviewPlatformWorkspaceSnapshot { /// fetched over HTTP from the host running OpenBitFun. Remote SSH workspaces are /// therefore fully supported as long as the product runtime can execute those /// Git probes on the remote host, which is what this port injects. +/// Identifies the repository a review-platform operation runs against. +/// +/// `workspace_id` is the authoritative identity: it decides whether Git probes +/// run locally or on a remote host, and which connection carries them. +/// `repository_path` is only the IO operand (the directory the probes run in); +/// it never selects a workspace on its own once an ID is present. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewRepositoryLocator { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + pub repository_path: String, +} + +impl ReviewRepositoryLocator { + pub fn new(workspace_id: Option, repository_path: impl Into) -> Self { + Self { + workspace_id: workspace_id.filter(|id| !id.trim().is_empty()), + repository_path: repository_path.into(), + } + } + + /// Legacy locator for callers that predate workspace IDs. The host + /// classifier must resolve the path through its legacy-compat boundary. + pub fn legacy_path(repository_path: impl Into) -> Self { + Self::new(None, repository_path) + } +} + +/// Remote execution target resolved from a workspace ID. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewRemoteGitTarget { + pub workspace_id: String, + pub connection_id: String, +} + +/// How Git probes for one repository locator must execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewGitExecution { + Local, + Remote(ReviewRemoteGitTarget), +} + #[async_trait::async_trait] pub trait ReviewPlatformWorkspaceClassifier: Send + Sync { - /// True when `path` belongs to a remote workspace whose Git repository is - /// not reachable through the local filesystem. - async fn is_remote_workspace_path(&self, path: &str) -> bool; + /// Resolves whether the repository lives on the local filesystem or inside + /// a remote workspace, keyed by the owning workspace ID. Hosts may accept + /// a legacy path-only locator through their legacy-compat boundary, but + /// must never pick a remote connection from an ambiguous path. + async fn classify_repository( + &self, + repository: &ReviewRepositoryLocator, + ) -> Result; /// Executes a Git command inside a remote workspace and returns stdout. /// - /// Only called for paths where [`Self::is_remote_workspace_path`] returned - /// `true`. `workspace_path` identifies the remote connection; `current_dir` - /// is the remote directory the Git command must run in (it may differ from - /// `workspace_path` once the repository root has been resolved). + /// Only called for locators that [`Self::classify_repository`] resolved to + /// [`ReviewGitExecution::Remote`]. `current_dir` is the remote directory + /// the Git command must run in (it may differ from the locator path once + /// the repository root has been resolved). /// - /// The default implementation fails loudly so hosts that classify paths as - /// remote without wiring remote Git execution surface a clear error instead - /// of silently degrading. + /// The default implementation fails loudly so hosts that classify + /// repositories as remote without wiring remote Git execution surface a + /// clear error instead of silently degrading. async fn execute_remote_git_command( &self, - workspace_path: &str, + target: &ReviewRemoteGitTarget, current_dir: &str, args: &[&str], ) -> Result { let _ = (current_dir, args); Err(ReviewPlatformError::InvalidRepository(format!( - "Remote workspace Git execution is not wired for {workspace_path}" + "Remote workspace Git execution is not wired for workspace {}", + target.workspace_id ))) } } @@ -652,16 +755,13 @@ pub struct ReviewPlatformService { workspace_classifier: Arc, } -/// Resolved Git execution scope for one workspace path. +/// Resolved Git execution scope for one repository locator. #[derive(Debug, Clone)] struct WorkspaceGitScope { - /// Original workspace path; identifies the remote connection for remote - /// scopes (the repository root may sit above the registered workspace - /// root and would not resolve a connection on its own). - workspace_path: String, /// Git repository root the probes must run in. repository_root: String, - remote: bool, + /// Local process or remote execution keyed by the owning workspace ID. + execution: ReviewGitExecution, } /// Test-only classifier that treats every path as local. Production hosts @@ -674,8 +774,11 @@ struct LocalOnlyReviewPlatformWorkspaceClassifier; #[cfg(test)] #[async_trait::async_trait] impl ReviewPlatformWorkspaceClassifier for LocalOnlyReviewPlatformWorkspaceClassifier { - async fn is_remote_workspace_path(&self, _path: &str) -> bool { - false + async fn classify_repository( + &self, + _repository: &ReviewRepositoryLocator, + ) -> Result { + Ok(ReviewGitExecution::Local) } } @@ -853,43 +956,42 @@ impl ReviewPlatformService { &self.token_store_path } - async fn is_remote_workspace_path(&self, repository_path: &str) -> bool { - self.workspace_classifier - .is_remote_workspace_path(repository_path) - .await - } - - /// Resolves how Git probes must run for `repository_path` (local process - /// vs. remote execution through the injected workspace runtime). + /// Resolves how Git probes must run for `repository` (local process vs. + /// remote execution through the injected workspace runtime). async fn workspace_git_scope( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, ) -> Result { - if self.is_remote_workspace_path(repository_path).await { - let output = self - .workspace_classifier - .execute_remote_git_command( - repository_path, - repository_path, - &["rev-parse", "--show-toplevel"], - ) - .await?; - // Remote roots are POSIX paths on the remote host; never apply - // local (Windows) path normalization to them. - let root = parse_repository_root_output(&output)?; - return Ok(WorkspaceGitScope { - workspace_path: repository_path.to_string(), - repository_root: root, - remote: true, - }); + let execution = self + .workspace_classifier + .classify_repository(repository) + .await?; + match execution { + ReviewGitExecution::Remote(target) => { + let output = self + .workspace_classifier + .execute_remote_git_command( + &target, + &repository.repository_path, + &["rev-parse", "--show-toplevel"], + ) + .await?; + // Remote roots are POSIX paths on the remote host; never apply + // local (Windows) path normalization to them. + let root = parse_repository_root_output(&output)?; + Ok(WorkspaceGitScope { + repository_root: root, + execution: ReviewGitExecution::Remote(target), + }) + } + ReviewGitExecution::Local => { + let root = get_repository_root(&repository.repository_path).await?; + Ok(WorkspaceGitScope { + repository_root: root, + execution: ReviewGitExecution::Local, + }) + } } - - let root = get_repository_root(repository_path).await?; - Ok(WorkspaceGitScope { - workspace_path: repository_path.to_string(), - repository_root: root, - remote: false, - }) } async fn execute_scope_git_command( @@ -897,21 +999,22 @@ impl ReviewPlatformService { scope: &WorkspaceGitScope, args: &[&str], ) -> Result { - if scope.remote { - self.workspace_classifier - .execute_remote_git_command(&scope.workspace_path, &scope.repository_root, args) - .await - } else { - execute_git_command(&scope.repository_root, args).await + match &scope.execution { + ReviewGitExecution::Remote(target) => { + self.workspace_classifier + .execute_remote_git_command(target, &scope.repository_root, args) + .await + } + ReviewGitExecution::Local => execute_git_command(&scope.repository_root, args).await, } } pub async fn discover_remotes( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, ) -> Result, ReviewPlatformError> { let auth_tokens = self.load_stored_tokens().await?; - let scope = self.workspace_git_scope(repository_path).await?; + let scope = self.workspace_git_scope(repository).await?; self.discover_remotes_in_scope(&scope, &auth_tokens).await } @@ -965,13 +1068,13 @@ impl ReviewPlatformService { pub async fn workspace_snapshot( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, page: Option, per_page: Option, ) -> Result { self.workspace_snapshot_with_state( - repository_path, + repository, remote_id, page, per_page, @@ -982,23 +1085,23 @@ impl ReviewPlatformService { pub async fn workspace_snapshot_with_state( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, page: Option, per_page: Option, state: ReviewPlatformListState, ) -> Result { - self.workspace_snapshot_internal(repository_path, remote_id, page, per_page, true, state) + self.workspace_snapshot_internal(repository, remote_id, page, per_page, true, state) .await } pub async fn workspace_context( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, ) -> Result { self.workspace_snapshot_internal( - repository_path, + repository, remote_id, None, None, @@ -1010,7 +1113,7 @@ impl ReviewPlatformService { async fn workspace_snapshot_internal( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, page: Option, per_page: Option, @@ -1019,7 +1122,7 @@ impl ReviewPlatformService { ) -> Result { let pagination_request = PullRequestPagination::new(page, per_page); let auth_tokens = self.load_stored_tokens().await?; - let scope = self.workspace_git_scope(repository_path).await?; + let scope = self.workspace_git_scope(repository).await?; let root = scope.repository_root.clone(); let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let selected_remote = select_remote(&remotes, remote_id).cloned(); @@ -1185,12 +1288,12 @@ impl ReviewPlatformService { pub async fn pull_request_detail( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ) -> Result { let auth_tokens = self.load_stored_tokens().await?; - let scope = self.workspace_git_scope(repository_path).await?; + let scope = self.workspace_git_scope(repository).await?; let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let remote = remotes .into_iter() @@ -1207,12 +1310,12 @@ impl ReviewPlatformService { pub async fn pull_request_review_target( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ) -> Result { let ctx = self - .provider_context_for_repository(repository_path, Some(remote_id)) + .provider_context_for_repository(repository, Some(remote_id)) .await?; provider_for(ctx.remote.platform) .pull_request_review_target(&ctx, pull_request_id) @@ -1227,7 +1330,7 @@ impl ReviewPlatformService { issue_id: &str, page: Option, per_page: Option, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { let auth_tokens = self.load_stored_tokens().await?; let identity = ProviderIssueIdentity::new(platform, host, project_path, issue_id)?; @@ -1236,7 +1339,7 @@ impl ReviewPlatformService { identity.platform, &identity.host, &identity.project_path, - repository_path, + repository, &auth_tokens, ) .await?; @@ -1249,7 +1352,7 @@ impl ReviewPlatformService { host: &str, project_path: &str, pull_request_id: &str, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { let auth_tokens = self.load_stored_tokens().await?; let pull_request_id = normalize_provider_item_id(pull_request_id, "Pull request")?; @@ -1258,7 +1361,7 @@ impl ReviewPlatformService { platform, host, project_path, - repository_path, + repository, &auth_tokens, ) .await?; @@ -1278,7 +1381,7 @@ impl ReviewPlatformService { expected_head_revision: &str, file_path: &str, file_page_hint: Option, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, ) -> Result { let pull_request_id = normalize_provider_item_id(pull_request_id, "Pull request")?; let auth_tokens = self.load_stored_tokens().await?; @@ -1287,7 +1390,7 @@ impl ReviewPlatformService { platform, host, project_path, - repository_path, + repository, &auth_tokens, ) .await?; @@ -1305,7 +1408,7 @@ impl ReviewPlatformService { pub async fn pull_request_file_diff( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, expected_base_revision: &str, @@ -1314,7 +1417,7 @@ impl ReviewPlatformService { file_page_hint: Option, ) -> Result { let ctx = self - .provider_context_for_repository(repository_path, Some(remote_id)) + .provider_context_for_repository(repository, Some(remote_id)) .await?; provider_for(ctx.remote.platform) .pull_request_file_diff( @@ -1330,7 +1433,7 @@ impl ReviewPlatformService { pub async fn pull_request_detail_page( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, section: ReviewPlatformDetailSection, @@ -1338,7 +1441,7 @@ impl ReviewPlatformService { per_page: Option, ) -> Result { let ctx = self - .provider_context_for_repository(repository_path, Some(remote_id)) + .provider_context_for_repository(repository, Some(remote_id)) .await?; provider_for(ctx.remote.platform) .pull_request_detail_page( @@ -1352,14 +1455,14 @@ impl ReviewPlatformService { pub async fn pull_request_ci_log( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: &str, pull_request_id: &str, ci_item_id: &str, ci_item_name: &str, ) -> Result { let ctx = self - .provider_context_for_repository(repository_path, Some(remote_id)) + .provider_context_for_repository(repository, Some(remote_id)) .await?; provider_for(ctx.remote.platform) .pull_request_ci_log(&ctx, pull_request_id, ci_item_id, ci_item_name) @@ -1371,7 +1474,7 @@ impl ReviewPlatformService { request: ReviewPlatformCreatePullRequestRequest, ) -> Result { let ctx = self - .provider_context_for_repository(&request.repository_path, request.remote_id.as_deref()) + .provider_context_for_repository(&request.repository(), request.remote_id.as_deref()) .await?; provider_for(ctx.remote.platform) .create_pull_request(&ctx, &request) @@ -1384,7 +1487,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1399,7 +1502,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1414,7 +1517,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1429,7 +1532,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1444,7 +1547,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1459,7 +1562,7 @@ impl ReviewPlatformService { ) -> Result { let ctx = self .provider_context_for_repository( - &request.repository_path, + &request.repository(), Some(request.remote_id.as_str()), ) .await?; @@ -1470,11 +1573,11 @@ impl ReviewPlatformService { async fn provider_context_for_repository( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, remote_id: Option<&str>, ) -> Result { let auth_tokens = self.load_stored_tokens().await?; - let scope = self.workspace_git_scope(repository_path).await?; + let scope = self.workspace_git_scope(repository).await?; let remotes = self.discover_remotes_in_scope(&scope, &auth_tokens).await?; let remote = select_remote_for_action(&remotes, remote_id)?.clone(); if !remote.supported { @@ -1488,16 +1591,16 @@ impl ReviewPlatformService { platform: ReviewPlatformKind, host: &str, project_path: &str, - repository_path: Option<&str>, + repository: Option<&ReviewRepositoryLocator>, auth_tokens: &ReviewPlatformAuthTokens, ) -> Result { let host = normalize_provider_host(host)?; let project_path = normalize_project_path(platform, project_path)?; let trusted_remote = if auth_tokens.get(platform, &host).is_none() { - match repository_path { - Some(repository_path) => { + match repository { + Some(repository) => { self.repository_trusts_provider_identity( - repository_path, + repository, platform, &host, &project_path, @@ -1520,7 +1623,7 @@ impl ReviewPlatformService { async fn repository_trusts_provider_identity( &self, - repository_path: &str, + repository: &ReviewRepositoryLocator, platform: ReviewPlatformKind, host: &str, project_path: &str, @@ -1531,7 +1634,7 @@ impl ReviewPlatformService { ) { return false; } - let Ok(scope) = self.workspace_git_scope(repository_path).await else { + let Ok(scope) = self.workspace_git_scope(repository).await else { return false; }; let Ok(output) = self @@ -7758,8 +7861,17 @@ mod tests { #[async_trait::async_trait] impl ReviewPlatformWorkspaceClassifier for AlwaysRemoteWorkspace { - async fn is_remote_workspace_path(&self, _path: &str) -> bool { - true + async fn classify_repository( + &self, + repository: &ReviewRepositoryLocator, + ) -> Result { + Ok(ReviewGitExecution::Remote(ReviewRemoteGitTarget { + workspace_id: repository + .workspace_id + .clone() + .unwrap_or_else(|| "remote-workspace".to_string()), + connection_id: "conn-1".to_string(), + })) } } @@ -7772,18 +7884,27 @@ mod tests { #[async_trait::async_trait] impl ReviewPlatformWorkspaceClassifier for RecordingRemoteGitRuntime { - async fn is_remote_workspace_path(&self, _path: &str) -> bool { - true + async fn classify_repository( + &self, + repository: &ReviewRepositoryLocator, + ) -> Result { + Ok(ReviewGitExecution::Remote(ReviewRemoteGitTarget { + workspace_id: repository + .workspace_id + .clone() + .unwrap_or_else(|| "remote-workspace".to_string()), + connection_id: "conn-1".to_string(), + })) } async fn execute_remote_git_command( &self, - workspace_path: &str, + target: &ReviewRemoteGitTarget, current_dir: &str, args: &[&str], ) -> Result { self.commands.lock().expect("commands lock").push(( - workspace_path.to_string(), + target.workspace_id.clone(), current_dir.to_string(), args.iter().map(|arg| arg.to_string()).collect(), )); @@ -8297,7 +8418,15 @@ mod tests { let service = ReviewPlatformService::new(path, Arc::new(AlwaysRemoteWorkspace)); let error = service - .workspace_snapshot("not-a-git-repository", None, None, None) + .workspace_snapshot( + &ReviewRepositoryLocator::new( + Some("ws-remote".to_string()), + "not-a-git-repository", + ), + None, + None, + None, + ) .await .expect_err("remote workspace without remote git wiring must fail loudly"); @@ -8316,7 +8445,13 @@ mod tests { let service = ReviewPlatformService::new(path, runtime.clone()); let snapshot = service - .workspace_context("/srv/projects/openbitfun", None) + .workspace_context( + &ReviewRepositoryLocator::new( + Some("ws-remote".to_string()), + "/srv/projects/openbitfun", + ), + None, + ) .await .expect("remote workspace context should resolve through remote git"); @@ -8340,17 +8475,17 @@ mod tests { commands, vec![ ( - "/srv/projects/openbitfun".to_string(), + "ws-remote".to_string(), "/srv/projects/openbitfun".to_string(), vec!["rev-parse".to_string(), "--show-toplevel".to_string()], ), ( - "/srv/projects/openbitfun".to_string(), + "ws-remote".to_string(), "/srv/projects".to_string(), vec!["remote".to_string(), "-v".to_string()], ), ], - "git probes must run through the remote runtime, keyed by the workspace path" + "git probes must run through the remote runtime, keyed by the workspace ID" ); } @@ -8363,7 +8498,10 @@ mod tests { assert!( service .repository_trusts_provider_identity( - "/srv/projects/openbitfun", + &ReviewRepositoryLocator::new( + Some("ws-remote".to_string()), + "/srv/projects/openbitfun", + ), ReviewPlatformKind::Gitlab, "gitlab.com", "example/repo", @@ -8398,7 +8536,7 @@ mod tests { let service = ReviewPlatformService::new_local_only(path.clone()); let snapshot = service - .workspace_context(repository_path, None) + .workspace_context(&ReviewRepositoryLocator::legacy_path(repository_path), None) .await .expect("workspace context should not call the provider list API"); @@ -10203,7 +10341,7 @@ mod tests { .expect("exact GitLab authority should be stored"); let remotes = service - .discover_remotes(repository_path) + .discover_remotes(&ReviewRepositoryLocator::legacy_path(repository_path)) .await .expect("registered internal remote should be discovered"); @@ -10248,12 +10386,12 @@ mod tests { ReviewPlatformKind::Gitlab, "gitlab.example.internal", "group/repo", - Some( + Some(&ReviewRepositoryLocator::legacy_path( repository .path() .to_str() .expect("repository path should be UTF-8"), - ), + )), &tokens, ) .await @@ -10297,12 +10435,12 @@ mod tests { ReviewPlatformKind::Gitlab, "gitlab.example.internal", "group/repo", - Some( + Some(&ReviewRepositoryLocator::legacy_path( repository .path() .to_str() .expect("repository path should be UTF-8"), - ), + )), &tokens, ) .await; diff --git a/src/crates/services/services-integrations/src/review_platform/gitee.rs b/src/crates/services/services-integrations/src/review_platform/gitee.rs index 5576472b5b..1f6fb30247 100644 --- a/src/crates/services/services-integrations/src/review_platform/gitee.rs +++ b/src/crates/services/services-integrations/src/review_platform/gitee.rs @@ -842,6 +842,7 @@ impl ReviewProvider for GiteeProvider { self.approve_pull_request( ctx, &ReviewPlatformApprovalRequest { + workspace_id: request.workspace_id.clone(), repository_path: request.repository_path.clone(), remote_id: request.remote_id.clone(), pull_request_id: request.pull_request_id.clone(), diff --git a/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs b/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs index 05750b920e..ad171b1006 100644 --- a/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs +++ b/src/crates/services/services-integrations/src/review_platform/gitee/tests.rs @@ -994,6 +994,7 @@ async fn create_preserves_fork_head_and_draft_without_extra_mutations() { .create_pull_request( &ctx, &ReviewPlatformCreatePullRequestRequest { + workspace_id: None, repository_path: "unused".into(), remote_id: None, title: "A change".into(), @@ -1016,6 +1017,7 @@ async fn create_preserves_fork_head_and_draft_without_extra_mutations() { fn approval(body: Option<&str>) -> ReviewPlatformApprovalRequest { ReviewPlatformApprovalRequest { + workspace_id: None, repository_path: "unused".into(), remote_id: "origin".into(), pull_request_id: "69".into(), @@ -1084,6 +1086,7 @@ async fn revocation_resets_only_the_current_review_and_unsupported_writes_fail_c .submit_review( &ctx, &ReviewPlatformSubmitReviewRequest { + workspace_id: None, repository_path: "unused".into(), remote_id: "origin".into(), pull_request_id: "69".into(), diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 00465be120..6bb57c2fc1 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -745,6 +745,7 @@ impl RecordingDialogHost { remote_ssh_host: &str, ) -> Self { self.binding_workspace = Some(RemoteDialogWorkspaceBinding { + workspace_id: None, workspace_path: workspace_path.to_string(), remote_connection_id: Some(remote_connection_id.to_string()), remote_ssh_host: Some(remote_ssh_host.to_string()), @@ -1002,6 +1003,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { async fn handle_workspace_command(&self, _command: &RemoteCommand) -> RemoteResponse { self.events.lock().unwrap().push("workspace".to_string()); RemoteResponse::WorkspaceInfo { + workspace_id: None, has_workspace: false, path: None, project_name: None, @@ -1018,8 +1020,10 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { self.events.lock().unwrap().push("session".to_string()); RemoteResponse::SessionCreated { session_id: "session-created".to_string(), + workspace_id: None, workspace_path: None, remote_connection_id: None, + remote_ssh_host: None, } } @@ -1298,6 +1302,7 @@ async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { &RemoteCommand::GetFileInfo { path: "README.md".to_string(), session_id: None, + workspace_id: None, workspace_path: None, remote_connection_id: None, }, @@ -1920,6 +1925,7 @@ impl RemoteWorkspaceFileRuntimeHost for UnavailableSessionFileHost { session: Option<&str>, _: Option<&str>, _: Option<&str>, + _: Option<&str>, _: u64, _: u64, ) -> Result, String> { @@ -1933,6 +1939,7 @@ impl RemoteWorkspaceFileRuntimeHost for UnavailableSessionFileHost { session: Option<&str>, _: Option<&str>, _: Option<&str>, + _: Option<&str>, ) -> Result, String> { assert_eq!(session, Some("remote-session")); Err("Session host is offline".into()) @@ -1953,12 +1960,14 @@ async fn remote_connect_file_provider_errors_never_fall_back_to_local_files() { session_id: session_id.clone(), offset: 0, limit: 3, + workspace_id: None, workspace_path: None, remote_connection_id: None, }, RemoteCommand::GetFileInfo { path, session_id, + workspace_id: None, workspace_path: None, remote_connection_id: None, }, @@ -1983,12 +1992,14 @@ impl RemoteWorkspaceFileRuntimeHost for ExplicitFileWorkspaceHost { &self, _: &str, session: Option<&str>, + workspace_id: Option<&str>, workspace: Option<&str>, connection: Option<&str>, _: u64, _: u64, ) -> Result, String> { assert_eq!(session, None); + assert_eq!(workspace_id, None); assert_eq!(workspace, Some("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/captured/workspace")); assert_eq!(connection, Some("saved-runtime-profile")); Ok(None) @@ -1997,16 +2008,74 @@ impl RemoteWorkspaceFileRuntimeHost for ExplicitFileWorkspaceHost { &self, _: &str, session: Option<&str>, + workspace_id: Option<&str>, workspace: Option<&str>, connection: Option<&str>, ) -> Result, String> { assert_eq!(session, None); + assert_eq!(workspace_id, None); assert_eq!(workspace, Some("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/captured/workspace")); assert_eq!(connection, Some("saved-runtime-profile")); Ok(None) } } +struct WorkspaceIdFileHost; + +#[async_trait::async_trait] +impl RemoteWorkspaceFileRuntimeHost for WorkspaceIdFileHost { + async fn resolve_remote_file_workspace_root(&self, _: Option<&str>) -> Option { + panic!("Workspace ID identity must never fall back to the selected local workspace") + } + async fn read_remote_file_chunk( + &self, + _: &str, + session: Option<&str>, + workspace_id: Option<&str>, + workspace: Option<&str>, + connection: Option<&str>, + _: u64, + _: u64, + ) -> Result, String> { + assert_eq!(session, None); + assert_eq!(workspace_id, Some("workspace-42")); + assert_eq!(workspace, None); + assert_eq!(connection, None); + Ok(None) + } + async fn remote_file_info( + &self, + _: &str, + session: Option<&str>, + workspace_id: Option<&str>, + workspace: Option<&str>, + connection: Option<&str>, + ) -> Result, String> { + assert_eq!(session, None); + assert_eq!(workspace_id, Some("workspace-42")); + assert_eq!(workspace, None); + assert_eq!(connection, None); + Ok(None) + } +} + +#[tokio::test] +async fn remote_connect_workspace_id_file_identity_is_forwarded_without_local_fallback() { + for name in ["read_file_chunk", "get_file_info"] { + let command: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": name, "path": "file.bin", "workspace_id": "workspace-42", + "offset": 0, "limit": 3 + })) + .unwrap(); + assert_eq!( + handle_remote_workspace_file_command(&WorkspaceIdFileHost, &command).await, + RemoteResponse::Error { + message: "This host cannot resolve an explicit file workspace".into() + } + ); + } +} + #[tokio::test] async fn remote_connect_explicit_file_workspace_is_forwarded_without_local_fallback() { for name in ["read_file_chunk", "get_file_info"] { @@ -2091,6 +2160,7 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { #[test] fn remote_connect_workspace_response_helpers_own_wire_shape() { let workspace = RemoteWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "D:/workspace/project".to_string(), name: "project".to_string(), git_branch: Some("main".to_string()), @@ -2114,6 +2184,7 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { assert_eq!( info_json["capabilities"], serde_json::json!([ + "workspace_id_references_v1", REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, @@ -2139,6 +2210,7 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { let recent_json = serde_json::to_value(remote_recent_workspaces_response(vec![ RemoteRecentWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: workspace.path.clone(), name: workspace.name.clone(), last_opened: "2026-05-25T00:00:00Z".to_string(), @@ -2162,6 +2234,7 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { let assistant_json = serde_json::to_value(remote_assistant_list_response(vec![ RemoteAssistantWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "D:/workspace/assistant".to_string(), name: "assistant".to_string(), assistant_id: Some("assistant-2".to_string()), @@ -2176,12 +2249,14 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { assert_eq!( remote_workspace_updated_response(Ok(RemoteWorkspaceUpdate { + workspace_id: "test-workspace".to_string(), path: "D:/workspace/project".to_string(), name: "project".to_string(), remote_connection_id: None, remote_ssh_host: None, })), RemoteResponse::WorkspaceUpdated { + workspace_id: Some("test-workspace".to_string()), success: true, path: Some("D:/workspace/project".to_string()), project_name: Some("project".to_string()), @@ -2193,6 +2268,7 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { assert_eq!( remote_assistant_updated_response(Err("open failed".to_string())), RemoteResponse::AssistantUpdated { + workspace_id: None, success: false, path: None, name: None, @@ -2262,6 +2338,7 @@ fn remote_connect_session_info_carries_child_lineage_to_clients() { fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { let metadata = vec![ RemoteSessionMetadata { + workspace_id: None, session_id: "session-1".to_string(), name: "first".to_string(), agent_type: "Standard".to_string(), @@ -2272,6 +2349,7 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { relationship_kind: None, }, RemoteSessionMetadata { + workspace_id: None, session_id: "session-2".to_string(), name: "second".to_string(), agent_type: "Cowork".to_string(), @@ -2282,6 +2360,7 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { relationship_kind: None, }, RemoteSessionMetadata { + workspace_id: None, session_id: "session-3".to_string(), name: "third".to_string(), agent_type: "Cowork".to_string(), @@ -2320,6 +2399,7 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { let initial = remote_initial_sync_response( Some(RemoteWorkspaceFacts { + workspace_id: "test-workspace".to_string(), path: "D:/workspace/project".to_string(), name: "project".to_string(), git_branch: Some("main".to_string()), @@ -2345,6 +2425,7 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { assert_eq!( initial_json["capabilities"], serde_json::json!([ + "workspace_id_references_v1", REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, @@ -2366,8 +2447,10 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { remote_session_created_response("session-new"), RemoteResponse::SessionCreated { session_id: "session-new".to_string(), + workspace_id: None, workspace_path: None, remote_connection_id: None, + remote_ssh_host: None, } ); assert_eq!( @@ -2549,6 +2632,7 @@ fn remote_connect_command_wire_shape_lives_in_owner_contract() { assert_eq!(cancel["turn_id"], "turn-1"); let list = serde_json::to_value(RemoteCommand::ListSessions { + workspace_id: None, workspace_path: Some("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/workspace/project".to_string()), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), diff --git a/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs b/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs index 34020d72a7..b72e631f3c 100644 --- a/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs @@ -4,9 +4,7 @@ not(feature = "remote-ssh-concrete") ))] -use openbitfun_services_integrations::remote_ssh::workspace_search::disabled::{ - remote_workspace_search_service_for_path, RemoteWorkspaceSearchService, -}; +use openbitfun_services_integrations::remote_ssh::workspace_search::disabled::RemoteWorkspaceSearchService; fn assert_disabled_error(error: String) { assert!( @@ -28,10 +26,6 @@ async fn disabled_remote_workspace_search_returns_explicit_unsupported_errors() .unwrap_err(); assert_disabled_error(resolve_error); - let resolver_error = match remote_workspace_search_service_for_path("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/remote/repo", None).await - { - Ok(_) => panic!("disabled remote search resolver should return an unsupported error"), - Err(error) => error, - }; - assert_disabled_error(resolver_error); + assert_disabled_error(service.build_index("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/remote/repo").await.unwrap_err()); + assert_disabled_error(service.rebuild_index("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/remote/repo").await.unwrap_err()); } diff --git a/src/crates/services/terminal/src/api.rs b/src/crates/services/terminal/src/api.rs index c1838e91ef..53c48a3f5b 100644 --- a/src/crates/services/terminal/src/api.rs +++ b/src/crates/services/terminal/src/api.rs @@ -60,6 +60,12 @@ pub struct CreateSessionRequest { /// Response for session creation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionResponse { + #[serde( + default, + rename = "workspaceId", + skip_serializing_if = "Option::is_none" + )] + pub workspace_id: Option, /// Session ID pub id: String, /// Session name @@ -90,6 +96,12 @@ pub struct SessionResponse { impl From for SessionResponse { fn from(session: TerminalSession) -> Self { Self { + workspace_id: session + .metadata + .owner + .as_ref() + .filter(|owner| owner.owner_type == crate::session::OwnerType::Workspace) + .map(|owner| owner.id.clone()), id: session.id, name: session.name, shell_type: session.shell_type, @@ -121,6 +133,22 @@ mod workspace_origin_contract_tests { assert_eq!(serde_json::to_value(response).unwrap(), legacy); } + #[test] + fn workspace_identity_survives_directory_changes_and_round_trip() { + let mut response: SessionResponse = serde_json::from_value(json!({ + "id": "terminal", "workspaceId": "workspace-remote", "name": "Development", + "shellType": "Bash", "cwd": "/repo", "pid": null, + "status": "Running", "cols": 80, "rows": 24, "source": "manual" + })) + .unwrap(); + response.cwd = "/another-project".to_owned(); + let json = serde_json::to_value(response).unwrap(); + assert_eq!(json["workspaceId"], "workspace-remote"); + assert_eq!(json["cwd"], "/another-project"); + let restored: SessionResponse = serde_json::from_value(json).unwrap(); + assert_eq!(restored.workspace_id.as_deref(), Some("workspace-remote")); + } + #[test] fn initial_directory_survives_current_directory_changes_and_serialization() { let mut response: SessionResponse = serde_json::from_value(json!({ diff --git a/src/crates/services/terminal/src/session/binding.rs b/src/crates/services/terminal/src/session/binding.rs index a4fad97365..98fe039f23 100644 --- a/src/crates/services/terminal/src/session/binding.rs +++ b/src/crates/services/terminal/src/session/binding.rs @@ -25,6 +25,7 @@ use crate::{TerminalError, TerminalResult}; /// Options for creating a terminal session when binding #[derive(Debug, Clone, Default)] pub struct TerminalBindingOptions { + pub owner: Option, /// Working directory for the terminal pub working_directory: Option, /// Session ID @@ -122,6 +123,12 @@ impl TerminalSessionBinding { ) .await?; + if let Some(owner) = options.owner { + session_manager + .set_owner(&terminal_session_id, owner) + .await?; + } + // Establish the binding self.bindings .insert(owner_id.to_string(), terminal_session_id.clone()); diff --git a/src/crates/services/terminal/src/session/manager.rs b/src/crates/services/terminal/src/session/manager.rs index e86796f31c..5c8dbca917 100644 --- a/src/crates/services/terminal/src/session/manager.rs +++ b/src/crates/services/terminal/src/session/manager.rs @@ -1414,6 +1414,20 @@ impl SessionManager { ))) } + /// Attach an explicit owner; cwd never establishes ownership. + pub async fn set_owner( + &self, + session_id: &str, + owner: super::SessionOwner, + ) -> TerminalResult<()> { + let mut sessions = self.sessions.write().await; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_owned()))?; + session.metadata.owner = Some(owner); + Ok(()) + } + /// Get a session by ID pub async fn get_session(&self, session_id: &str) -> Option { let sessions = self.sessions.read().await; diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 7114a86850..19972449e8 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -57,7 +57,8 @@ pnpm --dir src/mobile-web run test:realtime-sync # ordered live updates, gap rec pnpm --dir src/mobile-web run test:account-login # account login without an online desktop pnpm --dir src/mobile-web run test:account-browser # real Chrome tabs, persistence, migration, races; simulated Relay pnpm --dir src/mobile-web run test:images # image preparation and upload limits -pnpm --dir src/mobile-web run test:workspace-identity # SSH host scope and legacy cache records +pnpm --dir src/mobile-web run test:workspace-identity # workspace ID wire projection, capability gate, legacy routing and cache records +pnpm --dir src/mobile-web run test:remote-cache-browser # real IndexedDB read of pre-ID session cache records pnpm --dir src/mobile-web run type-check pnpm run build:mobile-web ``` diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 582599ae13..1c5dbef5e4 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -13,7 +13,8 @@ "test:account-login": "node --test tests/account-login.test.mjs", "test:account-browser": "node --test tests/browser-account.test.mjs", "test:images": "node --test tests/image-compressor.test.mjs", - "test:workspace-identity": "node --test tests/workspace-identity.test.mjs", + "test:workspace-identity": "node --test tests/workspace-identity.test.mjs tests/workspace-routing.test.mjs", + "test:remote-cache-browser": "node --test tests/remote-cache-browser.test.mjs", "type-check": "tsc --noEmit", "build": "vite build", "preview": "vite preview", diff --git a/src/mobile-web/src/components/WorkspaceFiles.tsx b/src/mobile-web/src/components/WorkspaceFiles.tsx index df4c401d91..3401295217 100644 --- a/src/mobile-web/src/components/WorkspaceFiles.tsx +++ b/src/mobile-web/src/components/WorkspaceFiles.tsx @@ -17,8 +17,12 @@ const basename = (path: string) => path.replace(/\/$/, '').split('/').slice(-1)[ const parentPath = (path: string) => path.replace(/\/$/, '').replace(/\/[^/]*$/, '') || '/'; const joinPath = (directory: string, name: string) => name.startsWith('/') ? name : `${directory.replace(/\/$/, '')}/${name}`; -/** Paths and mutations belong to the selected runtime and its captured SSH connection. */ -export function WorkspaceFiles({ manager, workspace }: { manager: RemoteSessionManager; workspace: Pick }) { +/** + * Paths and mutations belong to the selected runtime workspace. A runtime + * workspace is addressed by `workspace_id`; a bare device location (no + * workspace) is addressed by its captured SSH connection and directory. + */ +export function WorkspaceFiles({ manager, workspace }: { manager: RemoteSessionManager; workspace: Pick }) { const { t, formatDate } = useI18n(); const [directory, setDirectory] = useState(workspace.path ?? ''); const [editingPath, setEditingPath] = useState(false); @@ -40,7 +44,11 @@ export function WorkspaceFiles({ manager, workspace }: { manager: RemoteSessionM const generation = useRef(0); const originalHash = useRef(); const dirty = file !== null && content !== savedContent; - const args = { remoteConnectionId: workspace.remote_connection_id ?? '', workspacePath: directory }; + const args = { + ...(workspace.workspace_id ? { workspaceId: workspace.workspace_id } : {}), + remoteConnectionId: workspace.remote_connection_id ?? '', + workspacePath: directory, + }; const digest = (text: string) => Array.from(sha256(new TextEncoder().encode(text)), byte => byte.toString(16).padStart(2, '0')).join(''); async function perform(operation: (ticket: number) => Promise) { const ticket = ++generation.current; @@ -80,7 +88,7 @@ export function WorkspaceFiles({ manager, workspace }: { manager: RemoteSessionM setUpload(null); setAction(null); setFile(null); transfer.current = undefined; setUploaded(0); void perform(() => list(workspace.path ?? '')); return () => { generation.current++; }; - }, [manager, workspace.path, workspace.remote_connection_id]); + }, [manager, workspace.workspace_id, workspace.path, workspace.remote_connection_id]); const crumbs = directory.split('/').filter(Boolean).map((label, index, all) => ({ label, path: `${directory.startsWith('/') ? '/' : ''}${all.slice(0, index + 1).join('/')}` })); const actionLabel = action?.kind === 'file' ? t('workspace.createFile') : action?.kind === 'folder' ? t('workspace.createFolder') : action?.kind === 'rename' ? t('workspace.renameEntry') : t('workspace.deleteEntry'); const sortOptions: { value: SortOrder; label: string }[] = [ @@ -153,7 +161,7 @@ export function WorkspaceFiles({ manager, workspace }: { manager: RemoteSessionM }); }}>{entry.name}{entry.lastModified && {modifiedLabel(entry.lastModified)}}
- {!entry.isDirectory && void perform(ticket => downloadRuntimeFile(manager, entry.path, { isCurrent: () => ticket === generation.current, workspace: { path: directory, remoteConnectionId: workspace.remote_connection_id } }))}>} + {!entry.isDirectory && void perform(ticket => downloadRuntimeFile(manager, entry.path, { isCurrent: () => ticket === generation.current, workspace: { workspaceId: workspace.workspace_id, path: directory, remoteConnectionId: workspace.remote_connection_id } }))}>} beginAction({ kind: 'rename', entry })}> beginAction({ kind: 'delete', entry })}>
diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 45e4d6f96b..6dd3bbf840 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -224,6 +224,7 @@ export const messages: Record = { recentWorkspaces: 'Recent Workspaces', noRecentWorkspaces: 'No recent workspaces found. Please open a workspace on the desktop first.', failedToSetWorkspace: 'Failed to set workspace', + idReferencesUnsupported: 'The connected OpenBitFun does not support workspace ID references. Update it to a newer version to use this workspace.', openingWorkspace: 'Opening workspace...', }, chat: { @@ -546,6 +547,7 @@ export const messages: Record = { recentWorkspaces: '最近工作区', noRecentWorkspaces: '没有找到最近工作区,请先在桌面端打开一个工作区。', failedToSetWorkspace: '设置工作区失败', + idReferencesUnsupported: '当前连接的 OpenBitFun 不支持工作区 ID 引用,请升级到新版本后再使用该工作区。', openingWorkspace: '正在打开工作区...', }, chat: { @@ -868,6 +870,7 @@ export const messages: Record = { recentWorkspaces: '最近工作區', noRecentWorkspaces: '沒有找到最近工作區,請先在桌面端打開一個工作區。', failedToSetWorkspace: '設置工作區失敗', + idReferencesUnsupported: '目前連接的 OpenBitFun 不支援工作區 ID 引用,請升級到新版本後再使用該工作區。', openingWorkspace: '正在打開工作區...', }, chat: { diff --git a/src/mobile-web/src/pages/DeviceToolsPage.tsx b/src/mobile-web/src/pages/DeviceToolsPage.tsx index 6395c8c68d..bcb1c8aafe 100644 --- a/src/mobile-web/src/pages/DeviceToolsPage.tsx +++ b/src/mobile-web/src/pages/DeviceToolsPage.tsx @@ -33,7 +33,7 @@ export default function DeviceToolsPage({ manager, onBack }: { manager: RemoteSe }).catch(cause => { if (active) setError(String(cause)); }); return () => { active = false; }; }, [manager, targetEpoch]); - const location = { path: connection ? '/' : home, remote_connection_id: connection }; + const location = { workspace_id: undefined, path: connection ? '/' : home, remote_connection_id: connection }; return
} aria-label={t('common.close')} onClick={onBack}/>}/> diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 5cf409a0af..abb239b15a 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -48,14 +48,23 @@ import { useControlTargetEpoch } from '../hooks/useControlTargetEpoch'; import { useI18n } from '../i18n'; import { isRemoteControlTargetChangedError, + isWorkspaceIdReferencesUnsupportedError, REMOTE_CAPABILITY_HARNESS_PROFILES_V1, RemoteSessionManager, + type AssistantEntry, type RecentWorkspaceEntry, + type RemoteWorkspaceIdentity, type SessionInfo, } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; import { createRemoteCacheScope, remoteCache } from '../services/RemoteCache'; -import { sessionMatchesWorkspace, workspaceIdentityKey } from '../services/workspaceIdentity'; +import { describeRemoteError } from '../services/remoteErrorPresentation'; +import { + sameWorkspace, + sessionMatchesWorkspace, + workspaceIdentityKey, + type WorkspaceReference, +} from '../services/workspaceIdentity'; import { useTheme } from '../theme'; import logoMarkDark from '../assets/openbitfun-mark-dark.png'; import logoMarkLight from '../assets/openbitfun-mark-light.png'; @@ -105,6 +114,33 @@ function compactWorkspaceKey(workspace: RecentWorkspaceEntry): string { return workspaceIdentityKey(workspace); } +/** + * Key of one initial session load: the owning control target plus the + * workspace identity (ID when known, legacy triple only for ID-less rows). + */ +export function initialLoadKey( + deviceId: string | null | undefined, + workspace: WorkspaceReference | null | undefined, +): string | undefined { + if (!workspace || (!workspace.workspace_id && !workspace.path)) return undefined; + return JSON.stringify([deviceId ?? null, workspaceIdentityKey(workspace)]); +} + +/** Command identity for a workspace row; paths remain the legacy projection. */ +function commandIdentity( + workspace: Pick | null | undefined, +): RemoteWorkspaceIdentity { + return { + workspaceId: workspace?.workspace_id, + remoteConnectionId: workspace?.remote_connection_id, + remoteSshHost: workspace?.remote_ssh_host, + }; +} + +function assistantIdentity(assistant: AssistantEntry | null | undefined): RemoteWorkspaceIdentity { + return { workspaceId: assistant?.workspace_id }; +} + type SessionListTargetOwner = { sessionMgr: RemoteSessionManager; epoch: number; @@ -295,16 +331,9 @@ const SessionListPage: React.FC = ({ return 'pro'; }); - const [assistantList, setAssistantList] = useState>([]); + const [assistantList, setAssistantList] = useState([]); const [showAssistantPicker, setShowAssistantPicker] = useState(false); - const [workspaceList, setWorkspaceList] = useState>([]); + const [workspaceList, setWorkspaceList] = useState([]); const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); const [workspaceCatalogSource, setWorkspaceCatalogSource] = useState<'opened' | 'recent' | null>(null); @@ -513,7 +542,8 @@ const SessionListPage: React.FC = ({ const listRef = useRef(null); const listRequestSeqRef = useRef(0); const workspaceCatalogRequestSeqRef = useRef(0); - const initLoadedPathRef = useRef(undefined); + // Keyed by (control target, workspace identity); see initialLoadKey. + const initLoadedWorkspaceRef = useRef(undefined); const touchStartY = useRef(0); const isPulling = useRef(false); @@ -576,7 +606,7 @@ const SessionListPage: React.FC = ({ setPairedDisplayMode(null); setError(null); offsetRef.current = 0; - initLoadedPathRef.current = undefined; + initLoadedWorkspaceRef.current = undefined; } committedSessionListTargetRef.current = { sessionMgr, epoch: controlTargetEpoch }; return () => { @@ -644,21 +674,21 @@ const SessionListPage: React.FC = ({ if (!currentAssistant && assistants.length > 0) { const defaultAssistant = assistants.find(a => !a.assistant_id) || assistants[0]; setCurrentAssistant(defaultAssistant); - return defaultAssistant.path; + return defaultAssistant; } - return currentAssistant?.path; + return currentAssistant ?? undefined; } catch (e: any) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } return undefined; } - }, [captureSessionListEpoch, currentAssistant, isSessionListCurrent, sessionMgr, setCurrentAssistant, setError]); + }, [captureSessionListEpoch, currentAssistant, isSessionListCurrent, sessionMgr, setCurrentAssistant, setError, t]); const loadFirstPage = useCallback(async ( workspacePath: string | undefined, query = '', - identity?: { remoteConnectionId?: string; remoteSshHost?: string }, + identity?: { workspaceId?: string; remoteConnectionId?: string; remoteSshHost?: string }, ) => { const targetEpoch = captureSessionListEpoch(); if (targetEpoch === null) return; @@ -693,7 +723,7 @@ const SessionListPage: React.FC = ({ requestSeq !== listRequestSeqRef.current || !isSessionListCurrent(targetEpoch) ) return; - if (!isRemoteControlTargetChangedError(e)) setError(e.message); + if (!isRemoteControlTargetChangedError(e)) setError(describeRemoteError(e, t)); } finally { if ( requestSeq === listRequestSeqRef.current @@ -702,7 +732,7 @@ const SessionListPage: React.FC = ({ setLoading(false); } } - }, [cacheScope, captureSessionListEpoch, isSessionListCurrent, sessionMgr, setError, setSessions]); + }, [cacheScope, captureSessionListEpoch, isSessionListCurrent, sessionMgr, setError, setSessions, t]); // Load workspace list for Pro mode picker const loadWorkspaceList = useCallback(async () => { @@ -721,10 +751,10 @@ const SessionListPage: React.FC = ({ } catch (e: any) { if (requestSeq === workspaceCatalogRequestSeqRef.current && isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } } - }, [cacheScope, captureSessionListEpoch, compact, isSessionListCurrent, sessionMgr, setError]); + }, [cacheScope, captureSessionListEpoch, compact, isSessionListCurrent, sessionMgr, setError, t]); const loadCompactDirectory = useCallback(async () => { if (!compact) return; @@ -855,10 +885,8 @@ const SessionListPage: React.FC = ({ if (targetEpoch === null) return; setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'loading' })); try { - const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }); + const identity = commandIdentity(workspace); + const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', identity); if (!isSessionListCurrent(targetEpoch)) return; liveDataSeqRef.current += 1; setCompactWorkspaceSessions((current) => ({ ...current, [key]: response.sessions })); @@ -867,15 +895,13 @@ const SessionListPage: React.FC = ({ setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); remoteCache.saveSessionPage(cacheScope, response.sessions, { workspacePath: workspace.path, - workspaceIdentity: { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }, + workspaceIdentity: identity, replaceWorkspace: true, }); } catch (error: unknown) { if (!isSessionListCurrent(targetEpoch) || isRemoteControlTargetChangedError(error)) return; setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'failed' })); + if (isWorkspaceIdReferencesUnsupportedError(error)) setError(describeRemoteError(error, t)); } }, [ captureSessionListEpoch, @@ -884,6 +910,8 @@ const SessionListPage: React.FC = ({ cacheScope, isSessionListCurrent, sessionMgr, + setError, + t, ]); const handleRetryCompactWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { @@ -892,10 +920,8 @@ const SessionListPage: React.FC = ({ const key = compactWorkspaceKey(workspace); setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'loading' })); try { - const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }); + const identity = commandIdentity(workspace); + const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', identity); if (!isSessionListCurrent(targetEpoch)) return; liveDataSeqRef.current += 1; setCompactWorkspaceSessions((current) => ({ ...current, [key]: response.sessions })); @@ -904,17 +930,15 @@ const SessionListPage: React.FC = ({ setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); remoteCache.saveSessionPage(cacheScope, response.sessions, { workspacePath: workspace.path, - workspaceIdentity: { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }, + workspaceIdentity: identity, replaceWorkspace: true, }); } catch (error: unknown) { if (!isSessionListCurrent(targetEpoch) || isRemoteControlTargetChangedError(error)) return; setCompactWorkspaceStatuses((current) => ({ ...current, [key]: 'failed' })); + if (isWorkspaceIdReferencesUnsupportedError(error)) setError(describeRemoteError(error, t)); } - }, [cacheScope, captureSessionListEpoch, isSessionListCurrent, sessionMgr]); + }, [cacheScope, captureSessionListEpoch, isSessionListCurrent, sessionMgr, setError, t]); const handleLoadMoreCompactWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { const key = compactWorkspaceKey(workspace); @@ -933,15 +957,13 @@ const SessionListPage: React.FC = ({ if (targetEpoch === null) return; setCompactWorkspaceLoadingMore((current) => new Set(current).add(key)); try { + const identity = commandIdentity(workspace); const response = await sessionMgr.listSessions( workspace.path, PAGE_SIZE, loadedSessions.length, '', - { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }, + identity, ); if (!isSessionListCurrent(targetEpoch)) return; liveDataSeqRef.current += 1; @@ -958,14 +980,11 @@ const SessionListPage: React.FC = ({ })); remoteCache.saveSessionPage(cacheScope, response.sessions, { workspacePath: workspace.path, - workspaceIdentity: { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }, + workspaceIdentity: identity, }); } catch (error: unknown) { if (!isSessionListCurrent(targetEpoch) || isRemoteControlTargetChangedError(error)) return; - setError(String((error as { message?: string })?.message || error)); + setError(describeRemoteError(error, t)); } finally { if (isSessionListCurrent(targetEpoch)) { setCompactWorkspaceLoadingMore((current) => { @@ -985,6 +1004,7 @@ const SessionListPage: React.FC = ({ isSessionListCurrent, sessionMgr, setError, + t, ]); const handleCreateInCompactWorkspace = useCallback(async ( @@ -996,13 +1016,18 @@ const SessionListPage: React.FC = ({ if (targetEpoch === null) return; setCreating(true); try { - const identity = { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }; - const sessionId = await sessionMgr.createSession(agentType, undefined, workspace.path, identity); + const identity = commandIdentity(workspace); + const created = await sessionMgr.createSession(agentType, undefined, workspace.path, identity); if (!isSessionListCurrent(targetEpoch)) return; - const response = await sessionMgr.listSessions(workspace.path, PAGE_SIZE, 0, '', identity); + // The host pins the new session to a workspace record; that identity + // owns the follow-up listing and cache page. + const createdIdentity: RemoteWorkspaceIdentity = { + workspaceId: created.workspace_id ?? identity.workspaceId, + remoteConnectionId: created.remote_connection_id ?? identity.remoteConnectionId, + remoteSshHost: created.remote_ssh_host ?? identity.remoteSshHost, + }; + const createdPath = created.workspace_path ?? workspace.path; + const response = await sessionMgr.listSessions(createdPath, PAGE_SIZE, 0, '', createdIdentity); if (!isSessionListCurrent(targetEpoch)) return; const key = compactWorkspaceKey(workspace); liveDataSeqRef.current += 1; @@ -1012,14 +1037,14 @@ const SessionListPage: React.FC = ({ setCompactWorkspaceHasMore((current) => ({ ...current, [key]: response.has_more })); setCompactVisibleSessionCounts((current) => ({ ...current, [key]: 3 })); remoteCache.saveSessionPage(cacheScope, response.sessions, { - workspacePath: workspace.path, - workspaceIdentity: identity, + workspacePath: createdPath, + workspaceIdentity: createdIdentity, replaceWorkspace: true, }); - onSelectSession(sessionId, t(isClawAgent(agentType) ? 'sessions.remoteClawSession' : isCoworkAgent(agentType) ? 'sessions.remoteCoworkSession' : 'sessions.remoteCodeSession'), true, agentType); + onSelectSession(created.session_id, t(isClawAgent(agentType) ? 'sessions.remoteClawSession' : isCoworkAgent(agentType) ? 'sessions.remoteCoworkSession' : 'sessions.remoteCodeSession'), true, agentType); } catch (error: unknown) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(error)) { - setError(String((error as { message?: string })?.message || error)); + setError(describeRemoteError(error, t)); } } finally { if (isSessionListCurrent(targetEpoch)) setCreating(false); @@ -1035,48 +1060,40 @@ const SessionListPage: React.FC = ({ t, ]); - const handleSelectWorkspace = useCallback(async (workspace: { - path: string; - name: string; - remote_connection_id?: string; - remote_ssh_host?: string; - }) => { + const handleSelectWorkspace = useCallback(async (workspace: RecentWorkspaceEntry) => { if (targetInitializingRef.current) return; const targetEpoch = captureSessionListEpoch(); if (targetEpoch === null) return; try { - const result = await sessionMgr.setWorkspace(workspace.path, { - remoteConnectionId: workspace.remote_connection_id, - remoteSshHost: workspace.remote_ssh_host, - }); + const result = await sessionMgr.setWorkspace(workspace); if (!isSessionListCurrent(targetEpoch)) return; if (result.success) { const path = result.path || workspace.path; const remoteConnectionId = result.remote_connection_id ?? workspace.remote_connection_id; const remoteSshHost = result.remote_ssh_host ?? workspace.remote_ssh_host; - const identity = { remoteConnectionId, remoteSshHost }; + const workspaceId = result.workspace_id ?? workspace.workspace_id; + const identity = { workspaceId, remoteConnectionId, remoteSshHost }; setCurrentWorkspace({ + workspace_id: workspaceId, has_workspace: true, path, project_name: result.project_name || workspace.name, - workspace_kind: remoteConnectionId || remoteSshHost - ? 'remote' - : undefined, + workspace_kind: workspace.workspace_kind, remote_connection_id: remoteConnectionId, remote_ssh_host: remoteSshHost, }); setShowWorkspacePicker(false); loadFirstPage(path, searchQuery, identity); } else { - setError(result.error || 'Failed to set workspace'); + setError(result.error || t('workspace.failedToSetWorkspace')); } } catch (e: any) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } } - }, [captureSessionListEpoch, isSessionListCurrent, loadFirstPage, searchQuery, sessionMgr, setCurrentWorkspace, setError]); + }, [captureSessionListEpoch, isSessionListCurrent, loadFirstPage, searchQuery, sessionMgr, setCurrentWorkspace, setError, t]); const trySelectFirstProWorkspace = useCallback(async (): Promise => { const targetEpoch = captureSessionListEpoch(); @@ -1086,24 +1103,21 @@ const SessionListPage: React.FC = ({ if (!isSessionListCurrent(targetEpoch)) return false; const candidate = pickFirstProWorkspace(list); if (!candidate) return false; - const result = await sessionMgr.setWorkspace(candidate.path, { - remoteConnectionId: candidate.remote_connection_id, - remoteSshHost: candidate.remote_ssh_host, - }); + const result = await sessionMgr.setWorkspace(candidate); if (!isSessionListCurrent(targetEpoch)) return false; if (result.success) { const path = result.path || candidate.path; const remoteConnectionId = result.remote_connection_id ?? candidate.remote_connection_id; const remoteSshHost = result.remote_ssh_host ?? candidate.remote_ssh_host; - const identity = { remoteConnectionId, remoteSshHost }; + const workspaceId = result.workspace_id ?? candidate.workspace_id; + const identity = { workspaceId, remoteConnectionId, remoteSshHost }; setCurrentWorkspace({ + workspace_id: workspaceId, has_workspace: true, path, project_name: result.project_name || candidate.name, - workspace_kind: remoteConnectionId || remoteSshHost - ? 'remote' - : candidate.workspace_kind, + workspace_kind: candidate.workspace_kind, remote_connection_id: remoteConnectionId, remote_ssh_host: remoteSshHost, }); @@ -1114,7 +1128,7 @@ const SessionListPage: React.FC = ({ return false; } catch (e: any) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } return false; } @@ -1123,7 +1137,7 @@ const SessionListPage: React.FC = ({ const loadNextPage = useCallback(async ( workspacePath: string | undefined, query = '', - identity?: { remoteConnectionId?: string; remoteSshHost?: string }, + identity?: { workspaceId?: string; remoteConnectionId?: string; remoteSshHost?: string }, ) => { if (loading || loadingMore || !hasMore) return; const targetEpoch = captureSessionListEpoch(); @@ -1152,14 +1166,14 @@ const SessionListPage: React.FC = ({ requestSeq !== listRequestSeqRef.current || !isSessionListCurrent(targetEpoch) ) return; - if (!isRemoteControlTargetChangedError(e)) setError(e.message); + if (!isRemoteControlTargetChangedError(e)) setError(describeRemoteError(e, t)); } finally { if ( requestSeq === listRequestSeqRef.current && isSessionListCurrent(targetEpoch) ) setLoadingMore(false); } - }, [appendSessions, cacheScope, captureSessionListEpoch, hasMore, isSessionListCurrent, loading, loadingMore, sessionMgr, setError]); + }, [appendSessions, cacheScope, captureSessionListEpoch, hasMore, isSessionListCurrent, loading, loadingMore, sessionMgr, setError, t]); useEffect(() => { let cancelled = false; @@ -1173,32 +1187,32 @@ const SessionListPage: React.FC = ({ try { const info = await sessionMgr.getWorkspaceInfo(); if (!isInitCurrent()) return; + const deviceId = sessionMgr.controlTargetDeviceId; if (info.workspace_kind === 'assistant' && info.path) { - setCurrentAssistant({ + const assistant: AssistantEntry = { + workspace_id: info.workspace_id, path: info.path, name: info.project_name ?? 'Claw', assistant_id: info.assistant_id, - }); + }; + setCurrentAssistant(assistant); setCurrentWorkspace(null); setDisplayMode('assistant'); - initLoadedPathRef.current = info.path; - await loadFirstPage(info.path); + initLoadedWorkspaceRef.current = initialLoadKey(deviceId, assistant); + await loadFirstPage(info.path, '', assistantIdentity(assistant)); } else { setDisplayMode('pro'); const ws = info.has_workspace ? info : null; setCurrentWorkspace(ws); if (ws?.path) { - initLoadedPathRef.current = ws.path; - await loadFirstPage(ws.path, '', { - remoteConnectionId: ws.remote_connection_id, - remoteSshHost: ws.remote_ssh_host, - }); + initLoadedWorkspaceRef.current = initialLoadKey(deviceId, ws); + await loadFirstPage(ws.path, '', commandIdentity(ws)); } else { await trySelectFirstProWorkspace(); } } } catch (e: any) { - if (isInitCurrent() && !isRemoteControlTargetChangedError(e)) setError(e.message); + if (isInitCurrent() && !isRemoteControlTargetChangedError(e)) setError(describeRemoteError(e, t)); } finally { if (isInitCurrent()) { setPairedDisplayMode(null); @@ -1237,10 +1251,8 @@ const SessionListPage: React.FC = ({ } const ws = info.has_workspace ? info : null; setCurrentWorkspace(ws); - const resp = await sessionMgr.listSessions(ws?.path, PAGE_SIZE, 0, searchQuery, { - remoteConnectionId: ws?.remote_connection_id, - remoteSshHost: ws?.remote_ssh_host, - }); + const identity = commandIdentity(ws); + const resp = await sessionMgr.listSessions(ws?.path, PAGE_SIZE, 0, searchQuery, identity); if ( requestSeq !== listRequestSeqRef.current || !isSessionListCurrent(targetEpoch) @@ -1251,15 +1263,14 @@ const SessionListPage: React.FC = ({ offsetRef.current = resp.sessions.length; remoteCache.saveSessionPage(cacheScope, resp.sessions, { workspacePath: ws?.path, - workspaceIdentity: { - remoteConnectionId: ws?.remote_connection_id, - remoteSshHost: ws?.remote_ssh_host, - }, + workspaceIdentity: identity, replaceWorkspace: searchQuery.trim().length === 0, }); } else { - // Assistant mode: use currentAssistant path - const resp = await sessionMgr.listSessions(currentAssistant?.path, PAGE_SIZE, 0, searchQuery); + // Assistant mode: the assistant workspace ID scopes the listing; its + // path is the legacy projection for pre-ID hosts. + const identity = assistantIdentity(currentAssistant); + const resp = await sessionMgr.listSessions(currentAssistant?.path, PAGE_SIZE, 0, searchQuery, identity); if ( requestSeq !== listRequestSeqRef.current || !isSessionListCurrent(targetEpoch) @@ -1270,13 +1281,14 @@ const SessionListPage: React.FC = ({ offsetRef.current = resp.sessions.length; remoteCache.saveSessionPage(cacheScope, resp.sessions, { workspacePath: currentAssistant?.path, + workspaceIdentity: identity, replaceWorkspace: searchQuery.trim().length === 0, }); } } catch (error) { if (requestSeq === listRequestSeqRef.current && isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(error)) { - setError(error instanceof Error ? error.message : String(error)); + setError(describeRemoteError(error, t)); } } finally { @@ -1288,7 +1300,7 @@ const SessionListPage: React.FC = ({ setLoadingMore(false); } } - }, [cacheScope, captureSessionListEpoch, currentAssistant?.path, displayMode, isSessionListCurrent, searchQuery, sessionMgr, setCurrentWorkspace, setError, setSessions]); + }, [cacheScope, captureSessionListEpoch, currentAssistant, displayMode, isSessionListCurrent, searchQuery, sessionMgr, setCurrentWorkspace, setError, setSessions, t]); const catalogReadRef = useRef<() => Promise>(async () => {}); catalogReadRef.current = async () => { @@ -1321,31 +1333,36 @@ const SessionListPage: React.FC = ({ useEffect(() => { const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; if (!workspacePath) return; - // Skip the redundant first load when init() already loaded this path — - // otherwise the state change from init() triggers a second loadFirstPage - // 250 ms later, causing an extra network round-trip and a loading flicker. - if (initLoadedPathRef.current === workspacePath) { - initLoadedPathRef.current = undefined; + // Skip the redundant first load when init() already loaded this workspace + // on this control target. Otherwise the state change from init() triggers + // a second loadFirstPage 250 ms later, causing an extra network round-trip + // and a loading flicker. The key is the workspace identity, not its path. + const loadKey = initialLoadKey( + sessionMgr.controlTargetDeviceId, + displayMode === 'assistant' ? currentAssistant : currentWorkspace, + ); + if (loadKey !== undefined && initLoadedWorkspaceRef.current === loadKey) { + initLoadedWorkspaceRef.current = undefined; return; } const identity = displayMode === 'assistant' - ? undefined - : { - remoteConnectionId: currentWorkspace?.remote_connection_id, - remoteSshHost: currentWorkspace?.remote_ssh_host, - }; + ? assistantIdentity(currentAssistant) + : commandIdentity(currentWorkspace); const timer = setTimeout(() => { loadFirstPage(workspacePath, searchQuery, identity); }, 250); return () => clearTimeout(timer); }, [ + currentAssistant?.workspace_id, currentAssistant?.path, + currentWorkspace?.workspace_id, currentWorkspace?.path, currentWorkspace?.remote_connection_id, currentWorkspace?.remote_ssh_host, displayMode, loadFirstPage, searchQuery, + sessionMgr, ]); const PULL_THRESHOLD = 60; @@ -1387,19 +1404,14 @@ const SessionListPage: React.FC = ({ if (el.scrollHeight - el.scrollTop - el.clientHeight < 150) { const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; const identity = displayMode === 'assistant' - ? undefined - : { - remoteConnectionId: currentWorkspace?.remote_connection_id, - remoteSshHost: currentWorkspace?.remote_ssh_host, - }; + ? assistantIdentity(currentAssistant) + : commandIdentity(currentWorkspace); loadNextPage(workspacePath, searchQuery, identity); } }, [ displayMode, - currentAssistant?.path, - currentWorkspace?.path, - currentWorkspace?.remote_connection_id, - currentWorkspace?.remote_ssh_host, + currentAssistant, + currentWorkspace, loadNextPage, searchQuery, ]); @@ -1410,32 +1422,35 @@ const SessionListPage: React.FC = ({ if (targetEpoch === null) return; setCreating(true); try { - // For assistant mode (Claw), use currentAssistant.path - // For pro mode (Code/Cowork), use currentWorkspace.path + // Assistant mode (Claw) is scoped by the assistant workspace; Pro mode + // (Code/Cowork) by the current workspace. IDs scope the command; paths + // are the legacy projection for pre-ID hosts. const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; const identity = displayMode === 'assistant' - ? undefined - : { - remoteConnectionId: currentWorkspace?.remote_connection_id, - remoteSshHost: currentWorkspace?.remote_ssh_host, - }; + ? assistantIdentity(currentAssistant) + : commandIdentity(currentWorkspace); if (!workspacePath?.trim()) { onOpenWorkspace(); return; } - const id = await sessionMgr.createSession(agentType, undefined, workspacePath, identity); + const created = await sessionMgr.createSession(agentType, undefined, workspacePath, identity); if (!isSessionListCurrent(targetEpoch)) return; - await loadFirstPage(workspacePath, searchQuery, identity); + const createdIdentity: RemoteWorkspaceIdentity = { + workspaceId: created.workspace_id ?? identity.workspaceId, + remoteConnectionId: created.remote_connection_id ?? identity.remoteConnectionId, + remoteSshHost: created.remote_ssh_host ?? identity.remoteSshHost, + }; + await loadFirstPage(created.workspace_path ?? workspacePath, searchQuery, createdIdentity); if (!isSessionListCurrent(targetEpoch)) return; const label = isClawAgent(agentType) ? t('sessions.remoteClawSession') : isCoworkAgent(agentType) ? t('sessions.remoteCoworkSession') : t('sessions.remoteCodeSession'); - onSelectSession(id, label, true, agentType); + onSelectSession(created.session_id, label, true, agentType); } catch (e: any) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } } finally { if (isSessionListCurrent(targetEpoch)) setCreating(false); @@ -1443,10 +1458,8 @@ const SessionListPage: React.FC = ({ }, [ creating, captureSessionListEpoch, - currentWorkspace?.path, - currentWorkspace?.remote_connection_id, - currentWorkspace?.remote_ssh_host, - currentAssistant?.path, + currentWorkspace, + currentAssistant, displayMode, isSessionListCurrent, loadFirstPage, @@ -1489,37 +1502,44 @@ const SessionListPage: React.FC = ({ setDisplayMode(mode); setShowAssistantPicker(false); if (mode === 'assistant') { - const assistantPath = await loadAssistantList(); + const assistant = await loadAssistantList(); if (!isSessionListCurrent(targetEpoch)) return; - loadFirstPage(assistantPath, searchQuery); + loadFirstPage(assistant?.path, searchQuery, assistantIdentity(assistant)); } else { if (currentWorkspace?.path) { - await loadFirstPage(currentWorkspace.path, searchQuery, { - remoteConnectionId: currentWorkspace.remote_connection_id, - remoteSshHost: currentWorkspace.remote_ssh_host, - }); + await loadFirstPage(currentWorkspace.path, searchQuery, commandIdentity(currentWorkspace)); } else { await trySelectFirstProWorkspace(); } } - }, [captureSessionListEpoch, currentWorkspace?.path, isSessionListCurrent, loadAssistantList, loadFirstPage, searchQuery, trySelectFirstProWorkspace]); + }, [captureSessionListEpoch, currentWorkspace, isSessionListCurrent, loadAssistantList, loadFirstPage, searchQuery, trySelectFirstProWorkspace]); - const handleSelectAssistant = useCallback(async (assistant: { path: string; name: string; assistant_id?: string }) => { + const handleSelectAssistant = useCallback(async (assistant: AssistantEntry) => { if (targetInitializingRef.current) return; const targetEpoch = captureSessionListEpoch(); if (targetEpoch === null) return; try { - await sessionMgr.setAssistant(assistant.path); + const result = await sessionMgr.setAssistant(assistant); if (!isSessionListCurrent(targetEpoch)) return; - setCurrentAssistant(assistant); + if (!result.success) { + setError(result.error || t('workspace.failedToSetWorkspace')); + return; + } + const selected: AssistantEntry = { + ...assistant, + workspace_id: result.workspace_id ?? assistant.workspace_id, + path: result.path || assistant.path, + name: result.name || assistant.name, + }; + setCurrentAssistant(selected); setShowAssistantPicker(false); - loadFirstPage(assistant.path, searchQuery); + loadFirstPage(selected.path, searchQuery, assistantIdentity(selected)); } catch (e: any) { if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { - setError(e.message); + setError(describeRemoteError(e, t)); } } - }, [captureSessionListEpoch, isSessionListCurrent, loadFirstPage, searchQuery, sessionMgr, setCurrentAssistant, setError]); + }, [captureSessionListEpoch, isSessionListCurrent, loadFirstPage, searchQuery, sessionMgr, setCurrentAssistant, setError, t]); const workspaceDisplayName = currentWorkspace?.project_name || t('sessions.noWorkspaceSelected'); const assistantDisplayName = currentAssistant?.name || t('shared.agents.default'); @@ -1637,8 +1657,7 @@ const SessionListPage: React.FC = ({ const status = compactWorkspaceStatuses[key] ?? (projectedSessions.length > 0 ? 'ready' : 'idle'); const visibleCount = compactVisibleSessionCounts[key] ?? 3; - const current = currentWorkspace?.path === workspace.path - && currentWorkspace?.remote_connection_id === workspace.remote_connection_id; + const current = sameWorkspace(currentWorkspace, workspace); return (
@@ -1915,19 +1934,15 @@ const SessionListPage: React.FC = ({ headerAction={ setShowWorkspacePicker(false)} aria-label={t('common.close')} icon={
}> = memo(({ return ( {t('flexiblePanel.loading.gitGraph')}
}> @@ -683,7 +692,7 @@ const FlexiblePanel: React.FC = memo(({ return ( {t('flexiblePanel.loading.gitBranchHistory')}
}> = memo(({ return ( {t('flexiblePanel.loading.gitSettings')}
}> ); @@ -770,6 +779,7 @@ const FlexiblePanel: React.FC = memo(({ const planViewerData = content.data || {}; const planFilePath = planViewerData.filePath || ''; const planFileName = planViewerData.fileName || content.title; + const planWorkspaceId = planViewerData.workspaceId || content.metadata?.resourceScope?.workspaceId; const planWorkspacePath = planViewerData.workspacePath || workspacePath; const planJumpToLine = planViewerData.jumpToLine; const planJumpToColumn = planViewerData.jumpToColumn; @@ -788,6 +798,7 @@ const FlexiblePanel: React.FC = memo(({ = memo(({ isActive={isActive} childSessionId={content.data?.childSessionId} parentSessionId={content.data?.parentSessionId} + workspaceId={content.data?.workspaceId ?? content.metadata?.resourceScope?.workspaceId} workspacePath={content.data?.workspacePath || workspacePath} viewKind={content.data?.viewKind} displayTitle={content.data?.displayTitle} @@ -863,7 +875,7 @@ const FlexiblePanel: React.FC = memo(({ case 'review-platform': return ( Loading pull requests...}> - + ); @@ -871,6 +883,7 @@ const FlexiblePanel: React.FC = memo(({ return ( Loading pull request...}> = memo(({ ); @@ -995,6 +1007,7 @@ const FlexiblePanel: React.FC = memo(({ source={content.data?.source} status={content.data?.status} diagnostics={content.data?.diagnostics} + workspaceId={content.data?.workspaceId} workspacePath={content.data?.workspacePath} remoteConnectionId={content.data?.remoteConnectionId} remoteSshHost={content.data?.remoteSshHost} diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index e8e1bfa4e6..1b636c9133 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -12,7 +12,8 @@ import { useCanvasStore } from './stores'; import { useTabLifecycle, useKeyboardShortcuts } from './hooks'; import type { AnchorPosition } from './types'; import type { CanvasStoreMode } from './stores/canvasStore'; -import { selectActiveBtwSessionTab } from '@/flow_chat/services/btwSessionPane'; +import { selectActiveBtwSessionTab, type BtwSessionPanelData } from '@/flow_chat/services/btwSessionPane'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { isSamePath } from '@/shared/utils/pathUtils'; import './ContentCanvas.scss'; @@ -68,9 +69,9 @@ export const ContentCanvas: React.FC = ({ const closeMissionControl = useCanvasStore(state => state.closeMissionControl); const openMissionControl = useCanvasStore(state => state.openMissionControl); const activeBtwSessionTab = useCanvasStore(state => selectActiveBtwSessionTab(state as any)); - const activeBtwSessionData = activeBtwSessionTab?.content.data as - | { childSessionId: string; parentSessionId: string; workspacePath?: string } - | undefined; + const activeBtwSessionData = activeBtwSessionTab?.content.data as BtwSessionPanelData | undefined; + const { workspace: currentWorkspace } = useCurrentWorkspace(); + const currentWorkspaceId = currentWorkspace?.id; const lastSyncedBtwTabIdRef = useRef(null); // Initialize hooks const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({ @@ -97,15 +98,33 @@ export const ContentCanvas: React.FC = ({ // Only sync when the BTW session belongs to the current workspace, // preventing the wrong session from opening when switching workspaces. + // Workspace ID is the identity; the path check only serves tabs restored + // from a canvas snapshot written before tabs recorded a workspace ID. + const btwWorkspaceId = activeBtwSessionData.workspaceId; + const btwProjectWorkspaceId = activeBtwSessionData.projectWorkspaceId; const btwWorkspacePath = activeBtwSessionData.workspacePath; - if (workspacePath && btwWorkspacePath && !isSamePath(workspacePath, btwWorkspacePath)) { + const belongsToCurrentWorkspace = btwWorkspaceId + ? !currentWorkspaceId + || btwWorkspaceId === currentWorkspaceId + || btwProjectWorkspaceId === currentWorkspaceId + : !(workspacePath && btwWorkspacePath && !isSamePath(workspacePath, btwWorkspacePath)); + if (!belongsToCurrentWorkspace) { lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id; return; } lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id; void openMainSession(activeBtwSessionData.parentSessionId); - }, [activeBtwSessionData?.parentSessionId, activeBtwSessionData?.workspacePath, activeBtwSessionTab?.id, mode, workspacePath]); + }, [ + activeBtwSessionData?.parentSessionId, + activeBtwSessionData?.projectWorkspaceId, + activeBtwSessionData?.workspaceId, + activeBtwSessionData?.workspacePath, + activeBtwSessionTab?.id, + currentWorkspaceId, + mode, + workspacePath, + ]); // Keep the editor area mounted for legacy hidden terminal tabs restored from // an older canvas snapshot. New terminal closes destroy and remove the tab. diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 2f98081e92..631ca7d7fc 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -32,14 +32,20 @@ import './EditorGroup.scss'; function CanvasContentView({ documentId, ...props }: React.ComponentProps & { documentId: string }) { const resourceScope = props.content?.metadata?.resourceScope; + // Tabs created through the workbench carry an ID-owned resource scope. Only + // tabs persisted before scopes existed fall through to the legacy ingress, + // which prefers the tab's own workspace ID over any path it recorded. + const resourceWorkspaceId = props.content?.data?.workspaceId; const resourceWorkspacePath = props.content?.data?.workspacePath ?? props.workspacePath; const remoteConnectionId = props.content?.data?.remoteConnectionId; const filePath = props.content?.data?.filePath; const documentSession = useMemo(() => getEditorDocument( documentId, - resourceScope ?? captureContentScope({ workspacePath: resourceWorkspacePath, remoteConnectionId }), + resourceScope ?? captureContentScope({ + workspaceId: resourceWorkspaceId, workspacePath: resourceWorkspacePath, remoteConnectionId, + }), filePath, - ), [documentId, filePath, remoteConnectionId, resourceScope, resourceWorkspacePath]); + ), [documentId, filePath, remoteConnectionId, resourceScope, resourceWorkspaceId, resourceWorkspacePath]); useEffect(() => () => { if (!hasRetainedCanvasTab(documentId.slice('canvas:'.length)) && !Object.values(useContentResourceStore.getState().resources).some(resource => resource.documentId === documentId)) { diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx index 6b7f9689d2..00648792a3 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CanvasStoreModeContext, clearAgentCanvasForPeerSwitch, @@ -28,6 +28,21 @@ import { resolveSessionSceneTarget } from '@/app/services/sessionSceneTarget'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import { drainPendingTabs } from '@/shared/services/pendingTabQueue'; + +// Content scopes are owned by workspace ID. Provide the records that the +// file opens below name so that the ID-first scope capture can resolve them. +vi.mock('@/infrastructure/services/business/workspaceManager', () => { + const sshWorkspace = { + id: 'ssh-workspace-record', rootPath: '/workspace', workspaceKind: 'remote', connectionId: 'ssh-workspace', + }; + const workspace = { id: 'workspace', rootPath: '/workspace', workspaceKind: 'normal' }; + return { + workspaceManager: { getState: () => ({ + currentWorkspace: workspace, recentWorkspaces: [], + openedWorkspaces: new Map([[workspace.id, workspace], [sshWorkspace.id, sshWorkspace]]), + }) }, + }; +}); import { usePanelTabCoordinator } from './usePanelTabCoordinator'; import { useTabLifecycle } from './useTabLifecycle'; @@ -115,7 +130,7 @@ describe('canvas host panel ownership', () => { window.addEventListener(TAB_EVENTS.EXPAND_RIGHT_PANEL, onRightPanelRequest); try { await act(async () => { - const options = { filePath: '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/workspace/example.ts', workspacePath: '/workspace', + const options = { filePath: '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/workspace/example.ts', workspaceId: 'ssh-workspace-record', workspacePath: '/workspace', remoteConnectionId: 'ssh-workspace', mode: 'project' as const }; fileTabManager.openFile(options); fileTabManager.openFile({ ...options, jumpToLine: 12 }); @@ -150,13 +165,13 @@ describe('canvas host panel ownership', () => { it('opens session file links in the right panel and reveals an existing file again', async () => { flowChatStore.setState(state => ({ ...state, activeSessionId: 'session-a', sessions: new Map([['session-a', { sessionId: 'session-a', title: 'Session', status: 'idle', config: {}, dialogTurns: [], - createdAt: 1, lastActiveAt: 1, error: null, workspacePath: '/workspace', sessionKind: 'normal', + createdAt: 1, lastActiveAt: 1, error: null, workspacePath: '/workspace', workspaceId: 'workspace', sessionKind: 'normal', }]]) })); useSceneStore.getState().openSessionScene(resolveSessionSceneTarget( flowChatStore.getActiveSession()!, workspaceManager.getState().openedWorkspaces.values(), getActiveSurfaceId(), )); await act(async () => root.render()); - const options = { filePath: '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/workspace/session.ts', workspacePath: '/workspace', mode: 'agent' as const }; + const options = { filePath: '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/workspace/session.ts', workspaceId: 'workspace', workspacePath: '/workspace', mode: 'agent' as const }; await act(async () => { fileTabManager.openFile(options); collapseSessionAuxPane(); @@ -175,12 +190,13 @@ describe('canvas host panel ownership', () => { it('routes legacy content events to the top when the selected session has no open tab', async () => { flowChatStore.setState(state => ({ ...state, activeSessionId: 'cached-session', sessions: new Map([['cached-session', { sessionId: 'cached-session', title: 'Session', status: 'idle', config: {}, dialogTurns: [], - createdAt: 1, lastActiveAt: 1, error: null, workspacePath: '/workspace', sessionKind: 'normal', + createdAt: 1, lastActiveAt: 1, error: null, workspacePath: '/workspace', workspaceId: 'workspace', sessionKind: 'normal', }]]) })); await act(async () => root.render()); await act(async () => { window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail: { - type: 'code-editor', title: 'session.ts', data: { filePath: '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/workspace/session.ts', workspacePath: '/workspace' }, + type: 'code-editor', title: 'session.ts', + data: { filePath: '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/workspace/session.ts', workspaceId: 'workspace', workspacePath: '/workspace' }, } })); }); expect(useSceneStore.getState().activeTabId).toMatch(/^content:/); diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx index b5b13b658f..ed8fdd6e1c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx @@ -88,7 +88,12 @@ export const Tab: React.FC = ({ const tabData = tab.content.data as { filePath?: string; workspacePath?: string } | undefined; const filePath = typeof tabData?.filePath === 'string' ? tabData.filePath : undefined; const workspacePath = typeof tabData?.workspacePath === 'string' ? tabData.workspacePath : undefined; - const isRemote = isRemoteWorkspace(workspaceManager.getState().currentWorkspace); + // The tab's resource scope names the owning workspace by ID; the path is only its IO projection. + const resourceScope = tab.content.metadata?.resourceScope; + const owningWorkspace = resourceScope?.workspaceId + ? workspaceManager.getState().openedWorkspaces.get(resourceScope.workspaceId) ?? workspaceManager.getState().currentWorkspace + : workspaceManager.getState().currentWorkspace; + const isRemote = isRemoteWorkspace(owningWorkspace); const canUseLocalFileActions = Boolean(filePath) && !isRemote && !hasNonFileUriScheme(filePath || ''); const isPinned = tab.state === 'pinned'; @@ -151,6 +156,7 @@ export const Tab: React.FC = ({ tabType: tab.content.type, filePath, workspacePath, + resourceScope, isActive, isClosable: true, }; @@ -236,6 +242,7 @@ export const Tab: React.FC = ({ filePath, fileName: tab.title, workspacePath, + scope: resourceScope, editorType: 'code-editor', }), }); @@ -248,6 +255,7 @@ export const Tab: React.FC = ({ filePath, fileName: tab.title, workspacePath, + scope: resourceScope, editorType: 'html-preview', }), }); @@ -273,6 +281,7 @@ export const Tab: React.FC = ({ onCloseOthers, onPin, onPopOut, + resourceScope, runCommand, showMenu, t, diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx index f22c9690ab..7efb4858ae 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.tsx @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ReviewPlatformPullRequest, ReviewPlatformPullRequestDetailPage, ReviewPlatformWorkspaceSnapshot } from '@/infrastructure/api/service-api/ReviewPlatformAPI'; +import type { ReviewPlatformPullRequest, ReviewPlatformPullRequestDetailPage, ReviewPlatformWorkspaceSnapshot, ReviewRepositoryLocator } from '@/infrastructure/api/service-api/ReviewPlatformAPI'; import { ReviewPlatformPanel } from './ReviewPlatformPanel'; const mocks = vi.hoisted(() => ({ snapshot: vi.fn(), detail: vi.fn(), openExternal: vi.fn(), t: (key: string) => key })); @@ -51,6 +51,16 @@ function deferred() { let root: Root; let host: HTMLDivElement; let testNumber = 0; +// The panel keys its module-level caches by workspace ID, so every test mounts +// a distinct workspace; the path is only the IO operand sent alongside it. +const workspaceIdFor = (path: string) => `workspace:${path}`; +const locator = (path: string): ReviewRepositoryLocator => ({ workspaceId: workspaceIdFor(path), repositoryPath: path }); +type SnapshotMock = (repository: ReviewRepositoryLocator, remote: string | null, page: number, size: number, state: string) => Promise; +const snapshotFor = (mutate?: (result: ReviewPlatformWorkspaceSnapshot) => void): SnapshotMock => ({ repositoryPath }, _remote, page, _size, state) => { + const result = snapshot(repositoryPath, page, state); + mutate?.(result); + return Promise.resolve(result); +}; async function click(id: string) { const selector = id.startsWith('review-platform-filter-') ? `[data-openbitfun-product-part="filters"] [data-openbitfun-value="${id.slice('review-platform-filter-'.length)}"]` @@ -59,7 +69,7 @@ async function click(id: string) { } async function mount() { const path = `/gitee-panel-test-${++testNumber}`; - await act(async () => { root.render(); }); + await act(async () => { root.render(); }); return path; } @@ -78,22 +88,21 @@ describe('Gitee panel state and asynchronous request ordering', () => { host = document.createElement('div'); document.body.append(host); root = createRoot(host); - mocks.snapshot.mockReset().mockImplementation((path, _remote, page, _size, state) => Promise.resolve(snapshot(path, page, state))); + mocks.snapshot.mockReset().mockImplementation(snapshotFor()); mocks.detail.mockReset().mockImplementation(() => new Promise(() => {})); mocks.openExternal.mockReset().mockResolvedValue(undefined); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); it('uses list statistics in the selected detail while its overview is still pending', async () => { - mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { - const result = snapshot(path, page, state); + mocks.snapshot.mockImplementation(snapshotFor(result => { result.pullRequests = result.pullRequests.map((pr, index) => ({ ...pr, changedFiles: index, changedFileCountKnown: true, additions: index * 4, deletions: index * 2, lineStatsKnown: true, })); - return Promise.resolve(result); - }); - await mount(); + })); + const path = await mount(); + expect(mocks.snapshot).toHaveBeenCalledWith(locator(path), null, 1, 10, 'all'); const rows = [...host.querySelectorAll('[data-testid="review-platform-pr-row"]')]; expect(rows).toHaveLength(10); for (const [index, row] of rows.entries()) { @@ -103,16 +112,15 @@ describe('Gitee panel state and asynchronous request ordering', () => { expect(statisticText(host, 'detail-deletions')).toBe(`-${index * 2}`); } expect(mocks.detail.mock.calls.map(([request]) => request.pullRequestId)).toEqual(Array.from({ length: 10 }, (_, index) => String(10 + index))); + expect(mocks.detail).toHaveBeenCalledWith(expect.objectContaining(locator(path))); }); it.each([true, false])('uses complete list statistics for unknown detail counts only at the same revisions (%s)', async sameRevisions => { - mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { - const result = snapshot(path, page, state); + mocks.snapshot.mockImplementation(snapshotFor(result => { result.pullRequests = result.pullRequests.map(pr => ({ ...pr, changedFiles: 2, changedFileCountKnown: true, additions: 4, deletions: 3, lineStatsKnown: true, })); - return Promise.resolve(result); - }); + })); mocks.detail.mockImplementation(({ pullRequestId, section }) => Promise.resolve({ ...detail(Number(pullRequestId), section), headRevision: (sameRevisions ? 'b' : 'c').repeat(40), @@ -125,13 +133,11 @@ describe('Gitee panel state and asynchronous request ordering', () => { }); it('replaces list statistics with authoritative zero counts from a legacy overview', async () => { - mocks.snapshot.mockImplementation((path, _remote, page, _size, state) => { - const result = snapshot(path, page, state); + mocks.snapshot.mockImplementation(snapshotFor(result => { result.pullRequests = result.pullRequests.map(pr => ({ ...pr, changedFiles: 2, changedFileCountKnown: true, additions: 4, deletions: 3, lineStatsKnown: undefined, })); - return Promise.resolve(result); - }); + })); mocks.detail.mockImplementation(({ pullRequestId, section }) => Promise.resolve({ ...detail(Number(pullRequestId), section), changedFiles: 0, changedFileCountKnown: section === 'overview', @@ -151,7 +157,7 @@ describe('Gitee panel state and asynchronous request ordering', () => { mocks.snapshot.mockReturnValueOnce(pending.promise); await click('review-platform-refresh'); await click('review-platform-filter-merged'); - expect(mocks.snapshot).toHaveBeenLastCalledWith(path, null, 1, 10, 'merged'); + expect(mocks.snapshot).toHaveBeenLastCalledWith(locator(path), null, 1, 10, 'merged'); await act(async () => pending.resolve(snapshot(path, 2))); expect(host.querySelector('[data-testid="review-platform-pagination"]')?.textContent).toContain('1-10 of 269'); expect([...host.querySelectorAll('[data-testid="review-platform-pr-row"]')].map(row => row.getAttribute('data-pr-state'))).toEqual(Array(10).fill('merged')); diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx index 483c88f35d..9f82ad72a4 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.trust.test.tsx @@ -43,6 +43,7 @@ vi.mock('@/infrastructure/markdown', () => ({ MarkdownRenderer: () => null })); let dom: { window: Window & typeof globalThis }; let root: Root; let container: HTMLDivElement; +const workspaceId = 'workspace-review-trust-test'; const workspacePath = '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/workspace/review-trust-test'; const error = new TauriCommandError('Command failed', { command: 'review_platform_get_workspace_snapshot', @@ -73,8 +74,12 @@ afterEach(() => { describe('Review platform trust interaction', () => { it.each([false, true])('does not prompt during automatic loading (detailOnly=%s)', async (detailOnly) => { - await act(async () => root.render()); + await act(async () => root.render()); expect(detailOnly ? mocks.context : mocks.snapshot).toHaveBeenCalledTimes(1); + expect(detailOnly ? mocks.context : mocks.snapshot).toHaveBeenCalledWith( + { workspaceId, repositoryPath: workspacePath }, + ...(detailOnly ? [null] : [null, 1, 10, 'all']), + ); expect(mocks.confirm).not.toHaveBeenCalled(); expect(mocks.trust).not.toHaveBeenCalled(); expect(container.textContent).toContain('panels/git:trust.required'); @@ -84,12 +89,16 @@ describe('Review platform trust interaction', () => { it.each([true, false])('asks on Retry and replays only after approval (approved=%s)', async (approved) => { mocks.confirm.mockResolvedValue(approved); mocks.trust.mockResolvedValue({ state: 'trusted', repositoryPath: workspacePath }); - await act(async () => root.render()); + await act(async () => root.render()); const retry = Array.from(container.querySelectorAll('button')).find(button => button.textContent === 'Retry'); expect(retry).toBeTruthy(); await act(async () => retry!.click()); expect(mocks.confirm).toHaveBeenCalledTimes(1); expect(mocks.trust).toHaveBeenCalledTimes(approved ? 1 : 0); + if (approved) { + // Trust is granted to the workspace by ID; the path only names the repository inside it. + expect(mocks.trust).toHaveBeenCalledWith({ workspaceId, repositoryPath: workspacePath }); + } expect(mocks.snapshot).toHaveBeenCalledTimes(approved ? 3 : 2); expect(container.textContent).toContain('panels/git:trust.required'); }); diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index fb524ac085..fddd721104 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -89,6 +89,7 @@ const log = createLogger('ReviewPlatformPanel'); interface ReviewPlatformPanelProps { workspacePath?: string; + workspaceId: string; initialRemoteId?: string; initialPullRequestId?: string; initialPullRequestUrl?: string; @@ -194,20 +195,22 @@ function detailPageInfo(pagination: ReviewPlatformPagination, itemCount: number) }; } -function snapshotCacheKey(workspacePath: string, remoteId: string | null, page: number, perPage: number, mode: 'list' | 'context', state: ListStateFilter): string { - return `${workspacePath}::${remoteId ?? 'default'}::${page}::${perPage}::${mode}::${state}`; +// Caches are keyed by the owning workspace ID, never by path: two workspaces +// (for example a local checkout and a remote one) may share a root path. +function snapshotCacheKey(workspaceId: string, remoteId: string | null, page: number, perPage: number, mode: 'list' | 'context', state: ListStateFilter): string { + return `${workspaceId}::${remoteId ?? 'default'}::${page}::${perPage}::${mode}::${state}`; } -function detailCacheKey(workspacePath: string, remoteId: string, pullRequestId: string): string { - return `${workspacePath}::${remoteId}::${pullRequestId}`; +function detailCacheKey(workspaceId: string, remoteId: string, pullRequestId: string): string { + return `${workspaceId}::${remoteId}::${pullRequestId}`; } -function detailPageCacheKey(workspacePath: string, remoteId: string, pullRequestId: string, section: ReviewPlatformDetailSection, page: number, perPage: number): string { - return `${workspacePath}::${remoteId}::${pullRequestId}::${section}::${page}::${perPage}`; +function detailPageCacheKey(workspaceId: string, remoteId: string, pullRequestId: string, section: ReviewPlatformDetailSection, page: number, perPage: number): string { + return `${workspaceId}::${remoteId}::${pullRequestId}::${section}::${page}::${perPage}`; } -function clearDetailPageCacheForPullRequest(workspacePath: string, remoteId: string, pullRequestId: string): void { - const prefix = `${workspacePath}::${remoteId}::${pullRequestId}::`; +function clearDetailPageCacheForPullRequest(workspaceId: string, remoteId: string, pullRequestId: string): void { + const prefix = `${workspaceId}::${remoteId}::${pullRequestId}::`; for (const key of detailPageCache.keys()) { if (key.startsWith(prefix)) { detailPageCache.delete(key); @@ -241,23 +244,37 @@ function mergeDetailPage( }; } -function remotePreferenceKey(workspacePath: string): string { +function remotePreferenceKey(workspaceId: string): string { + return `${REMOTE_STORAGE_PREFIX}id:${workspaceId}`; +} + +/** Pre-ID installs stored the preference under the workspace path. */ +function legacyRemotePreferenceKey(workspacePath: string): string { return `${REMOTE_STORAGE_PREFIX}${workspacePath}`; } -function readRememberedRemote(workspacePath?: string): string | null { - if (!workspacePath || typeof window === 'undefined') return null; +function readRememberedRemote(workspaceId: string, workspacePath?: string): string | null { + if (!workspaceId || typeof window === 'undefined') return null; try { - return window.localStorage.getItem(remotePreferenceKey(workspacePath)); + const remembered = window.localStorage.getItem(remotePreferenceKey(workspaceId)); + if (remembered !== null) return remembered; + if (!workspacePath) return null; + // Migrate the legacy path-keyed preference onto the workspace ID. + const legacy = window.localStorage.getItem(legacyRemotePreferenceKey(workspacePath)); + if (legacy !== null) { + window.localStorage.setItem(remotePreferenceKey(workspaceId), legacy); + window.localStorage.removeItem(legacyRemotePreferenceKey(workspacePath)); + } + return legacy; } catch { return null; } } -function rememberRemote(workspacePath: string | undefined, remoteId: string | null): void { - if (!workspacePath || typeof window === 'undefined') return; +function rememberRemote(workspaceId: string, remoteId: string | null): void { + if (!workspaceId || typeof window === 'undefined') return; try { - const key = remotePreferenceKey(workspacePath); + const key = remotePreferenceKey(workspaceId); if (remoteId) { window.localStorage.setItem(key, remoteId); } else { @@ -688,6 +705,7 @@ function canExpandCiItem(remote: ReviewPlatformRemote | null, item: ReviewPlatfo export const ReviewPlatformPanel: React.FC = ({ workspacePath, + workspaceId, initialRemoteId, initialPullRequestId, initialPullRequestUrl, @@ -831,13 +849,13 @@ export const ReviewPlatformPanel: React.FC = ({ const requestedRemoteId = nextRemoteId !== undefined ? nextRemoteId : detailOnly - ? readRememberedRemote(workspacePath) + ? readRememberedRemote(workspaceId, workspacePath) : null; const requestedPage = Math.max(1, options?.page ?? 1); const requestedState = detailOnly ? 'all' : options?.state ?? serverStateFilter.current; const snapshotMode = detailOnly ? 'context' : 'list'; setListRemoteId(requestedRemoteId ?? null); - const requestedCacheKey = snapshotCacheKey(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState); + const requestedCacheKey = snapshotCacheKey(workspaceId, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState); const cached = snapshotCache.get(requestedCacheKey); const force = options?.force === true; @@ -864,24 +882,25 @@ export const ReviewPlatformPanel: React.FC = ({ setLoading(true); setError(null); try { + const repository = { workspaceId, repositoryPath: workspacePath }; const fetchSnapshot = () => detailOnly - ? reviewPlatformAPI.getWorkspaceContext(workspacePath, requestedRemoteId ?? null) + ? reviewPlatformAPI.getWorkspaceContext(repository, requestedRemoteId ?? null) : reviewPlatformAPI.getWorkspaceSnapshot( - workspacePath, + repository, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE, requestedState, ); const next = options?.userInitiated - ? await withGitRepositoryTrustRecovery(fetchSnapshot, { userInitiated: true }) + ? await withGitRepositoryTrustRecovery(fetchSnapshot, { workspaceId, repositoryPath: workspacePath }, { userInitiated: true }) : await fetchSnapshot(); if (snapshotRequestSeq.current !== requestSeq) return; setSnapshot(next); const remoteId = next.selectedRemoteId ?? next.remotes[0]?.id ?? null; setSelectedRemoteId(remoteId); setPageIndex(Math.max(0, (next.pagination.page || requestedPage) - 1)); - rememberRemote(workspacePath, remoteId); + rememberRemote(workspaceId, remoteId); setSelectedPrId(detailOnly ? null : next.pullRequests[0]?.id ?? null); setDetail(null); setVerifiedDetailKey(null); @@ -889,7 +908,7 @@ export const ReviewPlatformPanel: React.FC = ({ const entry = { snapshot: next, fetchedAt: Date.now() }; snapshotCache.set(requestedCacheKey, entry); if (remoteId) { - snapshotCache.set(snapshotCacheKey(workspacePath, remoteId, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState), entry); + snapshotCache.set(snapshotCacheKey(workspaceId, remoteId, requestedPage, PR_PAGE_SIZE, snapshotMode, requestedState), entry); } } catch (err) { if (snapshotRequestSeq.current !== requestSeq) return; @@ -901,13 +920,13 @@ export const ReviewPlatformPanel: React.FC = ({ setLoading(false); } } - }, [detailOnly, workspacePath]); + }, [detailOnly, workspacePath, workspaceId]); const loadDetail = useCallback(async (repo: ReviewPlatformRepositoryRef | null, remoteId: string, pullRequestId: string, options?: { force?: boolean }) => { const requestSeq = ++detailRequestSeq.current; detailSectionRequestSeq.current += 1; const repositoryPath = workspacePath || repo?.workspacePath || ''; - const cacheKey = detailCacheKey(repositoryPath, remoteId, pullRequestId); + const cacheKey = detailCacheKey(workspaceId, remoteId, pullRequestId); const cached = detailCache.get(cacheKey); const force = options?.force === true; @@ -915,7 +934,7 @@ export const ReviewPlatformPanel: React.FC = ({ setVerifiedDetailKey(null); if (force) { detailCache.delete(cacheKey); - clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + clearDetailPageCacheForPullRequest(workspaceId, remoteId, pullRequestId); } if (cached && !force) { @@ -927,6 +946,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailLoading(true); try { const nextDetail = await reviewPlatformAPI.getPullRequestDetailPage({ + workspaceId, repositoryPath, remoteId, pullRequestId, @@ -936,7 +956,7 @@ export const ReviewPlatformPanel: React.FC = ({ }); if (detailRequestSeq.current !== requestSeq) return; if (cached && !samePullRequestRevisions(cached.detail, nextDetail)) { - clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + clearDetailPageCacheForPullRequest(workspaceId, remoteId, pullRequestId); } setDetail((current) => mergeRevalidatedPullRequestOverview(current, nextDetail)); detailCache.set(cacheKey, { detail: nextDetail, fetchedAt: Date.now() }); @@ -953,7 +973,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailLoading(false); } } - }, [workspacePath]); + }, [workspacePath, workspaceId]); const applySectionPagination = useCallback((section: Exclude, pagination: ReviewPlatformPagination) => { if (section === 'ci') { @@ -978,8 +998,8 @@ export const ReviewPlatformPanel: React.FC = ({ ) => { const repositoryPath = workspacePath || repo?.workspacePath || ''; const page = Math.max(1, pageIndex + 1); - const cacheKey = detailPageCacheKey(repositoryPath, remoteId, pullRequestId, section, page, perPage); - const overviewCacheKey = detailCacheKey(repositoryPath, remoteId, pullRequestId); + const cacheKey = detailPageCacheKey(workspaceId, remoteId, pullRequestId, section, page, perPage); + const overviewCacheKey = detailCacheKey(workspaceId, remoteId, pullRequestId); const cached = detailPageCache.get(cacheKey); const force = options?.force === true; const matchesVerifiedOverview = (pageDetail: ReviewPlatformPullRequestDetail) => { @@ -989,7 +1009,7 @@ export const ReviewPlatformPanel: React.FC = ({ if (cached && !force) { if (!matchesVerifiedOverview(cached.detail)) { - clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + clearDetailPageCacheForPullRequest(workspaceId, remoteId, pullRequestId); void loadDetail(repo, remoteId, pullRequestId, { force: true }); return; } @@ -1003,6 +1023,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailError(null); try { const nextPage = await reviewPlatformAPI.getPullRequestDetailPage({ + workspaceId, repositoryPath, remoteId, pullRequestId, @@ -1012,7 +1033,7 @@ export const ReviewPlatformPanel: React.FC = ({ }); if (detailSectionRequestSeq.current !== requestSeq) return; if (!matchesVerifiedOverview(nextPage)) { - clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + clearDetailPageCacheForPullRequest(workspaceId, remoteId, pullRequestId); void loadDetail(repo, remoteId, pullRequestId, { force: true }); return; } @@ -1028,7 +1049,7 @@ export const ReviewPlatformPanel: React.FC = ({ setDetailLoading(false); } } - }, [applySectionPagination, loadDetail, workspacePath]); + }, [applySectionPagination, loadDetail, workspacePath, workspaceId]); useEffect(() => { serverStateFilter.current = 'all'; @@ -1094,7 +1115,7 @@ export const ReviewPlatformPanel: React.FC = ({ if (nextRemoteId && selectedRemoteId !== nextRemoteId) { setSelectedRemoteId(nextRemoteId); - rememberRemote(workspacePath, nextRemoteId); + rememberRemote(workspaceId, nextRemoteId); } if (selectedPrId !== targetPullRequestId) { @@ -1111,6 +1132,7 @@ export const ReviewPlatformPanel: React.FC = ({ snapshot.remotes, snapshot.selectedRemoteId, workspacePath, + workspaceId, ]); useEffect(() => { @@ -1138,7 +1160,7 @@ export const ReviewPlatformPanel: React.FC = ({ useEffect(() => { if (!hasDetail || !selectedRemoteId || !selectedPrId || (!repository && !workspacePath)) return; - if (verifiedDetailKey !== detailCacheKey(workspacePath || repository?.workspacePath || '', selectedRemoteId, selectedPrId)) return; + if (verifiedDetailKey !== detailCacheKey(workspaceId, selectedRemoteId, selectedPrId)) return; let disposed = false; if (activeTab === 'overview') { void (async () => { @@ -1171,6 +1193,7 @@ export const ReviewPlatformPanel: React.FC = ({ selectedRemoteId, verifiedDetailKey, workspacePath, + workspaceId, ]); const visiblePullRequests = useMemo(() => { @@ -1194,7 +1217,7 @@ export const ReviewPlatformPanel: React.FC = ({ ? flowState.sessions.get(flowState.activeSessionId) : undefined; const sameWorkspace = (session?: Session) => - Boolean(session && (!workspacePath || normalizePath(session.workspacePath ?? '') === normalizePath(workspacePath))); + Boolean(session && session.workspaceId === workspaceId); if (activeSession?.sessionKind === 'normal' && sameWorkspace(activeSession)) { return activeSession; @@ -1214,11 +1237,11 @@ export const ReviewPlatformPanel: React.FC = ({ return sessions .filter(session => session.sessionKind === 'normal' && sameWorkspace(session)) .sort((left, right) => (right.lastActiveAt || right.updatedAt || right.createdAt) - (left.lastActiveAt || left.updatedAt || left.createdAt))[0]; - }, [flowState.activeSessionId, flowState.sessions, workspacePath]); + }, [flowState.activeSessionId, flowState.sessions, workspaceId]); const currentPullRequest = detail ?? selectedPr; const selectedDetailKey = selectedRemoteId && selectedPrId - ? detailCacheKey(workspacePath || repository?.workspacePath || '', selectedRemoteId, selectedPrId) + ? detailCacheKey(workspaceId, selectedRemoteId, selectedPrId) : null; const currentRevisionsVerified = Boolean( selectedDetailKey && verifiedDetailKey === selectedDetailKey, @@ -1322,10 +1345,10 @@ export const ReviewPlatformPanel: React.FC = ({ setStateFilter('all'); serverStateFilter.current = 'all'; setPageIndex(0); - rememberRemote(workspacePath, remoteId || null); + rememberRemote(workspaceId, remoteId || null); setSnapshot(emptySnapshot()); void loadSnapshot(remoteId || null, { page: 1, state: 'all' }); - }, [loadSnapshot, workspacePath]); + }, [loadSnapshot, workspaceId]); const handleStateChange = useCallback((state: ListStateFilter) => { setPanelView('list'); @@ -1444,6 +1467,7 @@ export const ReviewPlatformPanel: React.FC = ({ try { const nextLog = await reviewPlatformAPI.getPullRequestCiLog({ + workspaceId, repositoryPath, remoteId: selectedRemoteId, pullRequestId: selectedPrId, @@ -1464,7 +1488,7 @@ export const ReviewPlatformPanel: React.FC = ({ return next; }); } - }, [ciLogById, repository, selectedPrId, selectedRemote, selectedRemoteId, workspacePath]); + }, [ciLogById, repository, selectedPrId, selectedRemote, selectedRemoteId, workspacePath, workspaceId]); const toggleCiExpanded = useCallback((item: ReviewPlatformCiItem) => { if (expandedCiItemIds.has(item.id)) { @@ -1550,7 +1574,7 @@ export const ReviewPlatformPanel: React.FC = ({ let ownsSharedLaunch = false; try { const reviewTarget = await reviewPlatformAPI.getPullRequestReviewTarget( - workspacePath, + { workspaceId, repositoryPath: workspacePath }, selectedRemote.id, selectedPr.id, ); @@ -1578,7 +1602,7 @@ export const ReviewPlatformPanel: React.FC = ({ baseRevision: freshPullRequest.baseRevision, headRevision: freshPullRequest.headRevision, }); - const cacheKey = detailCacheKey(workspacePath, selectedRemote.id, selectedPr.id); + const cacheKey = detailCacheKey(workspaceId, selectedRemote.id, selectedPr.id); setDetail((current) => current ? { ...current, ...reviewTarget.pullRequest } : current); setSnapshot((current) => ({ ...current, @@ -1601,6 +1625,7 @@ export const ReviewPlatformPanel: React.FC = ({ } const prepared = await prepareReviewLaunchFromPullRequest({ workspacePath, + workspaceId, remote: selectedRemote, repository, reviewTarget, @@ -1655,6 +1680,7 @@ export const ReviewPlatformPanel: React.FC = ({ selectedPr, selectedRemote, workspacePath, + workspaceId, ]); const handleAddFileDiffContext = useCallback(async (file: ReviewPlatformFile) => { @@ -2038,7 +2064,7 @@ export const ReviewPlatformPanel: React.FC = ({ onClick={() => { if (pr.providerId && pr.providerId !== selectedRemoteId) { setSelectedRemoteId(pr.providerId); - rememberRemote(workspacePath, pr.providerId); + rememberRemote(workspaceId, pr.providerId); } panelFocusRequested.current = true; setPanelView('detail'); diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx index 0a7fd40dfb..a3fc7a6360 100644 --- a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx @@ -16,11 +16,8 @@ import './ScheduledJobsModal.scss'; interface ScheduledJobsModalProps { isOpen: boolean; onClose: () => void; - workspacePath?: string; workspaceId?: string; workspaceKind?: WorkspaceKind; - remoteConnectionId?: string | null; - remoteSshHost?: string | null; sessionId?: string; targetKind: CronJobTargetKind; lockSessionId?: boolean; @@ -32,11 +29,8 @@ interface ScheduledJobsModalProps { const ScheduledJobsModal: React.FC = ({ isOpen, onClose, - workspacePath, workspaceId, workspaceKind, - remoteConnectionId, - remoteSshHost, sessionId, targetKind, lockSessionId = false, @@ -61,11 +55,8 @@ const ScheduledJobsModal: React.FC = ({
= ({ - workspacePath, workspaceId, workspaceKind, - remoteConnectionId, - remoteSshHost, sessionId, headerTitle, targetLabel, @@ -222,8 +216,8 @@ const ScheduledJobsView: React.FC = ({ [assistantWorkspaceMode, targetKind, workspaceKind], ); const workspaceRef = useMemo( - () => buildWorkspaceRef(workspacePath, workspaceId, remoteConnectionId, remoteSshHost), - [remoteConnectionId, remoteSshHost, workspaceId, workspacePath], + () => buildWorkspaceRef(workspaceId), + [workspaceId], ); const [draft, setDraft] = useState(() => @@ -269,18 +263,10 @@ const ScheduledJobsView: React.FC = ({ return Array.from(flowChatState.sessions.values()) .filter(s => { if (s.parentSessionId) return false; - if (workspaceId && s.workspaceId && s.workspaceId !== workspaceId) return false; - const trimmedWorkspacePath = workspacePath?.trim() ?? ''; - if (!trimmedWorkspacePath) return !s.workspacePath; - return sessionBelongsToWorkspaceNavRow( - s, - trimmedWorkspacePath, - remoteConnectionId, - remoteSshHost, - ); + return Boolean(workspaceId && sessionBelongsToWorkspaceNavRow(s, workspaceId)); }) .sort(compareSessionsForDisplay); - }, [flowChatState.sessions, remoteConnectionId, remoteSshHost, workspaceId, workspacePath]); + }, [flowChatState.sessions, workspaceId]); const defaultSessionIdForWorkspace = useMemo( () => sessionId || workspaceSessions[0]?.sessionId || '', @@ -294,11 +280,10 @@ const ScheduledJobsView: React.FC = ({ }), [jobs]); const loadJobs = useCallback(async () => { + if (!workspaceRef) { setJobs([]); return; } setLoading(true); const request = { - workspacePath: workspaceRef?.workspacePath, workspaceId: workspaceRef?.workspaceId ?? undefined, - remoteConnectionId: workspaceRef?.remoteConnectionId ?? undefined, sessionId: targetKind === 'session' && lockSessionId && !assistantWorkspaceMode ? sessionId || undefined : undefined, targetKind: assistantWorkspaceMode ? undefined : targetKind, }; diff --git a/src/web-ui/src/app/components/scheduled-jobs/scheduledJobDraft.ts b/src/web-ui/src/app/components/scheduled-jobs/scheduledJobDraft.ts index 023e962516..9dacec398b 100644 --- a/src/web-ui/src/app/components/scheduled-jobs/scheduledJobDraft.ts +++ b/src/web-ui/src/app/components/scheduled-jobs/scheduledJobDraft.ts @@ -8,12 +8,11 @@ import type { CronJob, - CronJobTarget, + CronJobTargetRequest, CronJobTargetKind, CronSchedule, - CronWorkspaceRef, + } from '@/infrastructure/api'; -import { normalizePath } from '@/shared/utils/pathUtils'; export const MINUTE_IN_MS = 60_000; export const HOUR_IN_MS = 60 * MINUTE_IN_MS; @@ -184,30 +183,16 @@ export function buildScheduleFromDraft(draft: JobDraft): CronSchedule { return { kind: 'cron', expr: draft.expr.trim(), tz: draft.tz.trim() || undefined }; } -export function buildWorkspaceRef( - workspacePath?: string, - workspaceId?: string, - remoteConnectionId?: string | null, - remoteSshHost?: string | null, -): CronWorkspaceRef | null { - const normalizedWorkspacePath = normalizePath(workspacePath?.trim() ?? ''); - if (!normalizedWorkspacePath) { - return null; - } - - return { - workspacePath: normalizedWorkspacePath, - workspaceId: workspaceId?.trim() || undefined, - remoteConnectionId: remoteConnectionId?.trim() || undefined, - remoteSshHost: remoteSshHost?.trim() || undefined, - }; +export function buildWorkspaceRef(workspaceId?: string): { workspaceId: string } | null { + const id = workspaceId?.trim(); + return id ? { workspaceId: id } : null; } export function buildTargetFromDraft( targetKind: CronJobTargetKind, draft: JobDraft, - workspace: CronWorkspaceRef, -): CronJobTarget { + workspace: { workspaceId: string }, +): CronJobTargetRequest { if (targetKind === 'session') { return { kind: 'session', diff --git a/src/web-ui/src/app/global-search/globalSearchActivator.ts b/src/web-ui/src/app/global-search/globalSearchActivator.ts index d1fd710f2f..d610544e60 100644 --- a/src/web-ui/src/app/global-search/globalSearchActivator.ts +++ b/src/web-ui/src/app/global-search/globalSearchActivator.ts @@ -46,9 +46,7 @@ export async function activateGlobalSearchTarget( await context.setActiveWorkspace(target.workspaceId); const available = await flowChatStore.ensurePersistedSessionMetadata( target.sessionId, - target.workspacePath, - target.remoteConnectionId, - target.remoteSshHost, + target.workspaceId, ); if (!available) { throw new Error(context.tCommon('nav.search.errors.sessionUnavailable')); @@ -74,6 +72,7 @@ export async function activateGlobalSearchTarget( openFileInBestTarget({ filePath: target.filePath, fileName: target.fileName, + workspaceId: target.workspaceId, workspacePath: target.workspacePath, jumpToLine: target.lineNumber, }); diff --git a/src/web-ui/src/app/global-search/providers/fileSearchProvider.test.ts b/src/web-ui/src/app/global-search/providers/fileSearchProvider.test.ts index a7535e23d6..5db2271692 100644 --- a/src/web-ui/src/app/global-search/providers/fileSearchProvider.test.ts +++ b/src/web-ui/src/app/global-search/providers/fileSearchProvider.test.ts @@ -43,7 +43,7 @@ describe('fileSearchProvider remote routing', () => { searchContentMock.mockReset().mockResolvedValue({ results: [], truncated: false }); }); - it('never sends an SSH workspace path to the legacy local-capable file commands', async () => { + it('only queries IDs whose workspace kind supports content search', async () => { const local = workspace('local', WorkspaceKind.Normal); const remote = workspace('remote', WorkspaceKind.Remote); @@ -55,7 +55,7 @@ describe('fileSearchProvider remote routing', () => { expect(searchFilenamesMock).toHaveBeenCalledTimes(1); expect(searchContentMock).toHaveBeenCalledTimes(1); expect(searchFilenamesMock).toHaveBeenCalledWith( - local.rootPath, + local.id, 'needle', false, false, @@ -65,7 +65,7 @@ describe('fileSearchProvider remote routing', () => { false, ); expect(searchFilenamesMock).not.toHaveBeenCalledWith( - remote.rootPath, + remote.id, expect.anything(), expect.anything(), expect.anything(), diff --git a/src/web-ui/src/app/global-search/providers/fileSearchProvider.ts b/src/web-ui/src/app/global-search/providers/fileSearchProvider.ts index 055bd53003..211e87a3f2 100644 --- a/src/web-ui/src/app/global-search/providers/fileSearchProvider.ts +++ b/src/web-ui/src/app/global-search/providers/fileSearchProvider.ts @@ -51,17 +51,13 @@ export const fileSearchProvider: GlobalSearchProvider = { search: async (request, signal) => { if (request.scope === 'actions' || request.query.length < 2) return { items: [] }; - // The legacy file-search commands do not yet carry a remote connection - // identity. Never hand an SSH path to that local-capable boundary: session - // content search has its own audited remote route, while remote file search - // remains explicitly unavailable until the workspace-search contract can - // address one concrete remote owner. + // Remote content search remains an explicit unsupported capability. const remoteWorkspaces = request.workspaces.filter(isRemoteWorkspace); const searchableWorkspaces = request.workspaces.filter(workspace => !isRemoteWorkspace(workspace)); const results = await Promise.allSettled(searchableWorkspaces.map(async (workspace) => { const [names, contents] = await Promise.all([ workspaceAPI.searchFilenamesOnlyDetailed( - workspace.rootPath, + workspace.id, request.query, false, false, @@ -71,7 +67,7 @@ export const fileSearchProvider: GlobalSearchProvider = { false, ), workspaceAPI.searchContentOnlyDetailed( - workspace.rootPath, + workspace.id, request.query, false, false, diff --git a/src/web-ui/src/app/global-search/providers/sessionContentSearchProvider.ts b/src/web-ui/src/app/global-search/providers/sessionContentSearchProvider.ts index 2d7640697b..87cd6c4f70 100644 --- a/src/web-ui/src/app/global-search/providers/sessionContentSearchProvider.ts +++ b/src/web-ui/src/app/global-search/providers/sessionContentSearchProvider.ts @@ -47,6 +47,7 @@ export const sessionContentSearchProvider: GlobalSearchProvider = { const results = await Promise.allSettled(request.workspaces.map(async (workspace) => ({ workspace, response: await sessionAPI.searchSessionContent({ + workspaceId: workspace.id, workspacePath: workspace.rootPath, remoteConnectionId: workspace.connectionId, remoteSshHost: workspace.sshHost, diff --git a/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx b/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx index e6d2dab029..08b4c8e6c4 100644 --- a/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx +++ b/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx @@ -7,13 +7,13 @@ import { sessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; import type { Session } from '@/flow_chat/types/flow-chat'; import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; -const { readFileContent, submit, t } = vi.hoisted(() => ({ - readFileContent: vi.fn(), +const { readWorkspaceFile, submit, t } = vi.hoisted(() => ({ + readWorkspaceFile: vi.fn(), submit: vi.fn(), t: (key: string) => key, })); vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ - workspaceAPI: { readFileContent }, + workspaceAPI: { readWorkspaceFile }, })); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { ensureAssistantBootstrap: submit, startDialogTurn: submit }, @@ -22,7 +22,7 @@ vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t }) })); function session(overrides: Partial = {}): Session { return { - sessionId: 'claw-1', mode: 'Claw', workspacePath: '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/assistants/default', + sessionId: 'claw-1', mode: 'Claw', workspaceId: 'assistant-default', workspacePath: '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/assistants/default', config: {}, dialogTurns: [], status: 'idle', title: 'Claw', createdAt: 1, lastActiveAt: 1, error: null, ...overrides, @@ -44,7 +44,7 @@ describe('assistant bootstrap draft', () => { resetDeviceSurfaceForTest(); sessionComposerStore.setState({ drafts: {} }); vi.clearAllMocks(); - readFileContent.mockResolvedValue('# Bootstrap'); + readWorkspaceFile.mockResolvedValue('# Bootstrap'); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -60,36 +60,36 @@ describe('assistant bootstrap draft', () => { it('prefills an empty Claw composer without starting a turn', async () => { await render(); - expect(readFileContent).toHaveBeenCalledWith('/assistants/default/BOOTSTRAP.md', undefined, undefined); + expect(readWorkspaceFile).toHaveBeenCalledWith('assistant-default', '/assistants/default/BOOTSTRAP.md'); expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe('nav.sessions.assistantBootstrapDraft'); expect(submit).not.toHaveBeenCalled(); }); it('leaves initialized assistants and existing conversations alone', async () => { - readFileContent.mockRejectedValueOnce(new Error('No such file')); + readWorkspaceFile.mockRejectedValueOnce(new Error('No such file')); await render(); expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); - readFileContent.mockClear(); + readWorkspaceFile.mockClear(); await render(session({ sessionId: 'existing', totalTurnCount: 3 })); - expect(readFileContent).not.toHaveBeenCalled(); + expect(readWorkspaceFile).not.toHaveBeenCalled(); await render(session({ sessionId: 'code', mode: 'Standard' })); - expect(readFileContent).not.toHaveBeenCalled(); + expect(readWorkspaceFile).not.toHaveBeenCalled(); }); it('does not replace a user draft or restore an explicitly cleared template', async () => { sessionComposerStore.getState().setValue('claw-1', 'My own request'); await render(); - expect(readFileContent).not.toHaveBeenCalled(); + expect(readWorkspaceFile).not.toHaveBeenCalled(); sessionComposerStore.getState().clearDraft('claw-1'); await render(session({ sessionId: 'other', mode: 'Standard' })); await render(); - expect(readFileContent).not.toHaveBeenCalled(); + expect(readWorkspaceFile).not.toHaveBeenCalled(); expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); }); it('preserves typing while the bootstrap check is in flight', async () => { let finish!: (content: string) => void; - readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + readWorkspaceFile.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); await render(); sessionComposerStore.getState().setValue('claw-1', 'Already typing'); await act(async () => finish('# Bootstrap')); @@ -106,14 +106,14 @@ describe('assistant bootstrap draft', () => { userMessage: { id: 'user-1', content: 'Initialize', timestamp: 1 }, modelRounds: [], }], })); - expect(readFileContent).toHaveBeenCalledTimes(1); + expect(readWorkspaceFile).toHaveBeenCalledTimes(1); expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); expect(submit).not.toHaveBeenCalled(); }); it('discards a late result after changing sessions', async () => { let finish!: (content: string) => void; - readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + readWorkspaceFile.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); await render(); await render(session({ sessionId: 'other', mode: 'Standard' })); await act(async () => finish('# Bootstrap')); @@ -121,16 +121,24 @@ describe('assistant bootstrap draft', () => { expect(sessionComposerStore.getState().getDraft('other').value).toBe(''); }); - it('routes remote workspace reads and never falls back to a local file', async () => { - readFileContent.mockRejectedValueOnce(new Error('Remote host is offline')); - await render(session({ remoteConnectionId: 'ssh-host', workspacePath: '/home/user/assistant/' })); - expect(readFileContent).toHaveBeenCalledExactlyOnceWith('/home/user/assistant/BOOTSTRAP.md', undefined, 'ssh-host'); + it('routes remote workspace reads through the owning workspace ID and never falls back to a local file', async () => { + readWorkspaceFile.mockRejectedValueOnce(new Error('Remote host is offline')); + await render(session({ + workspaceId: 'remote-assistant', remoteConnectionId: 'ssh-host', workspacePath: '/home/user/assistant/', + })); + expect(readWorkspaceFile).toHaveBeenCalledExactlyOnceWith('remote-assistant', '/home/user/assistant/BOOTSTRAP.md'); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + }); + + it('does not read anything for a session without a workspace ID', async () => { + await render(session({ workspaceId: undefined })); + expect(readWorkspaceFile).not.toHaveBeenCalled(); expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); }); it('rejects a late peer result even when the new surface has the same session id', async () => { let finish!: (content: string) => void; - readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + readWorkspaceFile.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); await render(); activateSurface('peer:other'); await act(async () => finish('# Bootstrap')); diff --git a/src/web-ui/src/app/hooks/useAssistantBootstrap.ts b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts index 295da9b54d..17c4319a0f 100644 --- a/src/web-ui/src/app/hooks/useAssistantBootstrap.ts +++ b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts @@ -5,6 +5,7 @@ import { useI18n } from '@/infrastructure/i18n'; import { sessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; import { isProjectedSessionEmpty } from '@/flow_chat/utils/flowChatTurnIdentity'; import type { Session } from '@/flow_chat/types/flow-chat'; +import { sessionWorkspaceId } from '@/flow_chat/utils/sessionWorkspace'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('AssistantBootstrap'); @@ -17,8 +18,10 @@ export function useAssistantBootstrap( const { t } = useI18n('common'); const scope = getActiveSurfaceScope(); const sessionId = session?.sessionId; + // The session's workspace ID owns the read; the root is only used to build + // the BOOTSTRAP.md path inside it. + const workspaceId = sessionWorkspaceId(session); const workspacePath = session?.workspacePath; - const remoteConnectionId = session?.remoteConnectionId; const isEmptyClaw = session?.mode?.toLowerCase() === 'claw' && isProjectedSessionEmpty(session) && !session.lastSubmittedMode @@ -26,7 +29,7 @@ export function useAssistantBootstrap( && !session.config.dispatchJobId; useEffect(() => { - if (!isEmptyClaw || !sessionId || !workspacePath) return; + if (!isEmptyClaw || !sessionId || !workspaceId || !workspacePath) return; const composer = sessionComposerStore.getState(); const draft = composer.getDraft(sessionId); // An edited or explicitly cleared draft belongs to the user, including on reopen. @@ -35,7 +38,7 @@ export function useAssistantBootstrap( let cancelled = false; const requestScope = getActiveSurfaceScope(); const bootstrapPath = `${workspacePath.replace(/[\\/]+$/, '')}/BOOTSTRAP.md`; - void workspaceAPI.readFileContent(bootstrapPath, undefined, remoteConnectionId).then(() => { + void workspaceAPI.readWorkspaceFile(workspaceId, bootstrapPath).then(() => { if (cancelled || !requestScope.isCurrent()) return; const latest = sessionComposerStore.getState().getDraft(sessionId); if (latest.updatedAt !== draft.updatedAt || latest.value || latest.contexts.length) return; @@ -45,9 +48,9 @@ export function useAssistantBootstrap( // Completed assistants no longer have BOOTSTRAP.md. Other failures remain diagnosable. const message = error instanceof Error ? error.message : String(error); if (!/does not exist|no such file|not found/i.test(message)) { - log.warn('Failed to prepare assistant bootstrap draft', { sessionId, workspacePath, error }); + log.warn('Failed to prepare assistant bootstrap draft', { sessionId, workspaceId, error }); } }); return () => { cancelled = true; }; - }, [isEmptyClaw, onDraftReady, remoteConnectionId, scope.epoch, sessionId, t, workspacePath]); + }, [isEmptyClaw, onDraftReady, scope.epoch, sessionId, t, workspaceId, workspacePath]); } diff --git a/src/web-ui/src/app/hooks/useSnapshot.ts b/src/web-ui/src/app/hooks/useSnapshot.ts index 79f807359d..e97cb5b82b 100644 --- a/src/web-ui/src/app/hooks/useSnapshot.ts +++ b/src/web-ui/src/app/hooks/useSnapshot.ts @@ -2,10 +2,9 @@ * Snapshot system data hook. */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback } from 'react'; import { snapshotAPI } from '../../infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; -import { isRemoteWorkspace } from '@/shared/types'; import { useI18n } from '@/infrastructure/i18n'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -77,16 +76,8 @@ export interface UseSnapshotReturn { export const useSnapshot = (): UseSnapshotReturn => { const { t } = useI18n('errors'); - const { workspace, workspacePath } = useCurrentWorkspace(); - const workspaceRemoteScope = useMemo(() => { - if (!workspace || !isRemoteWorkspace(workspace)) { - return {}; - } - return { - ...(workspace.connectionId ? { remoteConnectionId: workspace.connectionId } : {}), - ...(workspace.sshHost ? { remoteSshHost: workspace.sshHost } : {}), - }; - }, [workspace]); + const { workspace } = useCurrentWorkspace(); + const workspaceId = workspace?.id; const [sessions, setSessions] = useState([]); const [operations, setOperations] = useState([]); const [stats, setStats] = useState(null); @@ -98,8 +89,7 @@ export const useSnapshot = (): UseSnapshotReturn => { try { setError(null); const statsData = await snapshotAPI.getSnapshotStats( - workspacePath || undefined, - workspaceRemoteScope, + workspaceId || undefined, ); setStats(statsData); } catch (err) { @@ -107,15 +97,14 @@ export const useSnapshot = (): UseSnapshotReturn => { setError(t('snapshot.loadStatsFailed')); setStats(null); } - }, [t, workspacePath, workspaceRemoteScope]); + }, [t, workspaceId]); // Load snapshot sessions const loadSessions = useCallback(async () => { try { setError(null); const sessionsData = await snapshotAPI.getSnapshotSessions( - workspacePath || undefined, - workspaceRemoteScope, + workspaceId || undefined, ); setSessions(sessionsData || []); } catch (err) { @@ -123,7 +112,7 @@ export const useSnapshot = (): UseSnapshotReturn => { setError(t('snapshot.loadSessionsFailed')); setSessions([]); } - }, [t, workspacePath, workspaceRemoteScope]); + }, [t, workspaceId]); // Load session operations const loadSessionOperations = useCallback(async (sessionId: string) => { @@ -136,7 +125,7 @@ export const useSnapshot = (): UseSnapshotReturn => { setError(null); try { - const operationsData = await snapshotAPI.getSessionOperations(sessionId, workspacePath || undefined); + const operationsData = await snapshotAPI.getSessionOperations(sessionId, workspaceId || undefined); const operations = operationsData || []; setOperations(operations); return operations; @@ -148,26 +137,26 @@ export const useSnapshot = (): UseSnapshotReturn => { } finally { setLoading(false); } - }, [t, workspacePath]); + }, [t, workspaceId]); // Fetch operation diff const getOperationDiff = useCallback(async (sessionId: string, filePath: string) => { try { setError(null); - const diffData = await snapshotAPI.getOperationDiff(sessionId, filePath, undefined, workspacePath || undefined); + const diffData = await snapshotAPI.getOperationDiff(sessionId, filePath, undefined, workspaceId || undefined); return diffData; } catch (err) { log.error('Failed to get operation diff', err); setError(t('snapshot.getDiffFailed')); throw err; } - }, [t, workspacePath]); + }, [t, workspaceId]); // Accept operation const acceptOperation = useCallback(async (sessionId: string, operationId: string) => { try { setError(null); - await snapshotAPI.acceptOperation(sessionId, operationId, workspacePath || undefined); + await snapshotAPI.acceptOperation(sessionId, operationId, workspaceId || undefined); // Reload data await Promise.all([ @@ -179,13 +168,13 @@ export const useSnapshot = (): UseSnapshotReturn => { log.error('Failed to accept operation', err); setError(t('snapshot.acceptOperationFailed')); } - }, [loadSessionOperations, loadStats, loadSessions, t, workspacePath]); + }, [loadSessionOperations, loadStats, loadSessions, t, workspaceId]); // Reject operation const rejectOperation = useCallback(async (sessionId: string, operationId: string) => { try { setError(null); - await snapshotAPI.rejectOperation(sessionId, operationId, workspacePath || undefined); + await snapshotAPI.rejectOperation(sessionId, operationId, workspaceId || undefined); // Reload data await Promise.all([ @@ -197,13 +186,13 @@ export const useSnapshot = (): UseSnapshotReturn => { log.error('Failed to reject operation', err); setError(t('snapshot.rejectOperationFailed')); } - }, [loadSessionOperations, loadStats, loadSessions, t, workspacePath]); + }, [loadSessionOperations, loadStats, loadSessions, t, workspaceId]); // Roll back the session const rollbackSession = useCallback(async (sessionId: string) => { try { setError(null); - await snapshotAPI.rollbackSession(sessionId, workspacePath || undefined); + await snapshotAPI.rollbackSession(sessionId, workspaceId || undefined); // Reload data await Promise.all([ @@ -215,7 +204,7 @@ export const useSnapshot = (): UseSnapshotReturn => { log.error('Failed to rollback session', err); setError(t('snapshot.rollbackSessionFailed')); } - }, [loadSessionOperations, loadStats, loadSessions, t, workspacePath]); + }, [loadSessionOperations, loadStats, loadSessions, t, workspaceId]); // Update snapshot session info (called on backend create) const updateSnapshotSession = useCallback((session: SnapshotSession) => { diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index c008fef6bd..08f89c93f9 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -307,16 +307,7 @@ const AppLayout: React.FC = ({ className = '' }) => { : explicitPreferredMode; const flowChatManager = FlowChatManager.getInstance(); - const hasHistoricalSessions = await flowChatManager.initialize( - currentWorkspace.rootPath, - initializationPreferredMode, - currentWorkspace.workspaceKind === WorkspaceKind.Remote - ? currentWorkspace.connectionId - : undefined, - currentWorkspace.workspaceKind === WorkspaceKind.Remote - ? currentWorkspace.sshHost - : undefined - ); + const hasHistoricalSessions = await flowChatManager.initialize(currentWorkspace, initializationPreferredMode); if (cancelled) { return; } @@ -583,7 +574,7 @@ const AppLayout: React.FC = ({ className = '' }) => { // Create one unified project session using the user's default Harness policy. const handleCreateFlowChatSession = React.useCallback(async () => { try { - if (!currentWorkspace?.rootPath) { + if (!currentWorkspace?.id) { log.warn('Cannot create FlowChat session without an active workspace'); return; } @@ -608,17 +599,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const handler = (e: Event) => { const clientId = (e as CustomEvent<{ clientId?: string }>).detail?.clientId?.trim(); if (!clientId) return; - const config = currentWorkspace - ? { - workspacePath: currentWorkspace.rootPath, - ...(currentWorkspace.workspaceKind === WorkspaceKind.Remote && currentWorkspace.connectionId - ? { remoteConnectionId: currentWorkspace.connectionId } - : {}), - ...(currentWorkspace.workspaceKind === WorkspaceKind.Remote && currentWorkspace.sshHost - ? { remoteSshHost: currentWorkspace.sshHost } - : {}), - } - : {}; + const config = currentWorkspace ? flowChatSessionConfigForWorkspace(currentWorkspace) : {}; void FlowChatManager.getInstance() .createAcpChatSession(clientId, config) .then(sessionId => openMainSession(sessionId)) diff --git a/src/web-ui/src/app/layout/FloatingMiniChat.tsx b/src/web-ui/src/app/layout/FloatingMiniChat.tsx index c7be45df43..6817a5b6db 100644 --- a/src/web-ui/src/app/layout/FloatingMiniChat.tsx +++ b/src/web-ui/src/app/layout/FloatingMiniChat.tsx @@ -17,6 +17,7 @@ import { useRealtimeVoiceCall } from '@/flow_chat/components/voice/RealtimeVoice import type { VoiceCallTarget } from '@/flow_chat/components/voice/voiceClientContext'; import { useConversationDockStore, dockConversationKey, type DockConversation } from '../stores/conversationDockStore'; import { beginConversationTransfer, endConversationTransfer, isConversationTransfer, dropConversationInDock, returnConversationToWorkbench } from '../services/conversationDockTransfer'; +import { sessionSceneWorkspaceKey } from '../services/sessionSceneTarget'; import { useMiniAppStore, MINIAPP_COMPOSER_DRAFT_EVENT, MINIAPP_COMPOSER_FOCUS_EVENT, type MiniAppDraftEventDetail, type MiniAppFocusEventDetail } from '../scenes/miniapps/miniAppStore'; import { useSceneStore } from '../stores/sceneStore'; import { getMiniAppIdFromSceneId, getMiniAppSceneId } from '../scenes/miniapps/miniAppActivity'; @@ -47,7 +48,7 @@ function DockConversationView({ entry, active, onCollapse, renderHeader, onVoice if (scope.surfaceId !== entry.surfaceId) return; const result = await createControlConversation(entry.sessionId); scope.assertCurrent('open new control conversation'); - useConversationDockStore.getState().add({ ...result, surfaceId: entry.surfaceId, workspaceKey: result.workspacePath, kind: 'control' }); + useConversationDockStore.getState().add({ ...result, surfaceId: entry.surfaceId, workspaceKey: sessionSceneWorkspaceKey(result.workspaceId), kind: 'control' }); }, [entry.surfaceId, entry.sessionId]); const draft = useConversationDockStore(state => state.drafts[key]); const setDraft = (text: string) => useConversationDockStore.getState().setDraft(key, text); @@ -69,7 +70,8 @@ function DockConversationView({ entry, active, onCollapse, renderHeader, onVoice const voiceTarget = useMemo(() => !session ? undefined : entry.kind === 'miniapp' ? { kind: 'miniapp', appId: entry.appId!, appName, claimToken: entry.claimToken!, surfaceId: entry.surfaceId, sessionId: entry.sessionId, workspacePath: session.workspacePath, - } : { kind: entry.kind, surfaceId: entry.surfaceId, sessionId: entry.sessionId, workspacePath: session.workspacePath || '' }, [entry, session, appName]); + } : { kind: entry.kind, surfaceId: entry.surfaceId, sessionId: entry.sessionId, + workspaceId: session.workspaceId ?? session.config.workspaceId, workspacePath: session.workspacePath || '' }, [entry, session, appName]); const unavailable =
{t('dock.unavailable')}
; if (entry.kind === 'control' && session && voiceTarget) return { - if (!disposed) useConversationDockStore.getState().add({ ...result, surfaceId, workspaceKey: result.workspacePath, kind: 'control' }, false); + if (!disposed) useConversationDockStore.getState().add({ ...result, surfaceId, workspaceKey: sessionSceneWorkspaceKey(result.workspaceId), kind: 'control' }, false); }).catch(reason => { if (!disposed && !isSurfaceChangedError(reason)) setError(String(reason instanceof Error ? reason.message : reason)); }); return () => { disposed = true; }; }, [dock.open, surfaceId, retry]); @@ -243,7 +245,8 @@ export function FloatingMiniChat() { diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 61fcdf5207..ebd8a65def 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -209,7 +209,7 @@ const AgentsHomeView: React.FC = () => { } = useUserSkillGroups(); const { - workspacePath, + workspaceId, allAgents, filteredAgents, loading, @@ -603,7 +603,7 @@ const AgentsHomeView: React.FC = () => { if (!ok) return; setDeletingAgent(true); try { - await CustomAgentAPI.deleteCustomAgent(id, workspacePath || undefined); + await CustomAgentAPI.deleteCustomAgent(id, workspaceId); notification.success(t('agentsOverview.deleteSuccess', { name })); closeAgentDetails(); // CustomAgentAPI emits `custom-agent:updated` after the delete; the @@ -616,7 +616,7 @@ const AgentsHomeView: React.FC = () => { } finally { setDeletingAgent(false); } - }, [selectedAgent, closeAgentDetails, notification, t, workspacePath]); + }, [selectedAgent, closeAgentDetails, notification, t, workspaceId]); const startCurrentCapabilityEdit = () => { if (!selectedAgent || savingCapability) return; diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index bb1b6f3c0b..1bf42010dd 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -97,7 +97,8 @@ function defaultSelectedTools( const CreateAgentPage: React.FC = () => { const { t } = useTranslation('scenes/agents'); const notification = useNotification(); - const { hasWorkspace, workspacePath } = useCurrentWorkspace(); + const { hasWorkspace, workspace } = useCurrentWorkspace(); + const workspaceId = workspace?.id; const { openHome, agentEditorMode, editingAgentId } = useAgentsStore(); const { groups: userToolGroups, @@ -201,7 +202,7 @@ const CreateAgentPage: React.FC = () => { try { const detail = await CustomAgentAPI.getCustomAgentDetail({ agentId: editingAgentId, - workspacePath: workspacePath || undefined, + workspaceId: workspaceId || undefined, }); if (cancelled) { return; @@ -234,7 +235,7 @@ const CreateAgentPage: React.FC = () => { return () => { cancelled = true; }; - }, [editingAgentId, isEdit, workspacePath]); + }, [editingAgentId, isEdit, workspaceId]); const validateAgentId = useCallback( (value: string) => { @@ -384,7 +385,7 @@ const CreateAgentPage: React.FC = () => { notification.error(t('agentsOverview.form.promptRequired')); return; } - if (kind === 'subagent' && level === 'project' && !workspacePath) { + if (kind === 'subagent' && level === 'project' && !workspaceId) { notification.error(t('agentsOverview.form.noWorkspace')); return; } @@ -411,7 +412,7 @@ const CreateAgentPage: React.FC = () => { review: kind === 'subagent' ? review : false, tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, userContextPolicy: Array.from(userContextPolicy), - workspacePath: kind === 'subagent' && level === 'project' ? workspacePath : undefined, + workspaceId: kind === 'subagent' && level === 'project' ? workspaceId : undefined, } as const; if (isEdit && editingAgentId) { @@ -424,7 +425,7 @@ const CreateAgentPage: React.FC = () => { review: payload.review, tools: payload.tools, userContextPolicy: payload.userContextPolicy, - workspacePath: payload.workspacePath, + workspaceId: payload.workspaceId, }); notification.success(t('agentsOverview.form.updateSuccess', { name: payload.name })); } else { @@ -460,7 +461,7 @@ const CreateAgentPage: React.FC = () => { t, userContextPolicy, validateAgentId, - workspacePath, + workspaceId, ]); const formTitle = isEdit diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 5e274b2537..c1768a0d7c 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -251,8 +251,8 @@ export function useAgentsList({ try { const [modes, subagents, toolCatalog, configs, reviewTeamDefinition, modelConfigs] = await Promise.all([ - agentAPI.getAvailableModes({ workspacePath: workspacePath || undefined }).catch(() => []), - SubagentAPI.listSubagents({ workspacePath: workspacePath || undefined }).catch(() => []), + agentAPI.getAvailableModes({ workspaceId: workspace?.id }).catch(() => []), + SubagentAPI.listSubagents({ workspaceId: workspace?.id }).catch(() => []), fetchTools(), configAPI.getAgentProfileConfigs().catch(() => ({})), loadDefaultReviewTeamDefinition().catch(() => undefined), @@ -283,7 +283,7 @@ export function useAgentsList({ cacheKey, await configAPI.getModeSkillConfigs({ modeId: agentId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }).catch(() => []), ] as const), ); @@ -292,7 +292,7 @@ export function useAgentsList({ profile.profileId, await SubagentAPI.listManageableSubagents({ parentAgentType: profile.representativeModeId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }).catch(() => []), ] as const), ); @@ -363,7 +363,7 @@ export function useAgentsList({ setLoading(false); } } - }, [canQueryToolCatalog, workspacePath, renderedPeerDeviceId, t]); + }, [canQueryToolCatalog, workspace?.id, renderedPeerDeviceId, t]); useEffect(() => { void loadAgents(); @@ -463,7 +463,7 @@ export function useAgentsList({ const updated = await configAPI.getAgentProfileConfigs(); const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: profile.representativeModeId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); const modes = await agentAPI.getAvailableModes().catch(() => []); setModeConfigs(buildModeConfigsByProfile(modes, updated as Record)); @@ -479,7 +479,7 @@ export function useAgentsList({ } catch { notification.error(t('agentsOverview.toolsResetFailed')); } - }, [getModeProfile, notification, t, workspacePath]); + }, [getModeProfile, notification, t, workspace?.id]); const handleSetSkills = useCallback(async (agentId: string, enabledSkillKeys: string[]) => { const profile = getModeProfile(agentId); @@ -490,12 +490,12 @@ export function useAgentsList({ await configAPI.replaceModeSkillSelection({ modeId: targetAgentId, enabledSkillKeys, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: targetAgentId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); setAgentSkills((prev) => ({ ...prev, [cacheKey]: updatedSkills })); @@ -510,7 +510,7 @@ export function useAgentsList({ notification.error(t('agentsOverview.skillToggleFailed')); return false; } - }, [getModeProfile, notification, t, workspacePath]); + }, [getModeProfile, notification, t, workspace?.id]); const handleResetSkills = useCallback(async (agentId: string) => { const profile = getModeProfile(agentId); @@ -520,12 +520,12 @@ export function useAgentsList({ try { await configAPI.resetModeSkillSelection({ modeId: targetAgentId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: targetAgentId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); setAgentSkills((prev) => ({ ...prev, [cacheKey]: updatedSkills })); @@ -540,7 +540,7 @@ export function useAgentsList({ notification.error(t('agentsOverview.skillToggleFailed')); return false; } - }, [getModeProfile, notification, t, workspacePath]); + }, [getModeProfile, notification, t, workspace?.id]); const handleSetSubagentEnabled = useCallback(async ( agentId: string, @@ -555,12 +555,12 @@ export function useAgentsList({ subagentId, parentAgentType: agentId, enabled, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); const updatedSubagents = await SubagentAPI.listManageableSubagents({ parentAgentType: profile.representativeModeId, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }).catch(() => []); setModeManageableSubagents((prev) => ({ @@ -585,7 +585,7 @@ export function useAgentsList({ } catch { notification.error(t('agentsOverview.subagentToggleFailed')); } - }, [getModeProfile, notification, t, workspacePath]); + }, [getModeProfile, notification, t, workspace?.id]); const handleSetSubagentModel = useCallback(async ( subagentId: string, @@ -598,13 +598,13 @@ export function useAgentsList({ ? (selection.kind === 'inherit' ? 'inherit' : selection.model_id) : undefined, clearModelOverride: !selection, - workspacePath: workspacePath || undefined, + workspaceId: workspace?.id, }); await loadAgents(); } catch { notification.error(t('agentCard.modelSelector.updateFailed')); } - }, [loadAgents, notification, t, workspacePath]); + }, [loadAgents, notification, t, workspace?.id]); const filteredAgents = useMemo(() => allAgents.filter((agent) => { if (searchQuery) { @@ -644,6 +644,7 @@ export function useAgentsList({ return { workspacePath, + workspaceId: workspace?.id, workspaceIsRemote: isRemoteWorkspace(workspace), allAgents, filteredAgents, diff --git a/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx b/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx index 9ae7daa5c6..79a1e95d17 100644 --- a/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx +++ b/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx @@ -9,15 +9,13 @@ import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFla export interface HtmlPreviewPanelProps { isActive: boolean; filePath: string; - workspacePath: string; - remoteConnectionId?: string; + workspaceId: string; } const HtmlPreviewPanel: React.FC = ({ isActive, filePath, - workspacePath, - remoteConnectionId, + workspaceId, }) => { const { t } = useTranslation('common'); const [url, setUrl] = useState(); @@ -30,8 +28,7 @@ const HtmlPreviewPanel: React.FC = ({ setError(undefined); void htmlPreviewApi.create({ filePath, - workspacePath, - remoteConnectionId, + workspaceId, peerDeviceMode: isPeerDeviceModeActive(), }) .then((result) => { @@ -54,7 +51,7 @@ const HtmlPreviewPanel: React.FC = ({ } }; // The session is intentionally recreated only when the opened file scope changes. - }, [filePath, workspacePath, remoteConnectionId]); + }, [filePath, workspaceId]); if (error) { return
{error}
; diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx index 50026e9ac1..efbd03c847 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ ownerSurface: null as string | null, selectedProductId: 'codex', setOwnerSurface: vi.fn(), + workspaceId: 'workspace-a' as string | null, workspacePath: '/workspace', peerDeviceId: '', skills: [] as Array>, @@ -21,7 +22,10 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: mocks })); vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ ACPClientAPI: { getClients: async () => [] } })); -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspacePath: mocks.workspacePath, workspace: { workspaceKind: 'normal', sshHost: 'localhost' } }) })); +// The workspace is identified by its ID; the path is only the IO projection. +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => (mocks.workspaceId + ? { workspacePath: mocks.workspacePath, workspace: { id: mocks.workspaceId, name: 'Workspace', rootPath: mocks.workspacePath, workspaceKind: 'normal' } } + : { workspacePath: '', workspace: null }) })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: mocks.t, formatNumber: String }) })); vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => mocks.peerDeviceId ? ({ peerMode: { active: true, deviceId: mocks.peerDeviceId } }) : null })); vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }) })); @@ -101,6 +105,7 @@ describe('compatibility discovery lifecycle', () => { mocks.setOwnerSurface.mockClear(); mocks.planMcpImport.mockResolvedValue({ schemaVersion: 1, planFingerprint: 'plan', items: [] }); mocks.applyMcpImport.mockReset(); + mocks.workspaceId = 'workspace-a'; mocks.workspacePath = '/workspace'; mocks.peerDeviceId = ''; mocks.skills = []; @@ -175,7 +180,7 @@ describe('compatibility discovery lifecycle', () => { mocks.setAutomaticDiscovery.mockResolvedValue(snapshot(true, true, false, 2)); await renderMcp(); await act(async () => discoverySwitch().click()); - expect(mocks.setAutomaticDiscovery).toHaveBeenCalledExactlyOnceWith('/workspace', true, 1); + expect(mocks.setAutomaticDiscovery).toHaveBeenCalledExactlyOnceWith('workspace-a', true, 1); expect(initial.integrationPolicy.effective.enabled).toBe(false); expect(discoverySwitch().checked).toBe(true); expect(discoveryDescription().textContent).toBe('discovery.enabledDescription discovery.workspaceScope'); @@ -186,7 +191,7 @@ describe('compatibility discovery lifecycle', () => { }); it('uses host user settings when no workspace is open', async () => { - mocks.workspacePath = ''; + mocks.workspaceId = null; mocks.getDiscoverySnapshot.mockResolvedValue(snapshot(true)); mocks.setAutomaticDiscovery.mockResolvedValue(snapshot(false, false, false, 2)); await renderMcp(); @@ -228,7 +233,7 @@ describe('compatibility discovery lifecycle', () => { expect(alert?.querySelector('[data-openbitfun-part="icon"]')).toBeNull(); expect(discoverySwitch().checked).toBe(true); expect(discoverySwitch().disabled).toBe(false); - expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('/workspace', false); + expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('workspace-a', false); }); it('does not let an older refresh undo a confirmed discovery change', async () => { @@ -254,6 +259,7 @@ describe('compatibility discovery lifecycle', () => { await renderMcp(); await act(async () => discoverySwitch().click()); expect(discoverySwitch().disabled).toBe(true); + mocks.workspaceId = 'workspace-b'; mocks.workspacePath = '/other-workspace'; await renderMcp(); await act(async () => resolveSave(snapshot(true, false, false, 2))); @@ -270,7 +276,7 @@ describe('compatibility discovery lifecycle', () => { expect(container.textContent).toContain('Docs MCP'); await act(async () => vi.advanceTimersByTimeAsync(10000)); expect(mocks.getDiscoverySnapshot).toHaveBeenCalledTimes(2); - expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('/workspace', false); + expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('workspace-a', false); }); it('scans from the empty state while automatic discovery stays paused', async () => { @@ -284,7 +290,7 @@ describe('compatibility discovery lifecycle', () => { const scanButton = container.querySelector('[data-content-empty-state] button')!; expect(scanButton.textContent).toBe('content.scan'); await act(async () => scanButton.click()); - expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('/workspace', true); + expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('workspace-a', true); expect(container.querySelector('[data-content-empty-state="checking"]')).not.toBeNull(); expect(scanButton.disabled).toBe(true); await act(async () => scanButton.click()); @@ -295,7 +301,7 @@ describe('compatibility discovery lifecycle', () => { await act(async () => vi.advanceTimersByTimeAsync(300)); expect(container.textContent).toContain('Docs MCP'); expect(container.querySelector('[data-content-empty-state]')).toBeNull(); - expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('/workspace', false); + expect(mocks.getDiscoverySnapshot).toHaveBeenLastCalledWith('workspace-a', false); expect(mocks.setAutomaticDiscovery).not.toHaveBeenCalled(); }); @@ -316,6 +322,7 @@ describe('compatibility discovery lifecycle', () => { mocks.getDiscoverySnapshot.mockResolvedValueOnce(snapshot(true, true)).mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve; })); await renderMcp(); await act(async () => vi.advanceTimersByTimeAsync(300)); + mocks.workspaceId = 'workspace-b'; mocks.workspacePath = '/other-workspace'; mocks.getDiscoverySnapshot.mockResolvedValue(snapshot(false)); await renderMcp(); @@ -324,7 +331,17 @@ describe('compatibility discovery lifecycle', () => { expect(container.querySelector('[data-content-empty-state="notScanned"]')).not.toBeNull(); }); - it('drops local catalog content immediately when switching to a peer on the same path', async () => { + it('keeps the discovered catalog when only the workspace path changes', async () => { + mocks.getDiscoverySnapshot.mockResolvedValueOnce(snapshot(true, false, true)); + await renderMcp(); + expect(container.textContent).toContain('Docs MCP'); + mocks.workspacePath = '/moved-checkout'; + await renderMcp(); + expect(container.textContent).toContain('Docs MCP'); + expect(mocks.getDiscoverySnapshot).toHaveBeenCalledTimes(1); + }); + + it('drops local catalog content immediately when switching to a peer on the same workspace', async () => { mocks.getDiscoverySnapshot.mockResolvedValueOnce(snapshot(true, false, true)); await renderMcp(); expect(container.textContent).toContain('Docs MCP'); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts index d0d885f8af..0717de95d8 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts @@ -35,9 +35,11 @@ describe('ecosystem compatibility scene presentation contract', () => { const scene = source('./EcosystemCompatibilityScene.tsx'); const model = source('./ecosystemCompatibilityModel.ts'); - expect(scene).toContain('externalSourcesAPI.getDiscoverySnapshot(workspacePath, forceRefresh)'); + expect(scene).toContain('externalSourcesAPI.getDiscoverySnapshot(workspace?.id, forceRefresh)'); + expect(scene).not.toContain('getDiscoverySnapshot(workspacePath'); const content = source('./ExternalAgentContent.tsx'); - expect(content).toContain('externalSourcesAPI.planMcpImport(workspacePath || undefined)'); + expect(content).toContain('externalSourcesAPI.planMcpImport(workspace?.id)'); + expect(content).not.toContain('planMcpImport(workspacePath'); expect(content).toContain('externalSourcesAPI.applyMcpImport('); expect(scene).toContain('ACPClientAPI.getClients()'); expect(scene).toContain('ACPClientAPI.updateClientSubagentConfig({'); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx index 30ecba0f00..c8c8d89d3d 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.tsx @@ -119,7 +119,9 @@ const EcosystemCompatibilityScene: React.FC = () => { const { workspace, workspacePath } = useCurrentWorkspace(); const peerDevice = usePeerDeviceModeOptional(); const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; - const requestScope = JSON.stringify([peerDeviceId, workspace?.id, workspace?.workspaceKind, workspacePath]); + // Discovery results are owned by the (peer, workspace ID) pair; the path is + // an IO projection and must not fork the cache when a checkout moves. + const requestScope = JSON.stringify([peerDeviceId, workspace?.id]); const requestSequence = useRef(0); const contentRef = useRef(null); const acpManagerRef = useRef>(null); @@ -173,7 +175,7 @@ const EcosystemCompatibilityScene: React.FC = () => { if (!forceRefresh && !backgroundRequest) setLoading(true); const [sourceResult, clientsResult] = await Promise.allSettled([ - externalSourcesAPI.getDiscoverySnapshot(workspacePath, forceRefresh), + externalSourcesAPI.getDiscoverySnapshot(workspace?.id, forceRefresh), ACPClientAPI.getClients(), ]); if (sequence !== requestSequence.current || backgroundRequest?.isCurrent() === false) return undefined; @@ -192,7 +194,7 @@ const EcosystemCompatibilityScene: React.FC = () => { setLoadIssues(nextIssues); setLoading(false); return sourceResult.status === 'fulfilled' ? sourceResult.value : undefined; - }, [setAcpClients, setSnapshot, workspacePath]); + }, [setAcpClients, setSnapshot, workspace?.id]); useEffect(() => { setSnapshot(null); @@ -283,7 +285,7 @@ const EcosystemCompatibilityScene: React.FC = () => { }, [selectProduct, showDevelopmentNotice]); const handleStartAcpClient = useCallback((client: AcpClientInfo) => { - if (!workspacePath) { + if (!workspace?.id) { notification.info(t('run.workspaceRequired'), { duration: 3200 }); return; } @@ -298,7 +300,7 @@ const EcosystemCompatibilityScene: React.FC = () => { detail: { clientId: client.id }, })); notification.info(t('run.starting', { name: client.name || client.id }), { duration: 2400 }); - }, [notification, setOwnerSurface, t, workspacePath]); + }, [notification, setOwnerSurface, t, workspace?.id]); const handleConfigureSubagent = useCallback((client: AcpClientInfo) => { if (!client.subagent) return; diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx index becb7c666d..c7e51c5255 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx @@ -11,14 +11,14 @@ const mocks = vi.hoisted(() => ({ getSkillSettings: vi.fn(), setSkillDisabled: vi.fn(), petSettingsListener: vi.fn(), getAccounts: vi.fn(), getPets: vi.fn(), importPet: vi.fn(), getPetSettings: vi.fn(), savePetSettings: vi.fn(), getInstructions: vi.fn(), openScene: vi.fn(), openDestination: vi.fn(), openNativeSkills: vi.fn(), deleteSkill: vi.fn(), loadMcp: vi.fn(), saveMcp: vi.fn(), mutateHook: vi.fn(), getSkills: vi.fn(), validateSkill: vi.fn(), addSkill: vi.fn(), getHooks: vi.fn(), getHookCatalog: vi.fn(), planHook: vi.fn(), applyHook: vi.fn(), planMcp: vi.fn(), applyMcp: vi.fn(), refresh: vi.fn(), - workspacePath: '/project', remote: false, peer: false, skillImportVersion: 0, + workspacePath: '/project', workspaceId: 'workspace-id', remote: false, peer: false, skillImportVersion: 0, })); vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: { getState: () => ({ openScene: mocks.openScene }) } })); vi.mock('@/app/scenes/settings/settingsStore', () => ({ useSettingsStore: { getState: () => ({ openDestination: mocks.openDestination }) } })); vi.mock('@/app/scenes/skills/skillsSceneStore', () => ({ useSkillsSceneStore: { getState: () => ({ openNativeSkills: mocks.openNativeSkills }) } })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, formatNumber: String }) })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ - workspacePath: mocks.workspacePath, workspace: { workspaceKind: mocks.remote ? 'remote' : 'normal' }, + workspacePath: mocks.workspacePath, workspace: { id: mocks.workspaceId, workspaceKind: mocks.remote ? 'remote' : 'normal' }, }) })); vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => ({ peerMode: { active: mocks.peer } }) })); vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); @@ -109,7 +109,7 @@ describe('external agent content and explicit import boundary', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.resetAllMocks(); localStorage.clear(); - mocks.remote = false; mocks.peer = false; mocks.workspacePath = '/project'; mocks.skillImportVersion = 0; + mocks.remote = false; mocks.peer = false; mocks.workspacePath = '/project'; mocks.workspaceId = 'workspace-id'; mocks.skillImportVersion = 0; data = fixture(); mocks.getSkillSettings.mockResolvedValue({ directSkillManagementVersion: 1, globallyDisabledUserSkillKeys: [], globallyDisabledProjectSkillKeys: [] }); mocks.setSkillDisabled.mockResolvedValue({ directSkillManagementVersion: 1, globallyDisabledUserSkillKeys: [], globallyDisabledProjectSkillKeys: [] }); @@ -554,7 +554,7 @@ describe('external agent content and explicit import boundary', () => { mocks.setSkillDisabled.mockResolvedValue(disabled); await render(); await expand('skill'); await act(async () => container.querySelector('[role="switch"]')!.click()); - expect(mocks.setSkillDisabled).toHaveBeenCalledWith({ skillKey: data.skills[0].key, disabled: true, workspacePath: '/project' }); + expect(mocks.setSkillDisabled).toHaveBeenCalledWith({ skillKey: data.skills[0].key, disabled: true, workspaceId: 'workspace-id' }); expect(container.querySelector('[role="switch"]')!.checked).toBe(false); expect(container.querySelector('[data-import-kind="skill"]')?.getAttribute('data-import-state')).toBe('disabled'); mocks.getSkillSettings.mockResolvedValue(disabled); @@ -617,7 +617,7 @@ describe('external agent content and explicit import boundary', () => { mocks.setSkillDisabled.mockReturnValue(new Promise((resolve) => { finish = resolve; })); await render(); await expand('skill'); await act(async () => container.querySelector('[role="switch"]')!.click()); - mocks.workspacePath = '/other-project'; + mocks.workspacePath = '/other-project'; mocks.workspaceId = 'other-workspace-id'; await render(); await expand('skill'); await act(async () => finish({ directSkillManagementVersion: 1, globallyDisabledUserSkillKeys: [], globallyDisabledProjectSkillKeys: [data.skills[0].key] })); expect(container.querySelector('[role="switch"]')!.checked).toBe(true); @@ -704,7 +704,7 @@ describe('external agent content and explicit import boundary', () => { await render(); await click('content.prepareImport', 'mcp'); expect(mocks.applyMcp).not.toHaveBeenCalled(); await click('content.confirm'); - expect(mocks.applyMcp).toHaveBeenCalledWith('/project', data.plan, [{ candidateId: 'codex' }]); + expect(mocks.applyMcp).toHaveBeenCalledWith('workspace-id', data.plan, [{ candidateId: 'codex' }]); expect(container.querySelector('[data-import-kind="mcp"]')?.getAttribute('data-import-state')).toBe('imported'); }); @@ -794,9 +794,9 @@ describe('external agent content and explicit import boundary', () => { it('shows the exact Hook commands and applies only the reviewed external source', async () => { await render(); await click('content.prepareImport', 'hook'); - expect(mocks.planHook).toHaveBeenCalledWith('/project', data.hookPlan.source.key); + expect(mocks.planHook).toHaveBeenCalledWith('workspace-id', data.hookPlan.source.key); expect(container.textContent).toContain('echo reviewed-command'); expect(mocks.applyHook).not.toHaveBeenCalled(); - await click('content.confirm'); expect(mocks.applyHook).toHaveBeenCalledWith('/project', data.hookPlan); + await click('content.confirm'); expect(mocks.applyHook).toHaveBeenCalledWith('workspace-id', data.hookPlan); }); it('rejects a Hook preview for a different agent instead of showing or applying it', async () => { @@ -891,9 +891,9 @@ describe('external agent content and explicit import boundary', () => { await render(); await expand('hook'); await act(async () => container.querySelector('button[aria-label="content.refresh"]')!.click()); - expect(mocks.getHooks).toHaveBeenCalledWith('/project', true); + expect(mocks.getHooks).toHaveBeenCalledWith('workspace-id', true); await act(async () => vi.advanceTimersByTimeAsync(1000)); - expect(mocks.getHooks).toHaveBeenLastCalledWith('/project', false); + expect(mocks.getHooks).toHaveBeenLastCalledWith('workspace-id', false); await expand('hook'); expect(container.textContent).toContain('codex-Hooks'); await act(async () => vi.advanceTimersByTimeAsync(5000)); @@ -920,7 +920,7 @@ describe('external agent content and explicit import boundary', () => { expect(container.textContent).not.toContain('content.manageNative'); expect(mocks.validateSkill).not.toHaveBeenCalled(); expect(mocks.planMcp).not.toHaveBeenCalled(); expect(mocks.getHooks).not.toHaveBeenCalled(); - expect(mocks.getHookCatalog).toHaveBeenCalledWith('/project', false); + expect(mocks.getHookCatalog).toHaveBeenCalledWith('workspace-id', false); expect(mocks.addSkill).not.toHaveBeenCalled(); expect(mocks.applyMcp).not.toHaveBeenCalled(); }); @@ -968,7 +968,7 @@ describe('external agent content and explicit import boundary', () => { await render(); await click('content.undo', 'hook'); expect(mocks.mutateHook).not.toHaveBeenCalled(); await click('content.confirmUndo'); - expect(mocks.mutateHook).toHaveBeenCalledWith('/project', 'r1', { kind: 'remove', importId: 'codex-hook-copy' }); + expect(mocks.mutateHook).toHaveBeenCalledWith('workspace-id', 'r1', { kind: 'remove', importId: 'codex-hook-copy' }); }); }); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx index 510a2253dd..5697074274 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx @@ -78,7 +78,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const notification = useNotification(); const { workspace, workspacePath } = useCurrentWorkspace(); const peer = usePeerDeviceModeOptional(); - const cache = ecosystemDiscoveryCache(scopeKey ?? JSON.stringify([peer?.peerMode.active ? peer.peerMode.deviceId : undefined, workspace?.id, workspace?.workspaceKind, workspacePath])); + const cache = ecosystemDiscoveryCache(scopeKey ?? JSON.stringify([peer?.peerMode.active ? peer.peerMode.deviceId : undefined, workspace?.id])); const automaticDiscovery = snapshot !== null && (snapshot.discovery?.enabled ?? true); const localImportSupported = isTauriRuntime() && !peer?.peerMode.active && workspace?.workspaceKind !== WorkspaceKind.Remote; @@ -86,8 +86,8 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const [skillSettings, setSkillSettings] = useState(null); const [skillDiagnostics, setSkillDiagnostics] = useState(() => cache.skillDiagnostics?.filter((entry) => entry.sourceId === runtime.spec.ecosystemId) ?? []); const [hooks, setHooks] = useState(() => cache.hooks ?? null); - const [instructionSnapshot, setInstructionSnapshot] = useState<{ workspacePath: typeof workspacePath; catalog: InstructionSourceCatalog | null } | null>(null); - const instructions = localImportSupported && instructionSnapshot?.workspacePath === workspacePath + const [instructionSnapshot, setInstructionSnapshot] = useState<{ workspaceId: string | undefined; catalog: InstructionSourceCatalog | null } | null>(null); + const instructions = localImportSupported && instructionSnapshot?.workspaceId === workspace?.id ? instructionSnapshot?.catalog ?? null : null; const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -136,14 +136,14 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe setLoading(true); const [skillResult, hookResult, instructionResult, settingsResult] = await Promise.allSettled([ collectPending ? Promise.resolve(null) - : configAPI.getSkillScanReport({ workspacePath: workspacePath || undefined, forceRefresh: refresh }), + : configAPI.getSkillScanReport({ workspaceId: workspace?.id, forceRefresh: refresh }), !refresh && !automaticDiscovery && !collectPending ? Promise.resolve(cache.hooks ?? null) : localImportSupported - ? externalHooksAPI.getImportSnapshot(workspacePath || undefined, refresh) - : externalHooksAPI.getCatalog(workspacePath || undefined, refresh).then((catalog) => ({ + ? externalHooksAPI.getImportSnapshot(workspace?.id, refresh) + : externalHooksAPI.getCatalog(workspace?.id, refresh).then((catalog) => ({ schemaVersion: 1 as const, revision: '', catalog, imports: [], diagnostics: [], })), - localImportSupported ? instructionSourcesAPI.getCatalog(workspacePath || undefined) : Promise.resolve(null), - localImportSupported ? configAPI.getGlobalSkillSettings(workspacePath || undefined) : Promise.resolve(null), + localImportSupported ? instructionSourcesAPI.getCatalog(workspace?.id) : Promise.resolve(null), + localImportSupported ? configAPI.getGlobalSkillSettings(workspace?.id) : Promise.resolve(null), ]); if (!alive.current || sequence !== loadSequence.current) return; const failures: string[] = []; @@ -154,14 +154,14 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe if (hookResult.status === 'fulfilled') { if (hookResult.value) setHooks(rememberEcosystemHooks(cache, hookResult.value)); } else { failures.push('hook'); } - setInstructionSnapshot({ workspacePath, catalog: instructionResult.status === 'fulfilled' ? instructionResult.value : null }); + setInstructionSnapshot({ workspaceId: workspace?.id, catalog: instructionResult.status === 'fulfilled' ? instructionResult.value : null }); if (instructionResult.status === 'rejected') failures.push('instruction'); if (settingsRevision === skillSettingsRevision.current) { setSkillSettings(settingsResult.status === 'fulfilled' ? settingsResult.value : null); } setLoadFailures(failures); setLoading(false); - }, [automaticDiscovery, cache, localImportSupported, runtime.spec.ecosystemId, workspacePath]); + }, [automaticDiscovery, cache, localImportSupported, runtime.spec.ecosystemId, workspace?.id]); useEffect(() => { alive.current = true; @@ -177,7 +177,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const refreshAvailability = async () => { const revision = ++skillSettingsRevision.current; try { - const settings = await configAPI.getGlobalSkillSettings(workspacePath || undefined); + const settings = await configAPI.getGlobalSkillSettings(workspace?.id); if (alive.current && revision === skillSettingsRevision.current) setSkillSettings(settings); } catch { if (alive.current && revision === skillSettingsRevision.current) setSkillSettings(null); @@ -188,7 +188,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe skillSettingsRevision.current += 1; globalEventBus.off('mode:config:updated', refreshAvailability); }; - }, [localImportSupported, workspacePath]); + }, [localImportSupported, workspacePath, workspace?.id]); useEffect(() => { if (!hooks?.catalog.discoveryPending) return; @@ -247,12 +247,12 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe if (!localImportSupported || !hasMcp) { setPlanLoading(false); return; } setPlanLoading(true); setPlanFailed(false); - const next = await externalSourcesAPI.planMcpImport(workspacePath || undefined).catch(() => null); + const next = await externalSourcesAPI.planMcpImport(workspace?.id).catch(() => null); if (!alive.current || sequence !== mcpPlanSequence.current) return; setPlan(next); setPlanFailed(next === null); setPlanLoading(false); - }, [automaticDiscovery, hasMcp, localImportSupported, workspacePath]); + }, [automaticDiscovery, hasMcp, localImportSupported, workspace?.id]); useEffect(() => { void refreshMcpPlan(); return () => { mcpPlanSequence.current += 1; }; @@ -279,7 +279,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const scope = getActiveSurfaceScope(); setBusy(true); setNotice(null); try { - const settings = await configAPI.setGlobalSkillDisabled({ skillKey: skill.key, disabled: !enabled, workspacePath: workspacePath || undefined }); + const settings = await configAPI.setGlobalSkillDisabled({ skillKey: skill.key, disabled: !enabled, workspaceId: workspace?.id }); if (alive.current && scope.isCurrent()) { setSkillSettings(settings); globalEventBus.emit('mode:config:updated'); @@ -350,7 +350,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const entries: BatchImportEntry[] = []; try { const freshMcpPlan = candidates.some((item) => item.kind === 'mcp') - ? await externalSourcesAPI.planMcpImport(workspacePath || undefined).catch(() => null) : null; + ? await externalSourcesAPI.planMcpImport(workspace?.id).catch(() => null) : null; for (const item of candidates) { if (!alive.current) return; if (!presentation(item).canImport) continue; @@ -358,7 +358,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe candidate.candidateId === item.candidateId && ['eligible', 'automatic_rename'].includes(candidate.disposition))) { entries.push({ id: item.id, name: item.name, kind: 'mcp', candidateId: item.candidateId, plan: freshMcpPlan }); } else if (item.hookSource) { - const next = await externalHooksAPI.planImport(workspacePath || undefined, item.hookSource.key).catch(() => null); + const next = await externalHooksAPI.planImport(workspace?.id, item.hookSource.key).catch(() => null); if (next && next.source.ecosystemId === runtime.spec.ecosystemId && next.source.key.providerId === item.hookSource.key.providerId && next.source.key.sourceId === item.hookSource.key.sourceId @@ -379,7 +379,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe setBusy(true); setBatchResults([]); try { - await applyEcosystemBatch(batch, workspacePath || undefined, (result) => { + await applyEcosystemBatch(batch, { workspaceId: workspace?.id }, (result) => { if (!alive.current) return; setBatchResults((current) => [...(current ?? []), result]); if (result.status === 'imported' && batch.find((entry) => entry.id === result.id)?.kind !== 'mcp') { @@ -402,12 +402,12 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe setBusy(true); try { if (item.hookSource) { - const next = await externalHooksAPI.planImport(workspacePath || undefined, item.hookSource.key); + const next = await externalHooksAPI.planImport(workspace?.id, item.hookSource.key); if (!alive.current || sequence !== reviewSequence.current) return; if (next.source.ecosystemId !== runtime.spec.ecosystemId || next.source.key.providerId !== item.hookSource.key.providerId || next.source.key.sourceId !== item.hookSource.key.sourceId) { setNotice(t('content.previewFailed')); return; } setReview({ kind: 'hook', item, plan: next }); } else if (item.kind === 'mcp') { - const next = plan ?? await externalSourcesAPI.planMcpImport(workspacePath || undefined); + const next = plan ?? await externalSourcesAPI.planMcpImport(workspace?.id); if (!alive.current || sequence !== reviewSequence.current) return; setPlan(next); setReview({ kind: 'mcp', item, plan: next }); @@ -429,7 +429,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const candidateId = captured.item.candidateId!; const selected = captured.plan.items.find((item) => item.candidateId === candidateId); if (!selected || !['eligible', 'automatic_rename'].includes(selected.disposition)) return; - const result = await externalSourcesAPI.applyMcpImport(workspacePath || undefined, captured.plan, [{ candidateId }]); + const result = await externalSourcesAPI.applyMcpImport(workspace?.id, captured.plan, [{ candidateId }]); if (!alive.current) return; if (result.outcome.status === 'stale') { setPlan(result.outcome.refreshedPlan); @@ -439,7 +439,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe } } else { if (captured.plan.disposition === 'unavailable' || captured.plan.handlers.length === 0) return; - const result = await externalHooksAPI.applyImport(workspacePath || undefined, captured.plan); + const result = await externalHooksAPI.applyImport(workspace?.id, captured.plan); if (!alive.current) return; if (result.outcome.kind === 'stale') { setReview({ ...captured, plan: result.outcome.refreshedPlan }); @@ -471,7 +471,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe setNotice(null); try { const next = item.kind === 'mcp' && item.candidateId ? await prepareMcpUndo(item.candidateId) - : item.hookSource ? await prepareHookUndo(item.hookSource, workspacePath || undefined) + : item.hookSource ? await prepareHookUndo(item.hookSource, workspace?.id) : null; if (!alive.current || sequence !== reviewSequence.current) return; if (!next) { setNotice(t('content.undoUnavailable')); return; } @@ -489,7 +489,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe setBusy(true); setNotice(null); try { - const result = await applyImportUndo(captured.review, workspacePath || undefined); + const result = await applyImportUndo(captured.review, workspace?.id); if (!alive.current) return; setCompleted((current) => { const next = new Set(current); next.delete(captured.item.id); return next; }); setUndo(null); @@ -521,7 +521,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe for (const item of items.filter((entry) => (!group || entry.kind === group) && (!selectedOnly || (selected.has(entry.id) && `${entry.name} ${entry.description ?? ''} ${entry.sourceLocation ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()))) && contentState(entry) === 'imported')) { const review = item.kind === 'mcp' && item.candidateId ? await prepareMcpUndo(item.candidateId) - : item.hookSource ? await prepareHookUndo(item.hookSource, workspacePath || undefined) + : item.hookSource ? await prepareHookUndo(item.hookSource, workspace?.id) : null; if (!alive.current) return; if (!review) throw new Error(t('content.undoUnavailable')); @@ -536,7 +536,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe if (!batchUndo?.length || busy || !localImportSupported) return; setBusy(true); setBatchUndoResults([]); try { - await applyEcosystemBatchUndo(batchUndo, workspacePath || undefined, (result) => { + await applyEcosystemBatchUndo(batchUndo, workspace?.id, (result) => { if (!alive.current) return; setBatchUndoResults((current) => [...(current ?? []), result]); if (result.status !== 'failed') setCompleted((current) => { const next = new Set(current); next.delete(result.id); return next; }); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalDiscoveryToggle.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalDiscoveryToggle.tsx index 6eb62fb12a..4908a3bffa 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalDiscoveryToggle.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalDiscoveryToggle.tsx @@ -14,7 +14,7 @@ interface Props { /** Controls catalog discovery without changing runtime or import authorization. */ export default function ExternalDiscoveryToggle({ snapshot, onSnapshotChange, controlRef }: Props) { const { t } = useI18n('scenes/ecosystem-compatibility'); - const { workspacePath } = useCurrentWorkspace(); + const { workspace } = useCurrentWorkspace(); const labelId = useId(); const descriptionId = useId(); const errorId = useId(); @@ -31,7 +31,8 @@ export default function ExternalDiscoveryToggle({ snapshot, onSnapshotChange, co ? t('discovery.loadingDescription') : !canChange ? t('discovery.readOnlyDescription') - : `${t(enabled ? 'discovery.enabledDescription' : 'discovery.disabledDescription')} ${t(workspacePath ? 'discovery.workspaceScope' : 'discovery.userScope')}`; + // The mutation scopes by workspace ID, so the description must agree with it. + : `${t(enabled ? 'discovery.enabledDescription' : 'discovery.disabledDescription')} ${t(workspace?.id ? 'discovery.workspaceScope' : 'discovery.userScope')}`; async function change(enabled: boolean) { if (!canChange || !snapshot || pending.current) return; @@ -42,14 +43,14 @@ export default function ExternalDiscoveryToggle({ snapshot, onSnapshotChange, co setError(false); try { const next = await externalSourcesAPI.setAutomaticDiscovery( - workspacePath || undefined, enabled, discovery!.preferenceRevision, + workspace?.id, enabled, discovery!.preferenceRevision, ); if (isCurrent()) onSnapshotChange(next); } catch { if (!isCurrent()) return; setError(true); try { - const current = await externalSourcesAPI.getDiscoverySnapshot(workspacePath || undefined, false); + const current = await externalSourcesAPI.getDiscoverySnapshot(workspace?.id, false); if (isCurrent()) onSnapshotChange(current); } catch { /* Preserve the last confirmed state and keep the error visible. */ } } finally { diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.test.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.test.ts index 8f6e45ace1..9fe6e5066b 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.test.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.test.ts @@ -21,11 +21,11 @@ it('commits all MCP selections together and continues after an independent Skill skill: { key: id, path: `/source/${id}` } as SkillInfo })), ]; const results: BatchImportResult[] = []; - await applyEcosystemBatch(entries, '/workspace', (result) => results.push(result)); - expect(mocks.mcp).toHaveBeenCalledExactlyOnceWith('/workspace', plan, [{ candidateId: 'one' }, { candidateId: 'two' }]); + await applyEcosystemBatch(entries, { workspaceId: 'workspace-1' }, (result) => results.push(result)); + expect(mocks.mcp).toHaveBeenCalledExactlyOnceWith('workspace-1', plan, [{ candidateId: 'one' }, { candidateId: 'two' }]); expect(mocks.add).toHaveBeenCalledTimes(2); expect(results.map(({ status }) => status)).toEqual(['imported', 'imported', 'failed', 'imported']); - expect(mocks.add).toHaveBeenLastCalledWith({ sourceKey: 'good', sourcePath: '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/source/good', level: 'user', workspacePath: '/workspace' }); + expect(mocks.add).toHaveBeenLastCalledWith({ sourceKey: 'good', sourcePath: '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/source/good', level: 'user', workspaceId: 'workspace-1' }); }); it('refreshes a Hook target revision but refuses executable content changed since review', async () => { @@ -37,7 +37,7 @@ it('refreshes a Hook target revision but refuses executable content changed sinc .mockResolvedValueOnce({ ...plan, handlers: [{ ...plan.handlers[0], command: 'echo changed' }] }); mocks.hook.mockResolvedValue({ outcome: { kind: 'applied' } }); const results: BatchImportResult[] = []; - await applyEcosystemBatch(entries, undefined, (result) => results.push(result)); + await applyEcosystemBatch(entries, {}, (result) => results.push(result)); expect(mocks.hook).toHaveBeenCalledTimes(1); expect(mocks.hook.mock.calls[0][1].planFingerprint).toBe('new-target-revision'); expect(results.map(({ status }) => status)).toEqual(['imported', 'stale']); @@ -50,7 +50,7 @@ it('retains each reviewed Skill digest and continues after a stale package', asy level: 'user', targetName: `${id}-alias`, preview: { fingerprint: `digest-${id}`, fileCount: 2, name: id, description: '' }, })); const results: BatchImportResult[] = []; - await applyEcosystemBatch(entries, undefined, (result) => results.push(result)); + await applyEcosystemBatch(entries, {}, (result) => results.push(result)); expect(results.map(({ status }) => status)).toEqual(['stale', 'imported']); expect(mocks.add.mock.calls.map(([request]) => request.expectedSourceFingerprint)).toEqual(['digest-changed', 'digest-unchanged']); expect(mocks.add.mock.calls[1][0].targetName).toBe('unchanged-alias'); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.ts index ad3f85245b..8dcd173576 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchImport.ts @@ -14,9 +14,10 @@ export interface BatchImportResult { id: string; name: string; status: 'imported /** One reviewed MCP transaction; independent Skill/Hook failures do not erase successful copies. */ export async function applyEcosystemBatch( - entries: BatchImportEntry[], workspacePath: string | undefined, + entries: BatchImportEntry[], workspace: { workspaceId?: string }, onResult: (result: BatchImportResult) => void, ): Promise { + const { workspaceId } = workspace; const scope = getActiveSurfaceScope(); if (!isLocalSurface(scope.surfaceId)) throw new Error('External batch import requires the local host'); const mcpEntries = entries.filter((entry) => entry.kind === 'mcp'); @@ -25,7 +26,7 @@ export async function applyEcosystemBatch( let error: string | undefined; try { scope.assertCurrent('apply reviewed MCP batch'); - const response = await externalSourcesAPI.applyMcpImport(workspacePath, mcpEntries[0].plan, + const response = await externalSourcesAPI.applyMcpImport(workspaceId, mcpEntries[0].plan, mcpEntries.map(({ candidateId }) => ({ candidateId }))); scope.assertCurrent('confirm MCP batch'); status = response.outcome.status === 'stale' ? 'stale' : 'imported'; @@ -40,20 +41,20 @@ export async function applyEcosystemBatch( scope.assertCurrent('apply reviewed external import'); if (entry.kind === 'skill') { await configAPI.addSkill({ sourcePath: entry.skill.path, sourceKey: entry.skill.key, - level: entry.level, workspacePath, ...(entry.targetName ? { targetName: entry.targetName } : {}), + level: entry.level, workspaceId, ...(entry.targetName ? { targetName: entry.targetName } : {}), ...(entry.preview ? { expectedSourceFingerprint: entry.preview.fingerprint } : {}) }); status = 'imported'; } else { // Earlier imports change the target revision. Refresh it while requiring the // exact source behavior and executable handlers that the user reviewed. - const fresh = await externalHooksAPI.planImport(workspacePath, entry.plan.source.key); + const fresh = await externalHooksAPI.planImport(workspaceId, entry.plan.source.key); scope.assertCurrent('apply reviewed Hook import'); if (fresh.behaviorVersion !== entry.plan.behaviorVersion || JSON.stringify(fresh.handlers) !== JSON.stringify(entry.plan.handlers) || JSON.stringify(fresh.skipped) !== JSON.stringify(entry.plan.skipped) || fresh.disposition === 'unavailable') status = 'stale'; else { - const response = await externalHooksAPI.applyImport(workspacePath, fresh); + const response = await externalHooksAPI.applyImport(workspaceId, fresh); status = response.outcome.kind === 'stale' ? 'stale' : 'imported'; } } diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchUndo.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchUndo.ts index 7f61a24c3b..ff1a517c00 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchUndo.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemBatchUndo.ts @@ -5,7 +5,7 @@ import { getActiveSurfaceScope, isLocalSurface } from '@/infrastructure/peer-dev export interface BatchUndoEntry { id: string; name: string; review: ImportUndoReview } export interface BatchUndoResult { id: string; name: string; status: 'removed' | 'pending' | 'failed'; error?: string } -export async function applyEcosystemBatchUndo(entries: BatchUndoEntry[], workspacePath: string | undefined, +export async function applyEcosystemBatchUndo(entries: BatchUndoEntry[], workspaceId: string | undefined, onResult: (result: BatchUndoResult) => void) { const scope = getActiveSurfaceScope(); if (!isLocalSurface(scope.surfaceId)) throw new Error('External batch undo requires the local host'); @@ -22,7 +22,7 @@ export async function applyEcosystemBatchUndo(entries: BatchUndoEntry[], workspa if (entry.review.kind !== 'mcp' || entry.review.fingerprint !== first.fingerprint) throw new Error('MCP configuration changed; review the batch again'); delete config.mcpServers[entry.review.target]; } - const result = await applyImportUndo({ ...first, jsonConfig: JSON.stringify(config, null, 2) }, workspacePath); + const result = await applyImportUndo({ ...first, jsonConfig: JSON.stringify(config, null, 2) }, workspaceId); mcp.forEach(({ id, name }) => onResult({ id, name, status: result.runtimeApplied ? 'removed' : 'pending' })); } catch (error) { mcp.forEach(({ id, name }) => onResult({ id, name, status: 'failed', error: importErrorMessage(error) })); } } @@ -33,7 +33,7 @@ export async function applyEcosystemBatchUndo(entries: BatchUndoEntry[], workspa scope.assertCurrent('remove reviewed imported copy'); const original = entry.review; const review = original.kind === 'hook' ? { ...original, revision: revisions.get(original.revision) ?? original.revision } : original; - const result = await applyImportUndo(review, workspacePath); + const result = await applyImportUndo(review, workspaceId); // Advance only through revisions returned by our own successful removals. // An unrelated edit still fails the next compare-and-swap. if (original.kind === 'hook' && result.revision) revisions.set(original.revision, result.revision); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.test.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.test.ts index 24dd66149b..700c08b172 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.test.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.test.ts @@ -10,24 +10,37 @@ vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHo import { applyImportUndo, matchesSkillReceipt, prepareMcpUndo, readSkillImportReceipt, rememberSkillImport } from './ecosystemImportUndo'; const native = { key: 'native-demo', path: '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/native/demo', sourceId: 'openbitfun', level: 'user', isBuiltin: false } as SkillInfo; -const key = (source: string) => `openbitfun:external-skill-import:${JSON.stringify(['', source])}`; +const key = (source: string) => `openbitfun:external-skill-import:v2:${JSON.stringify(['local', '', source])}`; describe('external import undo ownership and compatibility', () => { beforeEach(() => { vi.resetAllMocks(); localStorage.clear(); resetDeviceSurfaceForTest(); }); it('reads saved native identities after reload and shares user copies across workspaces', () => { - rememberSkillImport('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/source/demo', native, '/workspace'); + rememberSkillImport('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/source/demo', native, 'workspace-id'); const saved = JSON.parse(localStorage.getItem(key('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/source/demo'))!); localStorage.setItem(key('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/source/demo'), JSON.stringify({ ...saved, futureOptionalField: true })); - expect(readSkillImportReceipt('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/source/demo', '/another-workspace')).toMatchObject(saved); + expect(readSkillImportReceipt('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/source/demo', 'another-workspace-id')).toMatchObject(saved); expect(matchesSkillReceipt({ ...native, sourceId: 'codex' }, saved)).toBe(false); expect(matchesSkillReceipt({ ...native, isBuiltin: true }, saved)).toBe(false); }); it('keeps project copy receipts scoped to the owning workspace', () => { - rememberSkillImport('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/project-source/demo', { ...native, level: 'project' }, '/a'); - expect(readSkillImportReceipt('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/project-source/demo', '/a')).not.toBeNull(); - expect(readSkillImportReceipt('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/project-source/demo', '/b')).toBeNull(); + rememberSkillImport('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/project-source/demo', { ...native, level: 'project' }, 'workspace-a'); + expect(readSkillImportReceipt('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/project-source/demo', 'workspace-a')).not.toBeNull(); + expect(readSkillImportReceipt('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/project-source/demo', 'workspace-b')).toBeNull(); + }); + + it('rejects project imports without an ID and never reads project receipts from the global slot', async () => { + const source = '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/missing-owner/demo'; + expect(() => rememberSkillImport(source, { ...native, level: 'project' })).toThrow('workspace ID'); + expect(localStorage.getItem(key(source))).toBeNull(); + const receipt = { schemaVersion: 1 as const, sourcePath: source, nativeKey: native.key, nativePath: native.path, level: 'project' as const }; + localStorage.setItem(key(source), JSON.stringify(receipt)); + expect(readSkillImportReceipt(source)).toBeNull(); + expect(readSkillImportReceipt(source, 'workspace-a')).toBeNull(); + await expect(applyImportUndo({ kind: 'skill', target: native.path, receipt })).rejects.toThrow('workspace ID'); + expect(mocks.scan).not.toHaveBeenCalled(); + expect(localStorage.getItem(key(source))).toBe(JSON.stringify(receipt)); }); it.each(['{broken', '{"schemaVersion":99,"important":"preserve"}'])('preserves unreadable or newer receipts: %s', (stored) => { diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.ts index 6b784b9028..a720c7a412 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemImportUndo.ts @@ -15,29 +15,42 @@ export interface SkillImportReceipt { } const receipts = new Map(); -const receiptKey = (sourcePath: string, workspacePath?: string) => - `openbitfun:external-skill-import:${JSON.stringify([workspacePath ?? '', sourcePath])}`; +const receiptKey = (sourcePath: string, workspaceId?: string) => + `openbitfun:external-skill-import:v2:${JSON.stringify([getActiveSurfaceScope().surfaceId, workspaceId ?? '', sourcePath])}`; /** Only native copy identities are saved; external content and credentials never enter browser storage. */ -export function readSkillImportReceipt(sourcePath: string, workspacePath?: string): SkillImportReceipt | null { - const key = receiptKey(sourcePath, workspacePath); - try { - const value = JSON.parse(localStorage.getItem(key) ?? localStorage.getItem(receiptKey(sourcePath)) ?? 'null'); - if (value?.schemaVersion === 1 && value.sourcePath === sourcePath +export function readSkillImportReceipt(sourcePath: string, workspaceId?: string): SkillImportReceipt | null { + const readAt = (key: string, allowProject: boolean): SkillImportReceipt | null => { + const valid = (value: SkillImportReceipt | null | undefined): value is SkillImportReceipt => + value?.schemaVersion === 1 && value.sourcePath === sourcePath && typeof value.nativeKey === 'string' && typeof value.nativePath === 'string' - && ['user', 'project'].includes(value.level)) return value; - return receipts.get(key) ?? receipts.get(receiptKey(sourcePath)) ?? null; - } catch { /* Preserve unreadable storage; the current session can still undo its own imports. */ } - return receipts.get(key) ?? receipts.get(receiptKey(sourcePath)) ?? null; + && (value.level === 'user' || (allowProject && value.level === 'project')); + try { + const raw = localStorage.getItem(key); + if (raw === 'null') return null; + const value = raw ? JSON.parse(raw) : undefined; + if (valid(value)) return value; + } catch { /* Preserve unreadable storage; only the current session's own receipt may substitute. */ } + const memory = receipts.get(key); + return valid(memory) ? memory : null; + }; + if (workspaceId) { + const scoped = readAt(receiptKey(sourcePath, workspaceId), true); + if (scoped) return scoped; + } + // User-level imports are shared. A malformed project receipt in the global + // slot must never become a receipt for every workspace. + return readAt(receiptKey(sourcePath), false); } -export function rememberSkillImport(sourcePath: string, native: SkillInfo, workspacePath?: string) { +export function rememberSkillImport(sourcePath: string, native: SkillInfo, workspaceId?: string) { if (!canDeleteSkill(native) || !['user', 'project'].includes(native.level)) return; + if (native.level === 'project' && !workspaceId?.trim()) throw new Error('Project Skill import requires a workspace ID'); const receipt: SkillImportReceipt = { schemaVersion: 1, sourcePath, nativeKey: native.key, nativePath: native.path, level: native.level as 'user' | 'project', }; - const key = receiptKey(sourcePath, native.level === 'project' ? workspacePath : undefined); + const key = receiptKey(sourcePath, native.level === 'project' ? workspaceId : undefined); receipts.set(key, receipt); try { // Do not overwrite an unknown version or an unreadable record. @@ -80,9 +93,9 @@ export async function prepareMcpUndo(candidateId: string): Promise { +export async function prepareHookUndo(source: ExternalHookSource, workspaceId?: string): Promise { const scope = localScope(); - const snapshot = await externalHooksAPI.getImportSnapshot(workspacePath, true); + const snapshot = await externalHooksAPI.getImportSnapshot(workspaceId, true); scope.assertCurrent('review Hook import removal'); const entry = snapshot.imports.find((item) => item.source.key.providerId === source.key.providerId && item.source.key.sourceId === source.key.sourceId && item.source.ecosystemId === source.ecosystemId); @@ -91,28 +104,29 @@ export async function prepareHookUndo(source: ExternalHookSource, workspacePath? } /** Called only after the user confirms removal of the displayed native copy, including its edits. */ -export async function applyImportUndo(review: ImportUndoReview, workspacePath?: string): Promise<{ runtimeApplied: boolean; revision?: string }> { +export async function applyImportUndo(review: ImportUndoReview, workspaceId?: string): Promise<{ runtimeApplied: boolean; revision?: string }> { const scope = localScope(); if (review.kind === 'mcp') return MCPAPI.saveMCPJsonConfig(review.jsonConfig, review.fingerprint); if (review.kind === 'hook') { - const snapshot = await externalHooksAPI.mutateImport(workspacePath, review.revision, { kind: 'remove', importId: review.importId }); + const snapshot = await externalHooksAPI.mutateImport(workspaceId, review.revision, { kind: 'remove', importId: review.importId }); scope.assertCurrent('confirm Hook import removal'); return { runtimeApplied: true, revision: snapshot.revision }; } else { - const report = await configAPI.getSkillScanReport({ workspacePath, forceRefresh: true }); + if (review.receipt.level === 'project' && !workspaceId?.trim()) throw new Error('Project Skill removal requires a workspace ID'); + const report = await configAPI.getSkillScanReport({ workspaceId, forceRefresh: true }); scope.assertCurrent('remove imported Skill copy'); if (!report.skills.some((skill) => matchesSkillReceipt(skill, review.receipt))) { throw new Error('Imported Skill copy changed or is no longer available'); } - await configAPI.deleteSkill({ skillKey: review.receipt.nativeKey, workspacePath, + await configAPI.deleteSkill({ skillKey: review.receipt.nativeKey, workspaceId, ...(review.receipt.importId ? { expectedImportId: review.receipt.importId } : {}) }); scope.assertCurrent('confirm Skill import removal'); - const key = receiptKey(review.receipt.sourcePath, review.receipt.level === 'project' ? workspacePath : undefined); + const key = receiptKey(review.receipt.sourcePath, review.receipt.level === 'project' ? workspaceId : undefined); receipts.delete(key); try { const stored = JSON.parse(localStorage.getItem(key) ?? 'null'); if (stored?.schemaVersion === 1 && stored.nativeKey === review.receipt.nativeKey - && stored.nativePath === review.receipt.nativePath) localStorage.removeItem(key); + && stored.nativePath === review.receipt.nativePath) localStorage.setItem(key, 'null'); // Keep an ID tombstone so the old receipt cannot remigrate. } catch { /* Preserve unreadable records; never reset data to recover from a parse failure. */ } } scope.assertCurrent('confirm external import removal'); diff --git a/src/web-ui/src/app/scenes/git/GitNav.tsx b/src/web-ui/src/app/scenes/git/GitNav.tsx index 438ac319c5..fb9672d874 100644 --- a/src/web-ui/src/app/scenes/git/GitNav.tsx +++ b/src/web-ui/src/app/scenes/git/GitNav.tsx @@ -46,7 +46,7 @@ const GitNav: React.FC = () => { untracked, refresh, } = useGitState({ - repositoryPath: workspacePath, + repositoryPath: { workspaceId: workspace?.id ?? '', repositoryPath: workspacePath }, isActive: true, refreshOnMount: true, layers: ['basic', 'status'], diff --git a/src/web-ui/src/app/scenes/git/GitScene.tsx b/src/web-ui/src/app/scenes/git/GitScene.tsx index cacdc5cb1d..6b6a525a72 100644 --- a/src/web-ui/src/app/scenes/git/GitScene.tsx +++ b/src/web-ui/src/app/scenes/git/GitScene.tsx @@ -39,7 +39,7 @@ const GitScene: React.FC = ({ isLoading: statusLoading, refresh, } = useGitState({ - repositoryPath: workspacePath, + repositoryPath: { workspaceId: workspace?.id ?? '', repositoryPath: workspacePath }, isActive, refreshOnMount: true, layers: ['basic', 'status'], @@ -83,26 +83,26 @@ const GitScene: React.FC = ({ if (!workspacePath || isTrusting) return; setIsTrusting(true); try { - const trusted = await requestGitRepositoryTrust(workspacePath, { userInitiated: true }); + const trusted = await requestGitRepositoryTrust({ workspaceId: workspace?.id ?? '', repositoryPath: workspacePath }, { userInitiated: true }); if (trusted) { await refresh({ force: true, layers: ['basic', 'status'], reason: 'manual' }); } } finally { setIsTrusting(false); } - }, [workspacePath, isTrusting, refresh]); + }, [workspacePath, isTrusting, refresh, workspace?.id]); const renderView = useCallback(() => { switch (activeView) { case 'branches': - return ; + return ; case 'graph': - return ; + return ; case 'working-copy': default: - return ; + return ; } - }, [activeView, isActive, workspacePath]); + }, [activeView, isActive, workspacePath, workspace?.id]); if (!isActive) { return