Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions packages/opencode/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ type NavigatorContext = {
legacyClient: (directory: string) => NavigatorLegacyClient;
};

type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; type: "worktree" | "rift" };
type NativeWorktree = { directory: string; name: string; branch?: string; id?: 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.";

Expand Down Expand Up @@ -153,6 +153,7 @@ function normalizeRiftWorkspace(workspace: NativeWorkspace): NativeWorktree | un
type: "rift",
directory: workspace.directory,
name: workspace.name || path.basename(workspace.directory),
...(workspace.projectID ? { projectID: workspace.projectID } : {}),
...(workspace.branch ? { branch: workspace.branch } : {}),
};
}
Expand All @@ -179,7 +180,36 @@ async function listManagedWorktrees(client: NavigatorClient, checkout: string, p
const worktrees = values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout));
if (!projectID) return worktrees;
const rifts = await listRiftWorkspaces(client, checkout, projectID).catch(() => []);
return [...worktrees, ...rifts];
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()];
}

async function removeCreatedWorkspace(client: NavigatorClient, checkout: string, workspace: NativeWorktree) {
if (workspace.type === "rift") {
const api = workspaceApi(client);
if (!api?.workspace?.remove || !workspace.id) throw new Error("OpenCode Rift workspace removal is unavailable");
const response = await api.workspace.remove({ id: workspace.id, directory: checkout });
if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace rollback for ${workspace.directory}`);
return;
}
responseData(await client.worktree.remove({
directory: checkout,
worktreeRemoveInput: { directory: workspace.directory },
}), `OpenCode worktree rollback for ${workspace.directory}`);
}

async function rollbackCreatedWorkspaces(client: NavigatorClient, checkout: string, workspaces: NativeWorktree[]) {
const unique = new Map(workspaces.map((item) => [normalizeDirectory(item.directory), item]));
const failures: string[] = [];
for (const workspace of unique.values()) {
try {
await removeCreatedWorkspace(client, checkout, workspace);
} catch (error) {
failures.push(`${workspace.directory}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return failures;
}

async function activeSessionIDs(client: NavigatorClient, navigator: NavigatorContext) {
Expand Down Expand Up @@ -559,6 +589,11 @@ export function createNavigatorTools(
const normalized = normalizeRiftWorkspace(workspace);
if (!normalized) throw new Error("OpenCode Rift workspace create returned no directory");
createdWorktree = normalized;
if (normalized.projectID && normalized.projectID !== navigator.projectID) {
throw new Error(
`OpenCode registered workspace ${normalized.id} to project ${normalized.projectID}; expected ${navigator.projectID}`,
);
}
} else {
const worktree = responseData(
await client.worktree.create({
Expand All @@ -577,9 +612,15 @@ export function createNavigatorTools(
const worktreesAfter = await listManagedWorktrees(client, navigator.checkout, navigator.projectID).catch(() => []);
const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory)));
const partial = worktreesAfter.filter((item) => !before.has(normalizeDirectory(item.directory)));
const created = createdWorktree ? [createdWorktree, ...partial] : partial;
const rollbackFailures = await rollbackCreatedWorkspaces(client, navigator.checkout, created);
throw new Error(
`Navigator could not create the requested workspace: ${error instanceof Error ? error.message : String(error)}` +
(partial.length ? `. Workspaces created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""),
(created.length
? rollbackFailures.length
? `. Rollback failed for ${rollbackFailures.join("; ")}`
: ". The partially created workspace was removed"
: ""),
);
}
}
Expand Down Expand Up @@ -608,13 +649,30 @@ export function createNavigatorTools(
"OpenCode session create",
);
} catch (error) {
const rollbackFailures = createdWorktree
? await rollbackCreatedWorkspaces(client, navigator.checkout, [createdWorktree])
: [];
throw new Error(
`Navigator session creation failed: ${error instanceof Error ? error.message : String(error)}` +
(createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory}) and was not removed` : ""),
(createdWorktree
? rollbackFailures.length
? `. Workspace created at ${createdWorktree.directory}, but rollback failed: ${rollbackFailures.join("; ")}`
: `. Workspace ${createdWorktree.directory} was rolled back`
: ""),
);
}
if (session.projectID !== navigator.projectID) {
throw new Error(`Created session ${session.id} belongs to an unexpected project; it was not prompted`);
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` +
(createdWorktree
? rollbackFailures.length
? `. Workspace rollback failed: ${rollbackFailures.join("; ")}`
: `. Workspace ${createdWorktree.directory} was rolled back`
: ""),
);
}

try {
Expand Down Expand Up @@ -654,7 +712,7 @@ export function createNavigatorTools(
sessionID: session.id,
directory,
...(createdWorktree
? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } }
? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.id ? { id: createdWorktree.id } : {}), ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } }
: {}),
});
},
Expand Down
15 changes: 12 additions & 3 deletions packages/opencode/rift-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type RiftAdapterOptions = {

type ExperimentalWorkspaceInput = {
experimental_workspace?: { register(type: string, adapter: WorkspaceAdapter): void };
project?: { id?: string };
project?: { id?: string; path?: string };
worktree?: string;
directory?: string;
};
Expand Down Expand Up @@ -103,7 +103,14 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte
copyAll: true,
}));
if (created !== expected) {
throw new Error(`Rift created ${created}, but OpenCode registered ${expected}`);
try {
rift.remove({ at: created });
} catch (error) {
throw new Error(
`Rift created ${created}, but OpenCode registered ${expected}. Rollback failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
throw new Error(`Rift created ${created}, but OpenCode registered ${expected}. The created Rift was removed`);
}
},
list() {
Expand All @@ -129,7 +136,9 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte
export async function registerRiftWorkspaceAdapter(input: ExperimentalWorkspaceInput, logger: Logger) {
const registrar = input.experimental_workspace;
const projectID = input.project?.id;
const sourceDirectory = input.worktree ?? input.directory;
// A plugin loaded inside a managed workspace still belongs to the stable project checkout.
// Using input.worktree here would recursively nest new Rifts below the current Rift.
const sourceDirectory = input.project?.path ?? input.directory ?? input.worktree;
if (!registrar || !projectID || !sourceDirectory) return;

let rift: RiftModule;
Expand Down
75 changes: 71 additions & 4 deletions packages/opencode/test/navigator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,34 @@ describe("Kompass Navigator", () => {
assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree", type: "worktree" }]);
});

test("deduplicates native and Rift workspace entries by directory", async () => {
const client = createClient({ worktree: { list: async () => response(["/repo-shared"]) } });
client.experimental = {
workspace: {
adapter: { list: async () => response([{ type: "rift" }]) },
syncList: async () => response(undefined),
list: async () => response([{
id: "wrk_rift",
type: "rift",
name: "shared",
directory: "/repo-shared",
projectID: "project-1",
}]),
create: async () => response(undefined),
remove: async () => response(undefined),
},
};

const output = JSON.parse(await (tools(client).worktree_list as any).execute({}, context()));
assert.deepEqual(output.worktrees, [{
id: "wrk_rift",
projectID: "project-1",
type: "rift",
directory: "/repo-shared",
name: "shared",
}]);
});

test("rejects foreign sessions", async () => {
const client = createClient({
session: { get: async () => response({ data: session("foreign", "/repo", "project-2") }) },
Expand Down Expand Up @@ -356,7 +384,7 @@ describe("Kompass Navigator", () => {

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" });
assert.deepEqual(output.worktree, { created: true, type: "rift", name: "parser-fix", id: "wrk_rift" });
});

test("reports resources created before a prompt failure", async () => {
Expand All @@ -371,14 +399,19 @@ describe("Kompass Navigator", () => {
});

test("reports a worktree created before a session failure", async () => {
const client = createClient({ session: { create: async () => { throw new Error("create failed"); } } });
const removes: any[] = [];
const client = createClient({
worktree: { remove: async (args: any) => { removes.push(args); return response(true); } },
session: { create: async () => { throw new Error("create failed"); } },
});
await assert.rejects(
(tools(client).session_create as any).execute({
prompt: "implement it",
environment: { type: "new_worktree" },
}, context()),
/Worktree created: new \(\/repo-new\).*was not removed/,
/Workspace \/repo-new was rolled back/,
);
assert.equal(removes[0].worktreeRemoveInput.directory, "/repo-new");
});

test("discovers a worktree left behind by a failed native create", async () => {
Expand All @@ -397,10 +430,44 @@ describe("Kompass Navigator", () => {
prompt: "implement it",
environment: { type: "new_worktree", startCommand: "false" },
}, context()),
/Workspaces created and not removed: \/repo-partial/,
/partially created workspace was removed/,
);
});

test("rejects a foreign Rift workspace before creating a session and rolls it back", async () => {
const removes: any[] = [];
let sessionCreates = 0;
const client = createClient({
worktree: { list: async () => response([]) },
session: { create: async () => { sessionCreates += 1; return response({ data: session("created") }); } },
});
client.experimental = {
workspace: {
adapter: { list: async () => response([{ type: "rift" }]) },
syncList: async () => response(undefined),
list: async () => response([]),
create: async () => response({
id: "wrk_foreign",
type: "rift",
name: "foreign",
directory: "/repo-foreign",
projectID: "project-2",
}),
remove: async (args: any) => { removes.push(args); return response(undefined); },
},
};

await assert.rejects(
(tools(client).session_create as any).execute({
prompt: "implement it",
environment: { type: "new_worktree" },
}, context()),
/registered workspace wrk_foreign to project project-2; expected project-1.*partially created workspace was removed/,
);
assert.equal(sessionCreates, 0);
assert.deepEqual(removes, [{ id: "wrk_foreign", directory: "/repo" }]);
});

test("rejects self send, wait, and interrupt", async () => {
const navigator = tools();
await assert.rejects(
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/test/tool-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ describe("createOpenCodeTools", () => {
});
});

test("Rift workspace adapter removes a snapshot created at an unexpected path", async () => {
const removed: string[] = [];
const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source");
const adapter = createRiftWorkspaceAdapter({
init: () => null,
create: () => path.join(os.tmpdir(), "unexpected-rift"),
remove: ({ at } = {}) => { if (at) removed.push(at); },
list: () => [],
}, { sourceDirectory, projectID: "project-1" });
const configured = await adapter.configure({
id: "wrk_1",
type: "rift",
name: "Parser Fix",
branch: null,
directory: null,
extra: null,
projectID: "project-1",
});

await assert.rejects(adapter.create(configured, {}), /created Rift was removed/);
assert.deepEqual(removed, [path.join(os.tmpdir(), "unexpected-rift")]);
});

test("registers Navigator by default", async () => {
await withTempHome(async () => {
const navigatorClient = {
Expand Down
Loading