diff --git a/apps/app/package.json b/apps/app/package.json index 2838d2a8d..c0f4e43d4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,6 +18,7 @@ "test:events": "node scripts/events.mjs", "test:todos": "node scripts/todos.mjs", "test:permissions": "node scripts/permissions.mjs", + "test:openwork-selection": "bun scripts/openwork-selection.ts", "test:session-scope": "bun scripts/session-scope.ts", "test:session-switch": "node scripts/session-switch.mjs", "test:fs-engine": "node scripts/fs-engine.mjs", diff --git a/apps/app/scripts/openwork-selection.ts b/apps/app/scripts/openwork-selection.ts new file mode 100644 index 000000000..4e2c65e9e --- /dev/null +++ b/apps/app/scripts/openwork-selection.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; + +const { selectOpenworkWorkspace } = await import("../src/app/workspace/openwork-selection.ts"); + +const workspace = (id: string, directory?: string) => ({ + id, + name: id, + path: directory ?? "", + preset: "remote", + workspaceType: "remote" as const, + remoteType: "openwork" as const, + baseUrl: `https://example.com/w/${id}/opencode`, + directory: directory ?? null, + opencode: directory ? { directory } : undefined, +}); + +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("explicit workspace id wins", () => { + const result = selectOpenworkWorkspace({ + items: [workspace("ws_alpha", "/repo/a"), workspace("ws_beta", "/repo/b")], + workspaceId: "ws_beta", + }); + + assert.deepEqual(result, { ok: true, workspace: workspace("ws_beta", "/repo/b") }); + }); + + await step("directory hint resolves normalized matches", () => { + const result = selectOpenworkWorkspace({ + items: [workspace("ws_alpha", "/repo/a"), workspace("ws_beta", "/repo/b")], + directoryHint: "/repo/b/", + }); + + assert.deepEqual(result, { ok: true, workspace: workspace("ws_beta", "/repo/b") }); + }); + + await step("single-workspace hosts still connect without extra selectors", () => { + const result = selectOpenworkWorkspace({ + items: [workspace("ws_only", "/repo/a")], + }); + + assert.deepEqual(result, { ok: true, workspace: workspace("ws_only", "/repo/a") }); + }); + + await step("multi-workspace hosts reject ambiguous host-only connects", () => { + const result = selectOpenworkWorkspace({ + items: [workspace("ws_alpha", "/repo/a"), workspace("ws_beta", "/repo/b")], + }); + + assert.deepEqual(result, { + ok: false, + reason: "ambiguous", + message: + "OpenWork host returned multiple workspaces. Use a workspace-scoped URL (/w/ws_*) or reconnect from the specific workspace.", + }); + }); + + await step("stale directory hints no longer silently fall back to the first workspace", () => { + const result = selectOpenworkWorkspace({ + items: [workspace("ws_alpha", "/repo/a"), workspace("ws_beta", "/repo/b")], + directoryHint: "/repo/missing", + }); + + assert.deepEqual(result, { + ok: false, + reason: "not-found", + message: "OpenWork worker directory not found on that host.", + }); + }); + + console.log(JSON.stringify(results)); +} catch { + console.log(JSON.stringify(results)); + process.exitCode = 1; +} diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 8b352ff35..23eac7af5 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -612,7 +612,9 @@ export default function App() { if (!id) return false; const ready = await workspaceStore.activateWorkspace(id); if (ready) { - await refreshSidebarWorkspaceSessions(id).catch(() => undefined); + // Keep workspace activation on the critical path for correctness, but do + // not block session creation or prompt send on a sidebar refresh. + void refreshSidebarWorkspaceSessions(id).catch(() => undefined); } return ready; }; diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts index aeebde2d3..cb79410f1 100644 --- a/apps/app/src/app/context/workspace.ts +++ b/apps/app/src/app/context/workspace.ts @@ -33,6 +33,7 @@ import { type OpenworkServerClient, type OpenworkWorkspaceInfo, } from "../lib/openwork-server"; +import { selectOpenworkWorkspace } from "../workspace/openwork-selection"; import { downloadDir, homeDir } from "@tauri-apps/api/path"; import { engineDoctor, @@ -816,32 +817,15 @@ export function createWorkspaceStore(options: { } const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const hint = normalizeDirectoryPath(input.directoryHint ?? ""); - const selectByHint = (entry: OpenworkWorkspaceInfo) => { - if (!hint) return false; - const entryPath = normalizeDirectoryPath( - (entry.opencode?.directory as string | undefined) ?? (entry.path as string | undefined) ?? "", - ); - return Boolean(entryPath && entryPath === hint); - }; - const selectById = (entry: OpenworkWorkspaceInfo) => Boolean(requestedWorkspaceId && entry?.id === requestedWorkspaceId); - - const workspaceById = requestedWorkspaceId - ? (items.find((item) => item?.id && selectById(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - if (requestedWorkspaceId && !workspaceById) { - throw new Error("OpenWork worker not found on that host."); - } - - const workspaceByHint = hint - ? (items.find((item) => item?.id && selectByHint(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - - const workspace = (workspaceById ?? workspaceByHint ?? items[0]) as OpenworkWorkspaceInfo | undefined; - if (!workspace?.id) { - throw new Error("OpenWork server did not return a worker."); + const selected = selectOpenworkWorkspace({ + items: Array.isArray(response.items) ? response.items : [], + workspaceId: requestedWorkspaceId, + directoryHint: input.directoryHint ?? null, + }); + if (!selected.ok) { + throw new Error(selected.message); } + const workspace = selected.workspace; const opencodeUpstreamBaseUrl = workspace.opencode?.baseUrl?.trim() ?? workspace.baseUrl?.trim() ?? ""; if (!opencodeUpstreamBaseUrl) { throw new Error("OpenWork server did not provide an OpenCode URL."); @@ -1201,7 +1185,7 @@ export function createWorkspaceStore(options: { const resolved = await resolveOpenworkHost({ hostUrl, token, - workspaceId: workspace.openworkWorkspaceId ?? null, + workspaceId: resolveRuntimeWorkspaceLookup(workspace)?.workspaceId ?? null, }); if (resolved.kind !== "openwork") { updateWorkspaceConnectionState(id, { @@ -1509,7 +1493,7 @@ export function createWorkspaceStore(options: { const resolved = await resolveOpenworkHost({ hostUrl, token, - workspaceId: next.openworkWorkspaceId ?? null, + workspaceId: resolveRuntimeWorkspaceLookup(next)?.workspaceId ?? null, directoryHint: next.directory ?? null, }); if (resolved.kind !== "openwork") { @@ -2792,7 +2776,7 @@ export function createWorkspaceStore(options: { const resolved = await resolveOpenworkHost({ hostUrl, token, - workspaceId: workspace.openworkWorkspaceId ?? null, + workspaceId: resolveRuntimeWorkspaceLookup(workspace)?.workspaceId ?? null, directoryHint: directory || null, }); if (resolved.kind !== "openwork") { diff --git a/apps/app/src/app/workspace/openwork-selection.ts b/apps/app/src/app/workspace/openwork-selection.ts new file mode 100644 index 000000000..cf8647260 --- /dev/null +++ b/apps/app/src/app/workspace/openwork-selection.ts @@ -0,0 +1,80 @@ +import type { OpenworkWorkspaceInfo } from "../lib/openwork-server"; +import { normalizeDirectoryPath } from "../utils"; + +type OpenworkWorkspaceSelectionReason = "missing" | "not-found" | "ambiguous"; + +export type OpenworkWorkspaceSelectionResult = + | { + ok: true; + workspace: OpenworkWorkspaceInfo; + } + | { + ok: false; + reason: OpenworkWorkspaceSelectionReason; + message: string; + }; + +const workspaceDirectory = (entry: OpenworkWorkspaceInfo) => + normalizeDirectoryPath((entry.opencode?.directory as string | undefined) ?? entry.directory ?? entry.path ?? ""); + +export function selectOpenworkWorkspace(input: { + items: OpenworkWorkspaceInfo[]; + workspaceId?: string | null; + directoryHint?: string | null; +}): OpenworkWorkspaceSelectionResult { + const items = Array.isArray(input.items) ? input.items.filter((entry): entry is OpenworkWorkspaceInfo => Boolean(entry?.id)) : []; + const explicitWorkspaceId = input.workspaceId?.trim() ?? ""; + const directoryHint = normalizeDirectoryPath(input.directoryHint ?? ""); + + if (!items.length) { + return { + ok: false, + reason: "missing", + message: "OpenWork server did not return a worker.", + }; + } + + if (explicitWorkspaceId) { + const exact = items.find((entry) => entry.id === explicitWorkspaceId); + if (exact) { + return { ok: true, workspace: exact }; + } + return { + ok: false, + reason: "not-found", + message: "OpenWork worker not found on that host.", + }; + } + + if (directoryHint) { + const matches = items.filter((entry) => workspaceDirectory(entry) === directoryHint); + if (matches.length === 1) { + return { ok: true, workspace: matches[0] }; + } + if (matches.length > 1) { + return { + ok: false, + reason: "ambiguous", + message: "OpenWork host returned multiple workspaces for that directory.", + }; + } + if (items.length === 1) { + return { ok: true, workspace: items[0] }; + } + return { + ok: false, + reason: "not-found", + message: "OpenWork worker directory not found on that host.", + }; + } + + if (items.length === 1) { + return { ok: true, workspace: items[0] }; + } + + return { + ok: false, + reason: "ambiguous", + message: "OpenWork host returned multiple workspaces. Use a workspace-scoped URL (/w/ws_*) or reconnect from the specific workspace.", + }; +}