diff --git a/README.md b/README.md index 9a46f4d..60e5f99 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,11 @@ Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator to Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. New sessions inherit the calling session's agent, model, and variant unless explicitly overridden. It rejects self-targeting lifecycle calls, arbitrary directories, unknown V2 agent overrides, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. -When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees otherwise. +When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees only when the experimental API or Rift adapter is unavailable. + +Kompass preserves the OpenCode workspace ID when it creates future sessions in Rift workspaces. Existing sessions without workspace identity are not migrated automatically. Rift workspaces are experimental OpenCode workspaces, not legacy Desktop sandboxes, and Kompass does not modify `project.sandboxes` or OpenCode's workspace database. + +Current OpenCode Desktop versions may not display adapter-backed workspaces until Desktop adopts the experimental workspace API. Desktop also currently filters Home sessions through legacy sandbox metadata, does not enumerate experimental adapters, and OpenCode synchronization may retain stale or duplicate workspace records. Users can select or move sessions between available locations with the TUI `/warp` workflow. OpenCode's public plugin workspace adapter type also needs to expose the runtime-supported optional `list()` method upstream. ## Workspace diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 9a46f4d..60e5f99 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -80,7 +80,11 @@ Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator to Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. New sessions inherit the calling session's agent, model, and variant unless explicitly overridden. It rejects self-targeting lifecycle calls, arbitrary directories, unknown V2 agent overrides, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. -When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees otherwise. +When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees only when the experimental API or Rift adapter is unavailable. + +Kompass preserves the OpenCode workspace ID when it creates future sessions in Rift workspaces. Existing sessions without workspace identity are not migrated automatically. Rift workspaces are experimental OpenCode workspaces, not legacy Desktop sandboxes, and Kompass does not modify `project.sandboxes` or OpenCode's workspace database. + +Current OpenCode Desktop versions may not display adapter-backed workspaces until Desktop adopts the experimental workspace API. Desktop also currently filters Home sessions through legacy sandbox metadata, does not enumerate experimental adapters, and OpenCode synchronization may retain stale or duplicate workspace records. Users can select or move sessions between available locations with the TUI `/warp` workflow. OpenCode's public plugin workspace adapter type also needs to expose the runtime-supported optional `list()` method upstream. ## Workspace diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index bc91f08..b4a0c25 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -37,11 +37,13 @@ type NativeWorkspace = { directory?: string | null; projectID?: string; }; +type NavigatorLocation = { directory: string; workspaceID?: string }; export interface SessionSummary { sessionID: string; projectID: string; directory: string; + workspaceID?: string; title: string; agent?: string; model?: { providerID: string; modelID: string }; @@ -87,7 +89,7 @@ type NavigatorContext = { legacyClient: (directory: string) => NavigatorLegacyClient; }; -type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; projectID?: string; type: "worktree" | "rift" }; +type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; workspaceID?: string; projectID?: string; type: "worktree" | "rift" }; const explicitNavigatorUse = "Use only when the user explicitly asks to create or manage native OpenCode sessions, worktrees, or a multi-session workflow. Do not use for subagent delegation; use the built-in task tool instead."; @@ -110,9 +112,9 @@ function envelopeData(response: { data?: { data: T }; error?: unknown }, oper return responseData(response, operation).data; } -async function assertKnownV2Agent(client: NavigatorClient, directory: string, agent: string) { +async function assertKnownV2Agent(client: NavigatorClient, location: NavigatorLocation, agent: string) { const agents = envelopeData>( - await client.v2.agent.list({ location: { directory } }), + await client.v2.agent.list({ location }), "OpenCode agent list", ); if (agents.some((item) => item.id === agent)) return; @@ -141,7 +143,7 @@ function workspaceApi(client: NavigatorClient): WorkspaceApi | undefined { async function hasRiftAdapter(client: NavigatorClient, checkout: string) { const api = workspaceApi(client); - if (!api?.workspace?.adapter?.list || !api.workspace.create) return false; + if (!api?.workspace?.adapter?.list) return false; const adapters = responseData(await api.workspace.adapter.list({ directory: checkout }), "OpenCode workspace adapter list"); return adapters.some((adapter) => adapter.type === "rift"); } @@ -150,6 +152,7 @@ function normalizeRiftWorkspace(workspace: NativeWorkspace): NativeWorktree | un if (workspace.type !== "rift" || !workspace.directory) return; return { id: workspace.id, + workspaceID: workspace.id, type: "rift", directory: workspace.directory, name: workspace.name || path.basename(workspace.directory), @@ -160,16 +163,34 @@ function normalizeRiftWorkspace(workspace: NativeWorkspace): NativeWorktree | un async function listRiftWorkspaces(client: NavigatorClient, checkout: string, projectID: string) { const api = workspaceApi(client); - if (!api?.workspace?.list || !api.workspace.adapter?.list) return []; + if (!api?.workspace?.adapter?.list) return []; if (!(await hasRiftAdapter(client, checkout))) return []; - await api.workspace.syncList?.({ directory: checkout }).catch(() => undefined); + if (!api.workspace.list) { + throw new Error("OpenCode advertises the Rift workspace adapter but workspace listing is unavailable"); + } + if (api.workspace.syncList) { + const response = await api.workspace.syncList({ directory: checkout }); + if (response.error !== undefined) failResponse(response.error, "OpenCode Rift workspace synchronization"); + } const workspaces = responseData(await api.workspace.list({ directory: checkout }), "OpenCode workspace list"); - return workspaces + const rifts = workspaces .filter((workspace) => !workspace.projectID || workspace.projectID === projectID) .flatMap((workspace) => { const normalized = normalizeRiftWorkspace(workspace); return normalized ? [normalized] : []; }); + const unique = new Map(); + for (const rift of rifts) { + const directory = normalizeDirectory(rift.directory); + const existing = unique.get(directory); + if (existing?.workspaceID !== undefined && existing.workspaceID !== rift.workspaceID) { + throw new Error( + `OpenCode returned multiple Rift workspace IDs for ${directory}: ${existing.workspaceID}, ${rift.workspaceID}. Remove stale or duplicate OpenCode workspace records before retrying`, + ); + } + unique.set(directory, rift); + } + return [...unique.values()]; } async function listManagedWorktrees(client: NavigatorClient, checkout: string, projectID?: string) { @@ -179,7 +200,7 @@ async function listManagedWorktrees(client: NavigatorClient, checkout: string, p ) as Array; const worktrees = values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); if (!projectID) return worktrees; - const rifts = await listRiftWorkspaces(client, checkout, projectID).catch(() => []); + const rifts = await listRiftWorkspaces(client, checkout, projectID); const unique = new Map(worktrees.map((item) => [normalizeDirectory(item.directory), item])); for (const rift of rifts) unique.set(normalizeDirectory(rift.directory), rift); return [...unique.values()]; @@ -309,6 +330,7 @@ function summarizeSession(session: SessionV2Info, active: Set): SessionS sessionID: session.id, projectID: session.projectID, directory: session.location.directory, + ...(session.location.workspaceID ? { workspaceID: session.location.workspaceID } : {}), title: session.title, ...(session.agent ? { agent: session.agent } : {}), ...(session.model @@ -563,24 +585,26 @@ export function createNavigatorTools( } : undefined); - if (navigator.protocol === "v2" && selectedAgent) { - await assertKnownV2Agent(client, navigator.checkout, selectedAgent); - } - - let directory = navigator.checkout; + let location: NavigatorLocation = { directory: navigator.checkout }; let createdWorktree: NativeWorktree | undefined; if (args.environment.type === "existing_worktree") { const requestedDirectory = args.environment.directory; const worktrees = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); const requested = worktrees.find((item) => sameDirectory(item.directory, requestedDirectory)); if (!requested) throw new Error("The requested directory is not a managed OpenCode workspace for this project"); - directory = requested.directory; + location = { + directory: requested.directory, + ...(requested.workspaceID ? { workspaceID: requested.workspaceID } : {}), + }; } else if (args.environment.type === "new_worktree") { const worktreesBefore = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); try { const api = workspaceApi(client); - const useRift = !args.environment.startCommand && await hasRiftAdapter(client, navigator.checkout).catch(() => false); - if (useRift && api?.workspace?.create) { + const useRift = !args.environment.startCommand && await hasRiftAdapter(client, navigator.checkout); + if (useRift) { + if (!api?.workspace?.create) { + throw new Error("OpenCode advertises the Rift workspace adapter but workspace creation is unavailable"); + } const workspace = responseData(await api.workspace.create({ directory: navigator.checkout, type: "rift", @@ -607,7 +631,10 @@ export function createNavigatorTools( ); createdWorktree = normalizeWorktree(worktree); } - directory = createdWorktree.directory; + location = { + directory: createdWorktree.directory, + ...(createdWorktree.workspaceID ? { workspaceID: createdWorktree.workspaceID } : {}), + }; } catch (error) { const worktreesAfter = await listManagedWorktrees(client, navigator.checkout, navigator.projectID).catch(() => []); const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory))); @@ -627,9 +654,12 @@ export function createNavigatorTools( let session: SessionV2Info | { id: string; projectID: string }; try { + if (navigator.protocol === "v2" && selectedAgent) { + await assertKnownV2Agent(client, location, selectedAgent); + } session = navigator.protocol === "v1" ? responseData( - await navigator.legacyClient(directory).session.create(), + await navigator.legacyClient(location.directory).session.create(), "OpenCode session create", ) as { id: string; projectID: string } : envelopeData( @@ -644,7 +674,7 @@ export function createNavigatorTools( }, } : {}), - location: { directory }, + location, }), "OpenCode session create", ); @@ -661,12 +691,26 @@ export function createNavigatorTools( : ""), ); } - if (session.projectID !== navigator.projectID) { + const createdLocation = navigator.protocol === "v2" + ? (session as SessionV2Info).location + : undefined; + const validationError = createdLocation + ? session.projectID !== navigator.projectID + ? `belongs to project ${session.projectID}; expected ${navigator.projectID}` + : !sameDirectory(createdLocation.directory, location.directory) + ? `uses directory ${createdLocation.directory}; expected ${location.directory}` + : location.workspaceID && createdLocation.workspaceID !== location.workspaceID + ? `uses workspace ID ${createdLocation.workspaceID ?? "null"}; expected ${location.workspaceID}` + : undefined + : session.projectID !== navigator.projectID + ? `belongs to project ${session.projectID}; expected ${navigator.projectID}` + : undefined; + if (validationError) { const rollbackFailures = createdWorktree ? await rollbackCreatedWorkspaces(client, navigator.checkout, [createdWorktree]) : []; throw new Error( - `Created session ${session.id} belongs to project ${session.projectID}; expected ${navigator.projectID}. It was not prompted` + + `Created session ${session.id} ${validationError}. It was not prompted` + (createdWorktree ? rollbackFailures.length ? `. Workspace rollback failed: ${rollbackFailures.join("; ")}` @@ -677,7 +721,7 @@ export function createNavigatorTools( try { if (navigator.protocol === "v1") { - const response = await navigator.legacyClient(directory).session.promptAsync({ + const response = await navigator.legacyClient(location.directory).session.promptAsync({ path: { id: session.id }, body: { parts: [{ type: "text", text: args.prompt }], @@ -710,9 +754,10 @@ export function createNavigatorTools( return json({ sessionID: session.id, - directory, + directory: location.directory, + ...(location.workspaceID ? { workspaceID: location.workspaceID } : {}), ...(createdWorktree - ? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.id ? { id: createdWorktree.id } : {}), ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } + ? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.id ? { id: createdWorktree.id } : {}), ...(createdWorktree.workspaceID ? { workspaceID: createdWorktree.workspaceID } : {}), ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } : {}), }); }, @@ -792,7 +837,7 @@ export function createNavigatorTools( return json({ sessionID: args.sessionID, admitted: true }); } if (args.agent) { - await assertKnownV2Agent(client, session.location.directory, args.agent); + await assertKnownV2Agent(client, session.location, args.agent); const response = await client.v2.session.switchAgent({ sessionID: args.sessionID, agent: args.agent, @@ -888,7 +933,10 @@ export function createNavigatorTools( for (const sessionID of active) { const session = await getSession(client, sessionID); if (session.projectID !== navigator.projectID) continue; - if (sameDirectory(session.location.directory, managed.directory)) { + const containsSession = managed.workspaceID && session.location.workspaceID + ? managed.workspaceID === session.location.workspaceID + : !session.location.workspaceID && sameDirectory(session.location.directory, managed.directory); + if (containsSession) { throw new Error(`Navigator refuses to remove a worktree containing active session ${sessionID}`); } } @@ -901,9 +949,14 @@ export function createNavigatorTools( const response = await api.workspace.remove({ id: target.id, directory: navigator.checkout }); if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace removal for ${target.directory}`); - const remaining = await listRiftWorkspaces(client, navigator.checkout, navigator.projectID).catch(() => []); + const remaining = await listRiftWorkspaces(client, navigator.checkout, navigator.projectID); const stillPresent = remaining.find((item) => sameDirectory(item.directory, target.directory)); - if (!stillPresent) return json({ removed: true }); + if (!stillPresent) return json({ + removed: true, + type: managed.type, + directory: managed.directory, + ...(managed.workspaceID ? { workspaceID: managed.workspaceID } : {}), + }); target = stillPresent; } throw new Error(`Navigator could not confirm Rift workspace removal for ${managed.directory}`); @@ -915,7 +968,7 @@ export function createNavigatorTools( }), `OpenCode worktree removal for ${managed.directory}`, ); - return json({ removed: Boolean(removed) }); + return json({ removed: Boolean(removed), type: managed.type, directory: managed.directory }); }, }), }; diff --git a/packages/opencode/rift-workspace.ts b/packages/opencode/rift-workspace.ts index 1e69beb..69ec040 100644 --- a/packages/opencode/rift-workspace.ts +++ b/packages/opencode/rift-workspace.ts @@ -66,17 +66,32 @@ function managedRoot(sourceDirectory: string) { return path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory)); } +function requireManagedDirectory(sourceDirectory: string, directory: unknown, operation: string) { + if (typeof directory !== "string" || !directory) { + throw new Error(`Rift workspace ${operation} is missing a directory`); + } + const resolved = path.resolve(directory); + const root = path.resolve(managedRoot(sourceDirectory)); + if (path.dirname(resolved) !== root) { + throw new Error(`Rift workspace ${operation} directory ${resolved} is outside managed root ${root}`); + } + return resolved; +} + +function requireProject(config: WorkspaceInfo, projectID: string) { + if (config.projectID !== projectID) { + throw new Error(`Rift workspace belongs to project ${config.projectID}; expected ${projectID}`); + } +} + function workspaceDirectory(sourceDirectory: string, name: string) { return path.join(managedRoot(sourceDirectory), name); } function availableWorkspaceName(rift: RiftModule, sourceDirectory: string, requested: string) { - let existing: Set; - try { - existing = new Set(rift.list({ of: sourceDirectory }).map((directory) => path.basename(directory))); - } catch { - return requested; - } + const existing = new Set(rift.list({ of: sourceDirectory }).map((directory) => + path.basename(requireManagedDirectory(sourceDirectory, directory, "listed")), + )); if (!existing.has(requested)) return requested; let suffix = 2; while (existing.has(`${requested}-${suffix}`)) suffix += 1; @@ -92,11 +107,6 @@ export function resolveRiftSourceDirectory(directory: string) { return namespace ? path.join(resolved.slice(0, markerIndex), namespace) : resolved; } -function requireDirectory(config: WorkspaceInfo) { - if (!config.directory) throw new Error("Rift workspace is missing a directory"); - return config.directory; -} - export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapterOptions): WorkspaceAdapter { const sourceDirectory = path.resolve(options.sourceDirectory); @@ -104,19 +114,30 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte name: "Rift", description: "Create a copy-on-write Rift workspace", configure(config) { + requireProject(config, options.projectID); const name = availableWorkspaceName(rift, sourceDirectory, workspaceName(config)); + const directory = requireManagedDirectory( + sourceDirectory, + workspaceDirectory(sourceDirectory, name), + "configured", + ); return { ...config, type: "rift", name, branch: null, - directory: workspaceDirectory(sourceDirectory, name), + directory, extra: { ...(typeof config.extra === "object" && config.extra ? config.extra : {}), sourceDirectory }, }; }, async create(config, _env, from) { + requireProject(config, options.projectID); const source = path.resolve(from?.directory ?? sourceDirectory); - const expected = path.resolve(requireDirectory(config)); + if (from) { + requireProject(from, options.projectID); + requireManagedDirectory(sourceDirectory, source, "creation source"); + } + const expected = requireManagedDirectory(sourceDirectory, config.directory, "creation target"); rift.init({ at: source }); const created = path.resolve(rift.create({ from: source, @@ -136,7 +157,10 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte } }, list() { - return rift.list({ of: sourceDirectory }).map((directory) => ({ + const directories = new Set(rift.list({ of: sourceDirectory }).map((directory) => + requireManagedDirectory(sourceDirectory, directory, "listed"), + )); + return [...directories].map((directory) => ({ id: `rift-${path.basename(directory)}`, type: "rift", name: path.basename(directory), @@ -147,10 +171,15 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte })); }, async remove(config) { - rift.remove({ at: requireDirectory(config) }); + requireProject(config, options.projectID); + rift.remove({ at: requireManagedDirectory(sourceDirectory, config.directory, "removal target") }); }, target(config) { - return { type: "local", directory: requireDirectory(config) }; + requireProject(config, options.projectID); + return { + type: "local", + directory: requireManagedDirectory(sourceDirectory, config.directory, "target"), + }; }, }; } diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index dedbbdd..933d7e2 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -15,12 +15,12 @@ function response(data: T) { return { data, error: undefined }; } -function session(id: string, directory = "/repo", projectID = "project-1") { +function session(id: string, directory = "/repo", projectID = "project-1", workspaceID?: string) { return { id, projectID, title: id, - location: { directory }, + location: { directory, ...(workspaceID ? { workspaceID } : {}) }, time: { created: 1, updated: 2 }, }; } @@ -44,7 +44,9 @@ function createClient(overrides: Record = {}) { }), list: async () => response({ data: sessions, cursor: {} }), messages: async () => response({ data: [], cursor: {} }), - create: async ({ location }: any) => response({ data: session("created", location.directory) }), + create: async ({ location }: any) => response({ + data: session("created", location.directory, "project-1", location.workspaceID), + }), prompt: async ({ sessionID }: any) => response({ data: { sessionID } }), switchAgent: async () => response(undefined), switchModel: async () => response(undefined), @@ -134,6 +136,7 @@ describe("Kompass Navigator", () => { const output = JSON.parse(await (tools(client).worktree_list as any).execute({}, context())); assert.deepEqual(output.worktrees, [{ id: "wrk_rift", + workspaceID: "wrk_rift", projectID: "project-1", type: "rift", directory: "/repo-shared", @@ -141,6 +144,45 @@ describe("Kompass Navigator", () => { }]); }); + test("surfaces Rift discovery failures without native fallback", async () => { + const client = createClient(); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => ({ data: undefined, error: new Error("sync unavailable") }), + list: async () => response([]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + + await assert.rejects( + (tools(client).worktree_list as any).execute({}, context()), + /Rift workspace synchronization failed: sync unavailable/, + ); + }); + + test("rejects conflicting Rift workspace IDs for one directory", async () => { + const client = createClient({ worktree: { list: async () => response([]) } }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([ + { id: "wrk_one", type: "rift", directory: "/repo-rift", projectID: "project-1" }, + { id: "wrk_two", type: "rift", directory: "/repo-rift/.", projectID: "project-1" }, + ]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + + await assert.rejects( + (tools(client).worktree_list as any).execute({}, context()), + /multiple Rift workspace IDs.*wrk_one, wrk_two.*stale or duplicate/, + ); + }); + test("rejects foreign sessions", async () => { const client = createClient({ session: { get: async () => response({ data: session("foreign", "/repo", "project-2") }) }, @@ -170,6 +212,21 @@ describe("Kompass Navigator", () => { assert.deepEqual(output.sessions.map((item: any) => item.sessionID), ["owned"]); }); + test("includes workspace identity in session summaries and omits it for legacy sessions", async () => { + const client = createClient({ + session: { + list: async () => response({ + data: [session("rift", "/repo-rift", "project-1", "wrk_rift"), session("legacy")], + cursor: {}, + }), + }, + }); + const output = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + + assert.equal(output.sessions[0].workspaceID, "wrk_rift"); + assert.equal("workspaceID" in output.sessions[1], false); + }); + test("rejects arbitrary existing worktree paths", async () => { await assert.rejects( (tools().session_create as any).execute({ @@ -208,7 +265,7 @@ describe("Kompass Navigator", () => { }, context())); assert.equal(output.directory, "/repo-worktree"); - assert.equal(creates[0].location.directory, "/repo-worktree"); + assert.deepEqual(creates[0].location, { directory: "/repo-worktree" }); assert.equal(prompts[0].prompt.text, "implement it"); }); @@ -236,6 +293,7 @@ describe("Kompass Navigator", () => { }, context()); assert.equal(creates[0].agent, "reviewer"); + assert.deepEqual(creates[0].location, { directory: "/repo" }); assert.deepEqual(creates[0].model, { providerID: "openai", id: "gpt-5.6-sol", @@ -243,12 +301,16 @@ describe("Kompass Navigator", () => { }); }); - test("rejects an unknown V2 agent before creating a worktree or session", async () => { + test("validates an unknown V2 agent in a newly created worktree and rolls it back", async () => { let worktreeCreates = 0; + let worktreeRemoves = 0; let sessionCreates = 0; const client = createClient({ agent: { list: async () => response({ location: { directory: "/repo" }, data: [{ id: "reviewer" }] }) }, - worktree: { create: async () => { worktreeCreates += 1; return response({ directory: "/repo-new", name: "new" }); } }, + worktree: { + create: async () => { worktreeCreates += 1; return response({ directory: "/repo-new", name: "new" }); }, + remove: async () => { worktreeRemoves += 1; return response(true); }, + }, session: { create: async () => { sessionCreates += 1; return response({ data: session("created") }); } }, }); @@ -260,7 +322,8 @@ describe("Kompass Navigator", () => { }, context()), /Unknown OpenCode agent "review".*reviewer/, ); - assert.equal(worktreeCreates, 0); + assert.equal(worktreeCreates, 1); + assert.equal(worktreeRemoves, 1); assert.equal(sessionCreates, 0); }); @@ -354,7 +417,7 @@ describe("Kompass Navigator", () => { session: { create: async (args: any) => { sessionCreates.push(args); - return response({ data: session("created", args.location.directory) }); + return response({ data: session("created", args.location.directory, "project-1", args.location.workspaceID) }); }, }, }); @@ -383,8 +446,115 @@ describe("Kompass Navigator", () => { }, context())); assert.deepEqual(workspaceCreates, [{ directory: "/repo", type: "rift", extra: { name: "Parser Fix" } }]); - assert.equal(sessionCreates[0].location.directory, "/repo-rift"); - assert.deepEqual(output.worktree, { created: true, type: "rift", name: "parser-fix", id: "wrk_rift" }); + assert.deepEqual(sessionCreates[0].location, { directory: "/repo-rift", workspaceID: "wrk_rift" }); + assert.equal(output.workspaceID, "wrk_rift"); + assert.deepEqual(output.worktree, { + created: true, + type: "rift", + name: "parser-fix", + id: "wrk_rift", + workspaceID: "wrk_rift", + }); + }); + + test("preserves an existing Rift workspace ID when creating a session", async () => { + const creates: any[] = []; + const client = createClient({ + worktree: { list: async () => response([]) }, + session: { + create: async (args: any) => { + creates.push(args); + return response({ data: session("created", args.location.directory, "project-1", args.location.workspaceID) }); + }, + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_existing", + type: "rift", + name: "existing", + directory: "/repo-rift", + projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + + await (tools(client).session_create as any).execute({ + prompt: "continue", + environment: { type: "existing_worktree", directory: "/repo-rift" }, + }, context()); + + assert.deepEqual(creates[0].location, { directory: "/repo-rift", workspaceID: "wrk_existing" }); + }); + + test("does not prompt a session whose returned workspace ID mismatches", async () => { + let prompts = 0; + let removes = 0; + const client = createClient({ + session: { + create: async ({ location }: any) => response({ + data: session("created", location.directory, "project-1", "wrk_wrong"), + }), + prompt: async () => { prompts += 1; return response({ data: true }); }, + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([]), + create: async () => response({ + id: "wrk_expected", + type: "rift", + name: "new", + directory: "/repo-rift", + projectID: "project-1", + }), + remove: async () => { removes += 1; return response(undefined); }, + }, + }; + + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "work", + environment: { type: "new_worktree" }, + }, context()), + /workspace ID wrk_wrong; expected wrk_expected.*not prompted.*rolled back/, + ); + assert.equal(prompts, 0); + assert.equal(removes, 1); + }); + + test("does not remove an existing Rift after returned location validation fails", async () => { + let removes = 0; + const client = createClient({ + worktree: { list: async () => response([]) }, + session: { + create: async () => response({ data: session("created", "/wrong", "project-1", "wrk_existing") }), + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_existing", type: "rift", directory: "/repo-rift", projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async () => { removes += 1; return response(undefined); }, + }, + }; + + await assert.rejects((tools(client).session_create as any).execute({ + prompt: "work", + environment: { type: "existing_worktree", directory: "/repo-rift" }, + }, context()), /uses directory \/wrong.*not prompted/); + assert.equal(removes, 0); }); test("reports resources created before a prompt failure", async () => { @@ -626,7 +796,7 @@ describe("Kompass Navigator", () => { { directory: "/repo-worktree" }, context(), )); - assert.deepEqual(output, { removed: true }); + assert.deepEqual(output, { removed: true, type: "worktree", directory: "/repo-worktree" }); }); test("removes managed Rift workspaces through the workspace API", async () => { @@ -658,13 +828,44 @@ describe("Kompass Navigator", () => { context(), )); - assert.deepEqual(output, { removed: true }); + assert.deepEqual(output, { + removed: true, + type: "rift", + directory: "/repo-rift", + workspaceID: "wrk_rift", + }); assert.deepEqual(removes, [ { id: "wrk_rift", directory: "/repo" }, { id: "wrk_rift", directory: "/repo" }, ]); }); + test("matches active Rift sessions by workspace ID before directory", async () => { + const client = createClient({ + worktree: { list: async () => response([]) }, + session: { + active: async () => response({ data: { active: { type: "running" } } }), + get: async () => response({ data: session("active", "/stale-path", "project-1", "wrk_rift") }), + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_rift", type: "rift", directory: "/repo-rift", projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + + await assert.rejects( + (tools(client).worktree_remove as any).execute({ directory: "/repo-rift" }, context()), + /active session active/, + ); + }); + test("guards active Rift workspaces when polling active sessions through V1", async () => { const statusDirectories: string[] = []; const client = createClient({ diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 2c539d9..034224c 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -224,6 +224,58 @@ describe("createOpenCodeTools", () => { assert.equal(configured.directory, `${existing}-2`); }); + test("Rift workspace adapter normalizes and deduplicates listed directories", async () => { + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const directory = path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix"); + const adapter = createRiftWorkspaceAdapter({ + init: () => null, + create: () => directory, + remove: () => undefined, + list: () => [directory, path.join(directory, "..", "parser-fix")], + }, { sourceDirectory, projectID: "project-1" }); + + const listed = await adapter.list?.(); + + assert.equal(listed?.length, 1); + assert.equal(listed?.[0]?.directory, path.resolve(directory)); + }); + + test("Rift workspace adapter rejects target and removal paths outside its managed root", async () => { + let removeCalls = 0; + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const adapter = createRiftWorkspaceAdapter({ + init: () => null, + create: () => "", + remove: () => { removeCalls += 1; }, + list: () => [], + }, { sourceDirectory, projectID: "project-1" }); + const foreign = { + id: "wrk_foreign", + type: "rift", + name: "foreign", + branch: null, + directory: path.join(os.tmpdir(), "foreign-rift"), + extra: null, + projectID: "project-1", + }; + + assert.throws(() => adapter.target(foreign), /outside managed root/); + await assert.rejects(adapter.remove(foreign), /outside managed root/); + assert.equal(removeCalls, 0); + }); + + test("Rift workspace adapter rejects foreign listed paths", async () => { + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const adapter = createRiftWorkspaceAdapter({ + init: () => null, + create: () => "", + remove: () => undefined, + list: () => [path.join(os.tmpdir(), "foreign-rift")], + }, { sourceDirectory, projectID: "project-1" }); + + await assert.rejects(Promise.resolve().then(() => adapter.list?.()), /outside managed root/); + }); + test("Rift source resolution unwraps a removed managed workspace", () => { assert.equal( resolveRiftSourceDirectory("/projects/.rifts/repo/removed-workspace"),