diff --git a/apps/app/package.json b/apps/app/package.json index 973d6febc..a6cb9923e 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -17,6 +17,7 @@ "test:events": "node scripts/events.mjs", "test:todos": "node scripts/todos.mjs", "test:permissions": "node scripts/permissions.mjs", + "test:session-scope": "bun scripts/session-scope.ts", "test:session-switch": "node scripts/session-switch.mjs", "test:fs-engine": "node scripts/fs-engine.mjs", "test:local-file-path": "node scripts/local-file-path.mjs", diff --git a/apps/app/scripts/session-scope.ts b/apps/app/scripts/session-scope.ts new file mode 100644 index 000000000..946ae020b --- /dev/null +++ b/apps/app/scripts/session-scope.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", + }, +}); + +const { + resolveScopedClientDirectory, + scopedRootsMatch, + shouldApplyScopedSessionLoad, + shouldRedirectMissingSessionAfterScopedLoad, + toSessionTransportDirectory, +} = await import("../src/app/lib/session-scope.ts"); + +const starterRoot = "/Users/test/OpenWork/starter"; +const otherRoot = "/Users/test/OpenWork/second"; + +const results = { + ok: true, + steps: [] as Array>, +}; + +async function step(name: string, fn: () => void | Promise) { + results.steps.push({ name, status: "running" }); + const index = results.steps.length - 1; + + try { + await fn(); + results.steps[index] = { name, status: "ok" }; + } catch (error) { + results.ok = false; + results.steps[index] = { + name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + throw error; + } +} + +try { + await step("local connect prefers explicit target root", () => { + assert.equal( + resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }), + starterRoot, + ); + assert.equal( + resolveScopedClientDirectory({ + workspaceType: "local", + directory: otherRoot, + targetRoot: starterRoot, + }), + otherRoot, + ); + }); + + await step("remote connect still waits for remote discovery", () => { + assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), ""); + }); + + await step("scope matching is stable on desktop-style paths", () => { + assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true); + assert.equal(scopedRootsMatch(starterRoot, otherRoot), false); + }); + + await step("stale session loads cannot overwrite another workspace sidebar", () => { + for (let index = 0; index < 50; index += 1) { + assert.equal( + shouldApplyScopedSessionLoad({ + loadedScopeRoot: otherRoot, + workspaceRoot: starterRoot, + }), + false, + ); + } + }); + + await step("same-scope session loads still update the active workspace", () => { + assert.equal( + shouldApplyScopedSessionLoad({ + loadedScopeRoot: `${starterRoot}/`, + workspaceRoot: starterRoot, + }), + true, + ); + }); + + await step("windows create and list use the same transport directory", () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + }); + + const winRoot = String.raw`C:\Users\Test\OpenWork\starter`; + const transport = toSessionTransportDirectory(winRoot); + + assert.equal(transport, "C:/Users/Test/OpenWork/starter"); + assert.equal(resolveScopedClientDirectory({ workspaceType: "local", targetRoot: winRoot }), transport); + assert.equal(resolveScopedClientDirectory({ workspaceType: "local", directory: winRoot }), transport); + }); + + await step("route guard only redirects when the loaded scope matches", () => { + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: otherRoot, + workspaceRoot: starterRoot, + hasMatchingSession: false, + }), + false, + ); + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: starterRoot, + workspaceRoot: starterRoot, + hasMatchingSession: false, + }), + true, + ); + assert.equal( + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: starterRoot, + workspaceRoot: starterRoot, + hasMatchingSession: true, + }), + false, + ); + }); + + console.log(JSON.stringify(results, null, 2)); +} catch (error) { + results.ok = false; + console.error( + JSON.stringify( + { + ...results, + error: error instanceof Error ? error.message : String(error), + }, + null, + 2, + ), + ); + process.exitCode = 1; +} diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index cab68d1d1..4a3abce16 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -161,6 +161,11 @@ import { normalizeModelBehaviorValue, sanitizeModelBehaviorValue, } from "./lib/model-behavior"; +import { + shouldApplyScopedSessionLoad, + shouldRedirectMissingSessionAfterScopedLoad, + toSessionTransportDirectory, +} from "./lib/session-scope"; const fileToDataUrl = (file: File) => new Promise((resolve, reject) => { @@ -1499,6 +1504,7 @@ export default function App() { const { sessions, + loadedScopeRoot: loadedSessionScopeRoot, sessionById, sessionStatusById, selectedSession, @@ -2197,7 +2203,8 @@ export default function App() { } const root = workspaceStore.activeWorkspaceRoot().trim(); - const params = root ? { sessionID: trimmed, directory: root } : { sessionID: trimmed }; + const directory = toSessionTransportDirectory(root); + const params = directory ? { sessionID: trimmed, directory } : { sessionID: trimmed }; unwrap(await c.session.delete(params)); // Remove the deleted session from the store and sidebar locally. @@ -3096,7 +3103,7 @@ export default function App() { if (workspace.workspaceType === "local") { const info = workspaceStore.engine(); const baseUrl = info?.baseUrl?.trim() ?? ""; - const directory = workspace.path?.trim() ?? ""; + const directory = toSessionTransportDirectory(workspace.path?.trim() ?? ""); const username = info?.opencodeUsername?.trim() ?? ""; const password = info?.opencodePassword?.trim() ?? ""; const auth: OpencodeAuth | undefined = username && password ? { username, password } : undefined; @@ -3331,6 +3338,21 @@ export default function App() { ? activeWorkspace.path : activeWorkspace?.directory ?? activeWorkspace?.path, ); + if ( + !shouldApplyScopedSessionLoad({ + loadedScopeRoot: loadedSessionScopeRoot(), + workspaceRoot: activeWorkspaceRoot, + }) + ) { + if (developerMode()) { + console.log("[sidebar-sync] skip stale session scope", { + wsId, + loadedScopeRoot: loadedSessionScopeRoot(), + activeWorkspaceRoot, + }); + } + return; + } const scopedSessions = activeWorkspaceRoot ? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot) : allSessions; @@ -6189,7 +6211,7 @@ export default function App() { try { mark("session:create:start"); rawResult = await c.session.create({ - directory: workspaceStore.activeWorkspaceRoot().trim(), + directory: toSessionTransportDirectory(workspaceStore.activeWorkspaceRoot().trim()) || undefined, }); mark("session:create:ok"); } catch (createErr) { @@ -7774,7 +7796,14 @@ export default function App() { // If the URL points at a session that no longer exists (e.g. after deletion), // route back to /session so the app can fall back safely. - if (sessionsLoaded() && !sessions().some((session) => session.id === id)) { + if ( + sessionsLoaded() && + shouldRedirectMissingSessionAfterScopedLoad({ + loadedScopeRoot: loadedSessionScopeRoot(), + workspaceRoot: workspaceStore.activeWorkspaceRoot().trim(), + hasMatchingSession: sessions().some((session) => session.id === id), + }) + ) { if (selectedSessionId() === id) { setSelectedSessionId(null); } diff --git a/apps/app/src/app/context/session.ts b/apps/app/src/app/context/session.ts index 79508d3a1..bf53764a1 100644 --- a/apps/app/src/app/context/session.ts +++ b/apps/app/src/app/context/session.ts @@ -203,6 +203,7 @@ export function createSessionStore(options: { const [messageLimitBySession, setMessageLimitBySession] = createSignal>({}); const [messageCompleteBySession, setMessageCompleteBySession] = createSignal>({}); const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal>({}); + const [loadedScopeRoot, setLoadedScopeRoot] = createSignal(""); const reloadDetectionSet = new Set(); const invalidToolDetectionSet = new Set(); const syntheticContinueEventTimesBySession = new Map(); @@ -819,6 +820,7 @@ export function createSessionStore(options: { })), }); sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length }); + setLoadedScopeRoot(root); rememberSessions(filtered); setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" })); } @@ -1727,6 +1729,7 @@ export function createSessionStore(options: { return { sessions, + loadedScopeRoot, sessionById, sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []), selectedSessionErrorTurns: createMemo(() => { diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts index 8345f6ace..d212f5982 100644 --- a/apps/app/src/app/context/workspace.ts +++ b/apps/app/src/app/context/workspace.ts @@ -21,6 +21,7 @@ import { writeStartupPreference, } from "../utils"; import { unwrap } from "../lib/opencode"; +import { resolveScopedClientDirectory } from "../lib/session-scope"; import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, @@ -762,12 +763,13 @@ export function createWorkspaceStore(options: { ) { const now = Date.now(); if (now - lastEngineReconnectAt > 10_000) { + const reconnectRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || ""; lastEngineReconnectAt = now; reconnectingEngine = true; connectToServer( info.baseUrl, - info.projectDir ?? undefined, - { reason: "engine-refresh" }, + reconnectRoot || undefined, + { workspaceType: "local", targetRoot: reconnectRoot, reason: "engine-refresh" }, auth ?? undefined, { quiet: true, navigate: false }, ) @@ -1174,8 +1176,8 @@ export function createWorkspaceStore(options: { if (nextInfo.baseUrl) { connectedToLocalHost = await connectToServer( nextInfo.baseUrl, - nextInfo.projectDir ?? undefined, - { reason: "workspace-attach-local" }, + next.path, + { workspaceType: "local", targetRoot: next.path, reason: "workspace-attach-local" }, auth, { navigate: false }, ); @@ -1232,8 +1234,8 @@ export function createWorkspaceStore(options: { if (newInfo.baseUrl) { const ok = await connectToServer( newInfo.baseUrl, - newInfo.projectDir ?? undefined, - { reason: "workspace-orchestrator-switch" }, + next.path, + { workspaceType: "local", targetRoot: next.path, reason: "workspace-orchestrator-switch" }, auth, { navigate: false }, ); @@ -1267,8 +1269,8 @@ export function createWorkspaceStore(options: { if (newInfo.baseUrl) { const ok = await connectToServer( newInfo.baseUrl, - newInfo.projectDir ?? undefined, - { reason: "workspace-restart" }, + next.path, + { workspaceType: "local", targetRoot: next.path, reason: "workspace-restart" }, auth, { navigate: false }, ); @@ -1363,7 +1365,11 @@ export function createWorkspaceStore(options: { const connectMetrics: NonNullable = {}; try { - let resolvedDirectory = directory?.trim() ?? ""; + let resolvedDirectory = resolveScopedClientDirectory({ + directory, + targetRoot: context?.targetRoot, + workspaceType: context?.workspaceType ?? "local", + }); let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth); const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason); const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs }); @@ -2855,16 +2861,16 @@ export function createWorkspaceStore(options: { const auth = username && password ? { username, password } : undefined; setEngineAuth(auth ?? null); - if (info.baseUrl) { - const ok = await connectToServer( - info.baseUrl, - info.projectDir ?? undefined, - { reason: "host-start" }, - auth, - { navigate: optionsOverride?.navigate ?? true }, - ); - if (!ok) return false; - } + if (info.baseUrl) { + const ok = await connectToServer( + info.baseUrl, + dir, + { workspaceType: "local", targetRoot: dir, reason: "host-start" }, + auth, + { navigate: optionsOverride?.navigate ?? true }, + ); + if (!ok) return false; + } markOnboardingComplete(); return true; @@ -3022,8 +3028,8 @@ export function createWorkspaceStore(options: { if (nextInfo.baseUrl) { const ok = await connectToServer( nextInfo.baseUrl, - nextInfo.projectDir ?? undefined, - { reason: "engine-reload-orchestrator" }, + root, + { workspaceType: "local", targetRoot: root, reason: "engine-reload-orchestrator" }, auth, ); if (!ok) { @@ -3057,8 +3063,8 @@ export function createWorkspaceStore(options: { if (nextInfo.baseUrl) { const ok = await connectToServer( nextInfo.baseUrl, - nextInfo.projectDir ?? undefined, - { reason: "engine-reload" }, + root, + { workspaceType: "local", targetRoot: root, reason: "engine-reload" }, auth, ); if (!ok) { @@ -3343,11 +3349,12 @@ export function createWorkspaceStore(options: { options.setStartupPreference("local"); if (info?.running && info.baseUrl) { + const bootstrapRoot = activeWorkspacePath().trim() || info.projectDir?.trim() || ""; options.setOnboardingStep("connecting"); const ok = await connectToServer( info.baseUrl, - info.projectDir ?? undefined, - { reason: "bootstrap-local" }, + bootstrapRoot || undefined, + { workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" }, engineAuth() ?? undefined, ); if (!ok) { @@ -3417,10 +3424,11 @@ export function createWorkspaceStore(options: { async function onAttachHost() { options.setStartupPreference("local"); options.setOnboardingStep("connecting"); + const attachRoot = activeWorkspacePath().trim() || engine()?.projectDir?.trim() || ""; const ok = await connectToServer( engine()?.baseUrl ?? "", - engine()?.projectDir ?? undefined, - { reason: "attach-local" }, + attachRoot || undefined, + { workspaceType: "local", targetRoot: attachRoot, reason: "attach-local" }, engineAuth() ?? undefined, ); if (!ok) { diff --git a/apps/app/src/app/lib/session-scope.ts b/apps/app/src/app/lib/session-scope.ts new file mode 100644 index 000000000..c27b1c34e --- /dev/null +++ b/apps/app/src/app/lib/session-scope.ts @@ -0,0 +1,50 @@ +import { normalizeDirectoryPath } from "../utils"; +import { normalizeDirectoryQueryPath } from "../utils"; + +type WorkspaceType = "local" | "remote"; + +export function resolveScopedClientDirectory(input: { + directory?: string | null; + targetRoot?: string | null; + workspaceType?: WorkspaceType | null; +}) { + const directory = toSessionTransportDirectory(input.directory); + if (directory) return directory; + + if (input.workspaceType === "remote") return ""; + + return toSessionTransportDirectory(input.targetRoot); +} + +export function toSessionTransportDirectory(input?: string | null) { + return normalizeDirectoryQueryPath(input); +} + +export function scopedRootsMatch(a?: string | null, b?: string | null) { + const left = normalizeDirectoryPath(a ?? ""); + const right = normalizeDirectoryPath(b ?? ""); + if (!left || !right) return false; + return left === right; +} + +export function shouldApplyScopedSessionLoad(input: { + loadedScopeRoot?: string | null; + workspaceRoot?: string | null; +}) { + const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? ""); + if (!workspaceRoot) return true; + return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot); +} + +export function shouldRedirectMissingSessionAfterScopedLoad(input: { + loadedScopeRoot?: string | null; + workspaceRoot?: string | null; + hasMatchingSession: boolean; +}) { + if (input.hasMatchingSession) return false; + + const workspaceRoot = normalizeDirectoryPath(input.workspaceRoot ?? ""); + if (!workspaceRoot) return false; + + return scopedRootsMatch(input.loadedScopeRoot, workspaceRoot); +} diff --git a/apps/desktop/src-tauri/src/commands/workspace.rs b/apps/desktop/src-tauri/src/commands/workspace.rs index 43c4a7937..9b2432c01 100644 --- a/apps/desktop/src-tauri/src/commands/workspace.rs +++ b/apps/desktop/src-tauri/src/commands/workspace.rs @@ -8,8 +8,8 @@ use crate::types::{ }; use crate::workspace::files::ensure_workspace_files; use crate::workspace::state::{ - load_workspace_state, save_workspace_state, stable_workspace_id, - stable_workspace_id_for_openwork, stable_workspace_id_for_remote, + load_workspace_state, normalize_local_workspace_path, save_workspace_state, + stable_workspace_id, stable_workspace_id_for_openwork, stable_workspace_id_for_remote, }; use crate::workspace::watch::{update_workspace_watch, WorkspaceWatchState}; use serde::Serialize; @@ -161,7 +161,7 @@ pub fn workspace_create( watch_state: State, ) -> Result { println!("[workspace] create local request"); - let folder = folder_path.trim().to_string(); + let mut folder = folder_path.trim().to_string(); if folder.is_empty() { return Err("folderPath is required".to_string()); } @@ -179,6 +179,7 @@ pub fn workspace_create( }; fs::create_dir_all(&folder).map_err(|e| format!("Failed to create workspace folder: {e}"))?; + folder = normalize_local_workspace_path(&folder); let id = stable_workspace_id(&folder); @@ -881,6 +882,7 @@ pub fn workspace_import_config( .trim() .to_string(); + let target_dir = normalize_local_workspace_path(&target_dir); let id = stable_workspace_id(&target_dir); let mut state = load_workspace_state(&app)?; diff --git a/apps/desktop/src-tauri/src/workspace/state.rs b/apps/desktop/src-tauri/src/workspace/state.rs index 32ab0febc..ceef86c2b 100644 --- a/apps/desktop/src-tauri/src/workspace/state.rs +++ b/apps/desktop/src-tauri/src/workspace/state.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use sha2::{Digest, Sha256}; use tauri::Manager; +use crate::paths::home_dir; use crate::types::{WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION}; pub fn stable_workspace_id(path: &str) -> String { @@ -12,6 +13,29 @@ pub fn stable_workspace_id(path: &str) -> String { format!("ws_{}", &hex[..12]) } +pub fn normalize_local_workspace_path(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let expanded = if trimmed == "~" { + home_dir().unwrap_or_else(|| PathBuf::from(trimmed)) + } else if trimmed.starts_with("~/") || trimmed.starts_with("~\\") { + if let Some(home) = home_dir() { + let suffix = trimmed[2..].trim_start_matches(['/', '\\']); + home.join(suffix) + } else { + PathBuf::from(trimmed) + } + } else { + PathBuf::from(trimmed) + }; + + let normalized = fs::canonicalize(&expanded).unwrap_or(expanded); + normalized.to_string_lossy().to_string() +} + pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> { let data_dir = app .path() @@ -36,7 +60,13 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result stable_workspace_id(&workspace.path), + WorkspaceType::Local => { + let normalized = normalize_local_workspace_path(&workspace.path); + if !normalized.is_empty() { + workspace.path = normalized; + } + stable_workspace_id(&workspace.path) + } WorkspaceType::Remote => { if workspace.remote_type == Some(crate::types::RemoteType::Openwork) { stable_workspace_id_for_openwork( @@ -108,3 +138,43 @@ pub fn stable_workspace_id_for_openwork(host_url: &str, workspace_id: Option<&st } stable_workspace_id(&key) } + +#[cfg(test)] +mod tests { + use super::{normalize_local_workspace_path, stable_workspace_id}; + use std::fs; + + #[test] + fn normalize_local_workspace_path_expands_home_prefix() { + let home = crate::paths::home_dir().expect("home dir"); + let expected = home.join("OpenWork").join("openwork-state-test-expand"); + let actual = normalize_local_workspace_path("~/OpenWork/openwork-state-test-expand"); + assert_eq!(actual, expected.to_string_lossy()); + } + + #[test] + fn normalize_local_workspace_path_keeps_canonical_id_stable() { + let temp = std::env::temp_dir().join(format!( + "openwork-workspace-state-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let nested = temp.join("starter"); + fs::create_dir_all(&nested).expect("create temp workspace"); + + let raw = format!("{}/../starter", nested.display()); + let normalized = normalize_local_workspace_path(&raw); + + let canonical = fs::canonicalize(&nested).expect("canonical starter workspace"); + assert_eq!(normalized, canonical.to_string_lossy()); + assert_eq!( + stable_workspace_id(&normalized), + stable_workspace_id(&canonical.to_string_lossy()) + ); + + let _ = fs::remove_dir_all(&temp); + } +} diff --git a/package.json b/package.json index 868e98f75..b5dfec311 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:events": "pnpm --filter @openwork/app test:events", "test:todos": "pnpm --filter @openwork/app test:todos", "test:permissions": "pnpm --filter @openwork/app test:permissions", + "test:session-scope": "pnpm --filter @openwork/app test:session-scope", "test:session-switch": "pnpm --filter @openwork/app test:session-switch", "test:fs-engine": "pnpm --filter @openwork/app test:fs-engine", "test:e2e": "pnpm --filter @openwork/app test:e2e",