From 866d49e35348bb5e45ce98abb90998b381ef247f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 13:38:24 +0530 Subject: [PATCH 01/24] feat(workspace): reuse opens within ChatGPT sessions --- docs/chatgpt-coding-workflow.md | 9 +++ package.json | 2 +- src/db/migrations.ts | 24 +++++++ src/db/schema.ts | 19 ++++++ src/oauth-store.test.ts | 1 + src/request-meta.test.ts | 26 ++++++++ src/request-meta.ts | 20 ++++++ src/server.ts | 78 ++++++++++++++--------- src/ui/tool-display.test.ts | 11 ++++ src/ui/tool-display.ts | 3 +- src/workspace-store.ts | 108 +++++++++++++++++++++++++++++++- src/workspaces.test.ts | 80 +++++++++++++++++++++-- src/workspaces.ts | 101 ++++++++++++++++++++++++++++- 13 files changed, 443 insertions(+), 39 deletions(-) create mode 100644 src/request-meta.test.ts create mode 100644 src/request-meta.ts diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..c068b325 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,6 +17,15 @@ ChatGPT should call `open_workspace` once for a project folder: The result includes a `workspaceId`. All later file, search, edit, show-changes, and shell calls should reuse that same `workspaceId`. +ChatGPT sends an anonymized conversation identifier in +`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: +if `open_workspace` is called again for the same path, mode, and base ref in the +same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits +the project instructions, skills, subagent metadata, and diagnostics already +returned by the first call. The conversation binding is persisted so reconnecting +the MCP transport or restarting DevSpace does not create another managed worktree +for the same ChatGPT conversation and target. + Do not reopen the same folder unless: - the `workspaceId` is rejected as unknown diff --git a/package.json b/package.json index 4489360e..cb8f4618 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 2bda20c2..24fb891c 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,11 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "workspace-conversation-bindings", + up: migrateWorkspaceConversationBindings, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,6 +179,25 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text"); } +function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bindings ( + conversation_scope_hash text not null, + target_key text not null, + workspace_session_id text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, target_key), + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_conversation_bindings_workspace_idx + on workspace_conversation_bindings(workspace_session_id); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 01d13fac..06a8b844 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,23 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceConversationBindings = sqliteTable( + "workspace_conversation_bindings", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + targetKey: text("target_key").notNull(), + workspaceSessionId: text("workspace_session_id") + .notNull() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }), + index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -101,5 +118,7 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; +export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e1c00338..e47f8121 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -44,6 +44,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, + { version: 4, name: "workspace-conversation-bindings" }, ]); } finally { database.close(); diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts new file mode 100644 index 00000000..de63e61f --- /dev/null +++ b/src/request-meta.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { openAiConversationScopeHash } from "./request-meta.js"; + +assert.equal(openAiConversationScopeHash(undefined), undefined); +assert.equal(openAiConversationScopeHash({}), undefined); +assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined); + +const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" }); +assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/); +assert.equal( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-1" }), +); +assert.notEqual( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-2" }), +); + +assert.equal( + sessionOnly, + openAiConversationScopeHash({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), +); diff --git a/src/request-meta.ts b/src/request-meta.ts new file mode 100644 index 00000000..4b17f6c2 --- /dev/null +++ b/src/request-meta.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +function metadataString( + meta: Record | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function openAiConversationScopeHash( + meta: Record | undefined, +): string | undefined { + const session = metadataString(meta, "openai/session"); + if (!session) return undefined; + + return createHash("sha256") + .update(JSON.stringify(["openai", session])) + .digest("hex"); +} diff --git a/src/server.ts b/src/server.ts index 91f5df61..a2a6a11b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -50,6 +50,7 @@ import { } from "./mcp-sessions.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { openAiConversationScopeHash } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -751,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.", + "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder. In ChatGPT, repeated calls for the same target in one conversation return the existing workspaceId and omit bootstrap details already returned. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session.", inputSchema: { path: z .string() @@ -784,35 +785,44 @@ function createMcpServer( managed: z.boolean(), }) .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), - skills: z.array(workspaceSkillOutputSchema), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), - agents: z.array(workspaceLocalAgentOutputSchema), - skillDiagnostics: z.array(z.unknown()), + agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), + availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), + skills: z.array(workspaceSkillOutputSchema).optional(), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), + agents: z.array(workspaceLocalAgentOutputSchema).optional(), + skillDiagnostics: z.array(z.unknown()).optional(), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, - async ({ path, mode, baseRef }) => { + async ({ path, mode, baseRef }, { _meta }) => { const startedAt = performance.now(); - const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef }); - if (config.widgets === "changes") { + const { + workspace, + agentsFiles, + availableAgentsFiles, + includeBootstrapContext, + } = await workspaces.openWorkspace( + { path, mode, baseRef }, + { conversationScopeHash: openAiConversationScopeHash(_meta) }, + ); + const reused = !includeBootstrapContext; + if (config.widgets === "changes" && includeBootstrapContext) { void reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); } - const visibleSkills = workspace.skills + const visibleSkills = includeBootstrapContext ? workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), - })); - const visibleAgentProviders = config.subagents ? localAgentProviders : []; - const visibleAgents = workspace.agentProfiles.map((profile) => { + })) : []; + const visibleAgentProviders = includeBootstrapContext && config.subagents ? localAgentProviders : []; + const visibleAgents = includeBootstrapContext ? workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); return { @@ -820,24 +830,29 @@ function createMcpServer( providerAvailable: availability?.available, providerUnavailableReason: availability?.reason, }; - }); - const loadedAgentsFiles = agentsFiles.map((file) => ({ + }) : []; + const loadedAgentsFiles = includeBootstrapContext ? agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, - })); - const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ + })) : []; + const availableAgentsFileOutputs = includeBootstrapContext ? availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), - })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + })) : []; + const instruction = reused + ? "Reuse this workspaceId for subsequent tool calls. Workspace instructions, nested instruction paths, skills, subagent metadata, and diagnostics were already returned earlier in this ChatGPT conversation and are intentionally omitted here." + : config.skillsEnabled + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `Opened workspace ${workspace.id}`, + `${reused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, + reused + ? "Bootstrap details omitted because they were already returned in this ChatGPT conversation." + : undefined, loadedAgentsFiles.length > 0 ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` : undefined, @@ -878,6 +893,7 @@ function createMcpServer( path: workspace.root, summary: { mode: workspace.mode, + reused, agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, @@ -893,12 +909,16 @@ function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + ...(includeBootstrapContext + ? { + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, + skillDiagnostics: workspace.skillDiagnostics, + } + : {}), instruction, }, }; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index b9977ac8..dd471795 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -25,6 +25,10 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); +assert.equal( + getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, + "Reused workspace", +); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -95,6 +99,13 @@ assert.deepEqual( }), { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); +assert.deepEqual( + getToolHeaderSummary({ + tool: "open_workspace", + summary: { mode: "worktree", reused: true }, + }), + { kind: "text", text: "worktree · reused" }, +); assert.deepEqual( getToolHeaderSummary({ tool: "exec_command", summary: { lines: 3, wallTimeMs: 1_500 } }), diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 7a847631..a9837580 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -27,7 +27,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "open_workspace": return { icon: toolIcons.folderOpen, - title: "Opened workspace", + title: card.summary?.reused === true ? "Reused workspace" : "Opened workspace", label: card.root ?? card.path, tone: "workspace", }; @@ -123,6 +123,7 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, + summary.reused === true ? "reused" : undefined, countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 39c2ed09..95bd282e 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,7 +1,9 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBindings, workspaceSessions, + type WorkspaceConversationBindingRow, type WorkspaceSessionRow, } from "./db/schema.js"; @@ -20,6 +22,14 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspaceConversationBinding { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + createdAt: string; + lastUsedAt: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +42,17 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined; + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding; + touchConversationBinding(conversationScopeHash: string, targetKey: string): void; + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; close?(): void; } @@ -102,6 +123,79 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined { + const row = this.database.db + .select() + .from(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .get(); + + return row ? rowToWorkspaceConversationBinding(row) : undefined; + } + + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding { + const now = new Date().toISOString(); + this.database.db + .insert(workspaceConversationBindings) + .values({ + conversationScopeHash: input.conversationScopeHash, + targetKey: input.targetKey, + workspaceSessionId: input.workspaceSessionId, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoUpdate({ + target: [ + workspaceConversationBindings.conversationScopeHash, + workspaceConversationBindings.targetKey, + ], + set: { + workspaceSessionId: input.workspaceSessionId, + lastUsedAt: now, + }, + }) + .run(); + + return this.getConversationBinding(input.conversationScopeHash, input.targetKey)!; + } + + touchConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .update(workspaceConversationBindings) + .set({ lastUsedAt: new Date().toISOString() }) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .delete(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + close(): void { this.database.close(); } @@ -126,3 +220,15 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspaceConversationBinding( + row: WorkspaceConversationBindingRow, +): WorkspaceConversationBinding { + return { + conversationScopeHash: row.conversationScopeHash, + targetKey: row.targetKey, + workspaceSessionId: row.workspaceSessionId, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, + }; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..22f316b7 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -59,6 +59,7 @@ try { agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); + assert.deepEqual( availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], @@ -159,11 +160,52 @@ try { const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); - const persistentWorkspace = await persistentRegistry.openWorkspace(root); - const persistentWorktree = await persistentRegistry.openWorkspace({ - path: gitRoot, - mode: "worktree", + const persistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + const reusedPersistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(persistentWorkspace.includeBootstrapContext, true); + assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); + assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual(reusedPersistentWorkspace.agentsFiles, []); + assert.deepEqual(reusedPersistentWorkspace.availableAgentsFiles, []); + + const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout-other", + }); + assert.equal(otherConversationWorkspace.includeBootstrapContext, true); + assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); + + const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); + await mkdir(staleWorkspaceRoot); + const staleWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", }); + await rm(staleWorkspaceRoot, { recursive: true, force: true }); + const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", + }); + assert.equal(replacementWorkspace.includeBootstrapContext, true); + assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); + assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); + + const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const [persistentWorktree, concurrentWorktree] = await Promise.all([ + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }), + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }), + ]); + assert.equal(persistentWorktree.includeBootstrapContext, true); + assert.equal(concurrentWorktree.includeBootstrapContext, false); + assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); + assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual(concurrentWorktree.agentsFiles, []); + assert.deepEqual(concurrentWorktree.availableAgentsFiles, []); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -172,16 +214,46 @@ try { assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); + const reboundWorkspace = await restoredRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(reboundWorkspace.includeBootstrapContext, false); + assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); + const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); assert.equal(restoredWorktree.sourceRoot, gitRoot); assert.equal(restoredWorktree.root, persistentWorktree.workspace.root); assert.equal(restoredWorktree.worktree?.managed, true); + + const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree", + }); + assert.equal(reboundWorktree.includeBootstrapContext, false); + assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); + assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); secondStore.close(); if (platform() !== "win32") { const aliasRoot = join(root, "alias-root"); await symlink(root, aliasRoot, "dir"); + + const aliasStateDir = join(root, ".alias-state"); + const aliasStore = new SqliteWorkspaceStore(aliasStateDir); + const aliasRegistry = new WorkspaceRegistry(config, aliasStore); + const directConversationWorkspace = await aliasRegistry.openWorkspace(root, { + conversationScopeHash: "chat-alias", + }); + const aliasedConversationWorkspace = await aliasRegistry.openWorkspace(aliasRoot, { + conversationScopeHash: "chat-alias", + }); + assert.equal(aliasedConversationWorkspace.includeBootstrapContext, false); + assert.equal( + aliasedConversationWorkspace.workspace.id, + directConversationWorkspace.workspace.id, + ); + aliasStore.close(); + const aliasConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: aliasRoot, DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..0d2ab5c6 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -53,6 +53,7 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + includeBootstrapContext: boolean; } export interface WorkspaceReadPath { @@ -67,6 +68,10 @@ export interface OpenWorkspaceInput { baseRef?: string; } +export interface OpenWorkspaceOptions { + conversationScopeHash?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -75,14 +80,48 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); + private readonly pendingConversationOpens = new Map>(); constructor( private readonly config: ServerConfig, private readonly store?: WorkspaceStore, ) {} - async openWorkspace(input: string | OpenWorkspaceInput): Promise { - const options = typeof input === "string" ? { path: input } : input; + async openWorkspace( + input: string | OpenWorkspaceInput, + openOptions: OpenWorkspaceOptions = {}, + ): Promise { + const workspaceInput = typeof input === "string" ? { path: input } : input; + const conversationScopeHash = openOptions.conversationScopeHash; + if (!conversationScopeHash || !this.store) { + return this.openNewWorkspace(workspaceInput); + } + + const targetKey = await this.conversationTargetKey(workspaceInput); + const operationKey = JSON.stringify([conversationScopeHash, targetKey]); + const pending = this.pendingConversationOpens.get(operationKey); + if (pending) { + const context = await pending; + return this.reusedWorkspaceContext(context.workspace); + } + + const open = this.openConversationWorkspace( + workspaceInput, + conversationScopeHash, + targetKey, + ); + this.pendingConversationOpens.set(operationKey, open); + + try { + return await open; + } finally { + if (this.pendingConversationOpens.get(operationKey) === open) { + this.pendingConversationOpens.delete(operationKey); + } + } + } + + private async openNewWorkspace(options: OpenWorkspaceInput): Promise { const mode = options.mode ?? "checkout"; if (mode === "worktree") { @@ -92,6 +131,57 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } + private async openConversationWorkspace( + input: OpenWorkspaceInput, + conversationScopeHash: string, + targetKey: string, + ): Promise { + const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); + if (binding) { + try { + const workspace = this.getWorkspace(binding.workspaceSessionId); + const workspaceStats = await stat(workspace.root); + if (workspaceStats.isDirectory()) { + this.store?.touchConversationBinding(conversationScopeHash, targetKey); + return this.reusedWorkspaceContext(workspace); + } + } catch { + // The persisted workspace is no longer usable; replace its binding below. + } + + this.workspaces.delete(binding.workspaceSessionId); + this.store?.deleteConversationBinding(conversationScopeHash, targetKey); + } + + const context = await this.openNewWorkspace(input); + this.store?.setConversationBinding({ + conversationScopeHash, + targetKey, + workspaceSessionId: context.workspace.id, + }); + return context; + } + + private async conversationTargetKey(input: OpenWorkspaceInput): Promise { + const mode = input.mode ?? "checkout"; + const path = assertAllowedPath(input.path, this.config.allowedRoots); + const canonicalPath = await realpath(path).catch(() => path); + return JSON.stringify([ + mode, + canonicalPath, + mode === "worktree" ? input.baseRef ?? "HEAD" : null, + ]); + } + + private reusedWorkspaceContext(workspace: Workspace): WorkspaceContext { + return { + workspace, + agentsFiles: [], + availableAgentsFiles: [], + includeBootstrapContext: false, + }; + } + getWorkspace(workspaceId: string): Workspace { const workspace = this.workspaces.get(workspaceId); if (workspace) { @@ -228,7 +318,12 @@ export class WorkspaceRegistry { const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); - return { workspace, agentsFiles, availableAgentsFiles }; + return { + workspace, + agentsFiles, + availableAgentsFiles, + includeBootstrapContext: true, + }; } private loadSkillsForWorkspace(root: string): Pick { From ff9c337fd2dba7ea86431e82bb2933e02723df7a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 15:49:55 +0530 Subject: [PATCH 02/24] fix(workspace): keep reused cards visually stable --- docs/chatgpt-coding-workflow.md | 4 ++- src/server.ts | 53 +++++++++++++++++++++------------ src/ui/card-types.ts | 5 ++++ src/ui/tool-display.test.ts | 6 ++-- src/ui/tool-display.ts | 3 +- src/workspaces.test.ts | 24 ++++++++++++--- src/workspaces.ts | 17 +++++++---- 7 files changed, 78 insertions(+), 34 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c068b325..5b77e782 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -24,7 +24,9 @@ same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits the project instructions, skills, subagent metadata, and diagnostics already returned by the first call. The conversation binding is persisted so reconnecting the MCP transport or restarting DevSpace does not create another managed worktree -for the same ChatGPT conversation and target. +for the same ChatGPT conversation and target. The workspace card still receives +the complete hidden display payload, so first and repeated calls render the same +workspace details without adding those fields to the model transcript again. Do not reopen the same folder unless: diff --git a/src/server.ts b/src/server.ts index a2a6a11b..e7751edc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -814,35 +814,41 @@ function createMcpServer( root: workspace.root, }); } - const visibleSkills = includeBootstrapContext ? workspace.skills + const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), - })) : []; - const visibleAgentProviders = includeBootstrapContext && config.subagents ? localAgentProviders : []; - const visibleAgents = includeBootstrapContext ? workspace.agentProfiles.map((profile) => { + })); + const cardAgentProviders = config.subagents ? localAgentProviders : []; + const cardAgents = workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); - const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); + const availability = cardAgentProviders.find((provider) => provider.name === summary.provider); return { ...summary, providerAvailable: availability?.available, providerUnavailableReason: availability?.reason, }; - }) : []; - const loadedAgentsFiles = includeBootstrapContext ? agentsFiles.map((file) => ({ + }); + const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, - })) : []; - const availableAgentsFileOutputs = includeBootstrapContext ? availableAgentsFiles.map((file) => ({ + })); + const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), - })) : []; + })); + const visibleSkills = includeBootstrapContext ? cardSkills : []; + const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; + const visibleAgents = includeBootstrapContext ? cardAgents : []; + const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; + const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; + const cardInstruction = config.skillsEnabled + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; const instruction = reused ? "Reuse this workspaceId for subsequent tool calls. Workspace instructions, nested instruction paths, skills, subagent metadata, and diagnostics were already returned earlier in this ChatGPT conversation and are intentionally omitted here." - : config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + : cardInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -891,14 +897,23 @@ function createMcpServer( workspaceId: workspace.id, root: workspace.root, path: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: cardAgentsFiles, + availableAgentsFiles: cardAvailableAgentsFiles, + skills: cardSkills, + agentProviders: cardAgentProviders, + agents: cardAgents, + skillDiagnostics: workspace.skillDiagnostics, + instruction: cardInstruction, summary: { mode: workspace.mode, - reused, - agentsFiles: loadedAgentsFiles.length, - availableAgentsFiles: availableAgentsFileOutputs.length, - skills: visibleSkills.length, - agentProviders: visibleAgentProviders.length, - agents: visibleAgents.length, + agentsFiles: cardAgentsFiles.length, + availableAgentsFiles: cardAvailableAgentsFiles.length, + skills: cardSkills.length, + agentProviders: cardAgentProviders.length, + agents: cardAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 1e3c9409..d50a21a7 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,9 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: Record; status?: string; summary?: Record; files?: Array<{ @@ -46,6 +49,8 @@ export interface ToolResultCard { description?: string; path?: string; }>; + agentProviders?: Array>; + agents?: Array>; skillDiagnostics?: unknown[]; instruction?: string; } diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index dd471795..4a401bb4 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -27,7 +27,7 @@ for (const [card, expected] of displayCases) { assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); assert.equal( getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, - "Reused workspace", + "Opened workspace", ); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, @@ -102,9 +102,9 @@ assert.deepEqual( assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", - summary: { mode: "worktree", reused: true }, + summary: { mode: "worktree", reused: true, agentsFiles: 1, skills: 4 }, }), - { kind: "text", text: "worktree · reused" }, + { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); assert.deepEqual( diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index a9837580..7a847631 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -27,7 +27,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "open_workspace": return { icon: toolIcons.folderOpen, - title: card.summary?.reused === true ? "Reused workspace" : "Opened workspace", + title: "Opened workspace", label: card.root ?? card.path, tone: "workspace", }; @@ -123,7 +123,6 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, - summary.reused === true ? "reused" : undefined, countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 22f316b7..a44d7d88 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -169,8 +169,14 @@ try { assert.equal(persistentWorkspace.includeBootstrapContext, true); assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); - assert.deepEqual(reusedPersistentWorkspace.agentsFiles, []); - assert.deepEqual(reusedPersistentWorkspace.availableAgentsFiles, []); + assert.deepEqual( + reusedPersistentWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + reusedPersistentWorkspace.availableAgentsFiles, + persistentWorkspace.availableAgentsFiles, + ); const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { conversationScopeHash: "chat-checkout-other", @@ -204,8 +210,8 @@ try { assert.equal(concurrentWorktree.includeBootstrapContext, false); assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); - assert.deepEqual(concurrentWorktree.agentsFiles, []); - assert.deepEqual(concurrentWorktree.availableAgentsFiles, []); + assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); + assert.deepEqual(concurrentWorktree.availableAgentsFiles, persistentWorktree.availableAgentsFiles); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -219,6 +225,15 @@ try { }); assert.equal(reboundWorkspace.includeBootstrapContext, false); assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual( + reboundWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual(reboundWorkspace.availableAgentsFiles, persistentWorkspace.availableAgentsFiles); + assert.deepEqual( + reboundWorkspace.workspace.agentProfiles.map((profile) => profile.name), + persistentWorkspace.workspace.agentProfiles.map((profile) => profile.name), + ); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); @@ -232,6 +247,7 @@ try { assert.equal(reboundWorktree.includeBootstrapContext, false); assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual(reboundWorktree.agentsFiles, persistentWorktree.agentsFiles); secondStore.close(); if (platform() !== "win32") { diff --git a/src/workspaces.ts b/src/workspaces.ts index 0d2ab5c6..77f98e41 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -102,7 +102,10 @@ export class WorkspaceRegistry { const pending = this.pendingConversationOpens.get(operationKey); if (pending) { const context = await pending; - return this.reusedWorkspaceContext(context.workspace); + return { + ...context, + includeBootstrapContext: false, + }; } const open = this.openConversationWorkspace( @@ -143,7 +146,7 @@ export class WorkspaceRegistry { const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { this.store?.touchConversationBinding(conversationScopeHash, targetKey); - return this.reusedWorkspaceContext(workspace); + return await this.reusedWorkspaceContext(workspace); } } catch { // The persisted workspace is no longer usable; replace its binding below. @@ -173,11 +176,15 @@ export class WorkspaceRegistry { ]); } - private reusedWorkspaceContext(workspace: Workspace): WorkspaceContext { + private async reusedWorkspaceContext(workspace: Workspace): Promise { + workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + return { workspace, - agentsFiles: [], - availableAgentsFiles: [], + agentsFiles, + availableAgentsFiles, includeBootstrapContext: false, }; } From 8ea171f606f69a14dd70bc30f2c6c3e9dc104049 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:22:56 +0530 Subject: [PATCH 03/24] fix(review): restore checkpoints after restart --- src/review-checkpoints.test.ts | 10 +++++ src/review-checkpoints.ts | 78 ++++++++++++++++++++++++++++------ src/server.ts | 4 +- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 3ec4676a..227edd0d 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -40,6 +40,16 @@ try { assert.equal(firstReview.files.some((file) => file.path === "new.txt"), true); assert.match(firstReview.patch, /world/); + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const afterRestart = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.equal(afterRestart.summary.files, 2); + assert.match(afterRestart.patch, /world/); + const stillUnreviewed = await manager.reviewChanges({ workspaceId: "ws_review", root, diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index eaa04dfa..07ef212f 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -48,26 +48,32 @@ const REVIEW_REF_PREFIX = "refs/devspace/review"; export function createReviewCheckpointManager(): ReviewCheckpointManager { const states = new Map(); + const initializations = new Map>(); return { async initializeWorkspace({ workspaceId, root }) { - const refs = reviewRefs(workspaceId); - const state: WorkspaceReviewState = { root, ...refs }; - states.set(workspaceId, state); + const existingState = states.get(workspaceId); + if ( + existingState?.root === root && + (existingState.gitRoot !== undefined || existingState.diagnostic !== undefined) + ) { + return; + } + + const pending = initializations.get(workspaceId); + if (pending) { + await pending; + return; + } + const initialize = initializeWorkspaceState(states, workspaceId, root); + initializations.set(workspaceId, initialize); try { - const eligibility = await getGitEligibility(root); - if (!eligibility.ok || !eligibility.gitRoot) { - state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; - return; + await initialize; + } finally { + if (initializations.get(workspaceId) === initialize) { + initializations.delete(workspaceId); } - - state.gitRoot = eligibility.gitRoot; - const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); - await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); - await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); - } catch (error) { - state.diagnostic = error instanceof Error ? error.message : String(error); } }, @@ -111,6 +117,50 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +async function initializeWorkspaceState( + states: Map, + workspaceId: string, + root: string, +): Promise { + const refs = reviewRefs(workspaceId); + const state: WorkspaceReviewState = { root, ...refs }; + states.set(workspaceId, state); + + try { + const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { + state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; + return; + } + + state.gitRoot = eligibility.gitRoot; + const [hasOpenRef, hasBaselineRef] = await Promise.all([ + hasCommitRef(eligibility.gitRoot, state.openRef), + hasCommitRef(eligibility.gitRoot, state.baselineRef), + ]); + if (hasOpenRef && hasBaselineRef) return; + + const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); + if (!hasOpenRef) { + await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); + } + if (!hasBaselineRef) { + await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); + } + } catch (error) { + state.diagnostic = error instanceof Error ? error.message : String(error); + } +} + +async function hasCommitRef(gitRoot: string, ref: string): Promise { + try { + await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + function reviewRefs(workspaceId: string): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { diff --git a/src/server.ts b/src/server.ts index e7751edc..737724a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -808,8 +808,8 @@ function createMcpServer( { conversationScopeHash: openAiConversationScopeHash(_meta) }, ); const reused = !includeBootstrapContext; - if (config.widgets === "changes" && includeBootstrapContext) { - void reviewCheckpoints.initializeWorkspace({ + if (config.widgets === "changes") { + await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); From e2121b44bf16f60c50c3079c4a5fc0a8e8341572 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:25:06 +0530 Subject: [PATCH 04/24] fix(ui): expose workspace card metadata --- src/ui/card-types.test.ts | 15 +++++++++++ src/ui/card-types.ts | 31 ++++++++++++++++++--- src/ui/workspace-app.tsx | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a0..4e0f2263 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -23,3 +23,18 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); + +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agentProviders: [{ name: "codex", available: true }], + }), + true, +); +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agents: [{ name: "reviewer", provider: "codex" }], + }), + true, +); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index d50a21a7..cb3ab0fa 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -25,7 +25,14 @@ export interface ToolResultCard { root?: string; mode?: "checkout" | "worktree"; sourceRoot?: string; - worktree?: Record; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; summary?: Record; files?: Array<{ @@ -49,8 +56,20 @@ export interface ToolResultCard { description?: string; path?: string; }>; - agentProviders?: Array>; - agents?: Array>; + agentProviders?: Array<{ + name?: string; + available?: boolean; + reason?: string; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + thinking?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -142,10 +161,16 @@ export function isExpandableCard(card: ToolResultCard): boolean { return ( Number(card.summary?.agentsFiles ?? 0) > 0 || Number(card.summary?.skills ?? 0) > 0 || + Number(card.summary?.agentProviders ?? 0) > 0 || + Number(card.summary?.agents ?? 0) > 0 || Number(card.summary?.skillDiagnostics ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || + Boolean(card.worktree) || + Boolean(card.instruction) || Boolean(card.skillDiagnostics?.length) ); } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 78723864..cfeec817 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -449,23 +449,80 @@ function workspacePayloadText(card: ToolResultCard): string { const agentsFiles = card.agentsFiles ?? []; const availableAgentsFiles = card.availableAgentsFiles ?? []; const skills = card.skills ?? []; + const agentProviders = card.agentProviders ?? []; + const agents = card.agents ?? []; + const diagnostics = card.skillDiagnostics ?? []; const lines = [ card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, card.root ? `Root: ${card.root}` : undefined, + card.mode ? `Mode: ${card.mode}` : undefined, + card.sourceRoot ? `Source root: ${card.sourceRoot}` : undefined, + card.worktree ? formatWorktree(card.worktree) : undefined, skills.length > 0 ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` : "Skills: none", + agentProviders.length > 0 + ? `Agent providers: ${agentProviders.map(formatAgentProvider).join(", ")}` + : undefined, + agents.length > 0 + ? `Agents: ${agents.map(formatAgent).join(", ")}` + : undefined, + diagnostics.length > 0 + ? `Skill diagnostics: ${diagnostics.map(formatDiagnostic).join("; ")}` + : undefined, availableAgentsFiles.length > 0 ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` : undefined, agentsFiles.length > 0 ? `\n${formatAgentsFilesForPayload(agentsFiles)}` : "\nAGENTS.md: none loaded", + card.instruction ? `\nInstruction: ${card.instruction}` : undefined, ].filter((line): line is string => typeof line === "string"); return lines.join("\n"); } +function formatWorktree(worktree: NonNullable): string { + const details = [ + worktree.baseRef ? `base ${worktree.baseRef}` : undefined, + worktree.baseSha ? `at ${worktree.baseSha.slice(0, 12)}` : undefined, + worktree.managed === true ? "managed" : undefined, + worktree.detached === true ? "detached" : undefined, + worktree.dirtySource === true ? "dirty source" : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `Worktree: ${worktree.path ?? "unknown"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatAgentProvider( + provider: NonNullable[number], +): string { + const name = provider.name ?? "unknown"; + if (provider.available !== false) return name; + return provider.reason ? `${name} unavailable: ${provider.reason}` : `${name} unavailable`; +} + +function formatAgent(agent: NonNullable[number]): string { + const details = [ + agent.provider, + agent.model, + agent.thinking ? `thinking ${agent.thinking}` : undefined, + agent.providerAvailable === false + ? agent.providerUnavailableReason ?? "provider unavailable" + : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `${agent.name ?? "unnamed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatDiagnostic(diagnostic: unknown): string { + if (typeof diagnostic === "string") return diagnostic; + if (diagnostic instanceof Error) return diagnostic.message; + try { + return JSON.stringify(diagnostic); + } catch { + return String(diagnostic); + } +} + function formatAgentsFilesForPayload( agentsFiles: NonNullable, ): string { From 44c096969d1d1ab8145ba389e3c60c524a3ff1f3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:26:15 +0530 Subject: [PATCH 05/24] test(workspace): make reuse assertions deterministic --- src/ui/tool-display.test.ts | 4 ++-- src/workspace-store.ts | 11 ++++++++--- src/workspaces.test.ts | 12 +++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 4a401bb4..35780509 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -26,7 +26,7 @@ for (const [card, expected] of displayCases) { assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project", summary: { reused: true } }).title, + getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).title, "Opened workspace", ); assert.equal( @@ -102,7 +102,7 @@ assert.deepEqual( assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", - summary: { mode: "worktree", reused: true, agentsFiles: 1, skills: 4 }, + summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, }), { kind: "text", text: "worktree · 1 instruction · 4 skills" }, ); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 95bd282e..1d535f54 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -147,7 +147,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { workspaceSessionId: string; }): WorkspaceConversationBinding { const now = new Date().toISOString(); - this.database.db + const row = this.database.db .insert(workspaceConversationBindings) .values({ conversationScopeHash: input.conversationScopeHash, @@ -166,9 +166,14 @@ export class SqliteWorkspaceStore implements WorkspaceStore { lastUsedAt: now, }, }) - .run(); + .returning() + .get(); + + if (!row) { + throw new Error("Conversation workspace binding upsert returned no row."); + } - return this.getConversationBinding(input.conversationScopeHash, input.targetKey)!; + return rowToWorkspaceConversationBinding(row); } touchConversationBinding(conversationScopeHash: string, targetKey: string): void { diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index a44d7d88..ba05cdcb 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -206,12 +206,18 @@ try { conversationScopeHash: "chat-worktree", }), ]); - assert.equal(persistentWorktree.includeBootstrapContext, true); - assert.equal(concurrentWorktree.includeBootstrapContext, false); assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; + assert.equal( + concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, + 1, + ); assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); - assert.deepEqual(concurrentWorktree.availableAgentsFiles, persistentWorktree.availableAgentsFiles); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles, + persistentWorktree.availableAgentsFiles, + ); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); From f7fc11bbcaa7d2db1b5c665c8aea3f55d9dcaacb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:34:35 +0530 Subject: [PATCH 06/24] test(ui): remove duplicate workspace assertions --- src/ui/tool-display.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 35780509..b9977ac8 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -25,10 +25,6 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).title, - "Opened workspace", -); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -92,13 +88,6 @@ assert.deepEqual( { kind: "diff", additions: 14, removals: 1 }, ); -assert.deepEqual( - getToolHeaderSummary({ - tool: "open_workspace", - summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, - }), - { kind: "text", text: "worktree · 1 instruction · 4 skills" }, -); assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace", From fef52508ec7cd495ecbea186f308ed14590e36d6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:41:39 +0530 Subject: [PATCH 07/24] feat(workspace): track project bootstrap delivery --- src/db/migrations.ts | 56 +++++++++++++++++++++++++++++++++++++++++ src/db/schema.ts | 15 +++++++++++ src/oauth-store.test.ts | 1 + src/workspace-store.ts | 31 +++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 24fb891c..47887eb5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,6 +27,11 @@ const migrations: Migration[] = [ name: "workspace-conversation-bindings", up: migrateWorkspaceConversationBindings, }, + { + version: 5, + name: "workspace-conversation-bootstraps", + up: migrateWorkspaceConversationBootstraps, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -198,6 +203,57 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { `); } +function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bootstraps ( + conversation_scope_hash text not null, + project_key text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, project_key) + ); + `); + + const bindings = sqlite.prepare(` + select conversation_scope_hash, target_key, created_at, last_used_at + from workspace_conversation_bindings + `).all() as Array<{ + conversation_scope_hash: string; + target_key: string; + created_at: string; + last_used_at: string; + }>; + const insertBootstrap = sqlite.prepare(` + insert or ignore into workspace_conversation_bootstraps ( + conversation_scope_hash, + project_key, + created_at, + last_used_at + ) values (?, ?, ?, ?) + `); + + for (const binding of bindings) { + const projectKey = projectKeyFromConversationTarget(binding.target_key); + if (!projectKey) continue; + insertBootstrap.run( + binding.conversation_scope_hash, + projectKey, + binding.created_at, + binding.last_used_at, + ); + } +} + +function projectKeyFromConversationTarget(targetKey: string): string | undefined { + try { + const parsed = JSON.parse(targetKey) as unknown; + if (!Array.isArray(parsed)) return undefined; + return typeof parsed[1] === "string" && parsed[1].length > 0 ? parsed[1] : undefined; + } catch { + return undefined; + } +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 06a8b844..7d557e97 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,19 @@ export const workspaceConversationBindings = sqliteTable( ], ); +export const workspaceConversationBootstraps = sqliteTable( + "workspace_conversation_bootstraps", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + projectKey: text("project_key").notNull(), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.projectKey] }), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -120,5 +133,7 @@ export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; +export type WorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferSelect; +export type NewWorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e47f8121..fe69797f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, { version: 4, name: "workspace-conversation-bindings" }, + { version: 5, name: "workspace-conversation-bootstraps" }, ]); } finally { database.close(); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 1d535f54..5fb66169 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBootstraps, workspaceConversationBindings, workspaceSessions, type WorkspaceConversationBindingRow, @@ -53,6 +54,7 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeHash: string, targetKey: string): void; deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean; close?(): void; } @@ -201,6 +203,35 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean { + const now = new Date().toISOString(); + const [inserted] = this.database.db + .insert(workspaceConversationBootstraps) + .values({ + conversationScopeHash, + projectKey, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoNothing() + .returning() + .all(); + + if (inserted) return true; + + this.database.db + .update(workspaceConversationBootstraps) + .set({ lastUsedAt: now }) + .where( + and( + eq(workspaceConversationBootstraps.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBootstraps.projectKey, projectKey), + ), + ) + .run(); + return false; + } + close(): void { this.database.close(); } From 540d51ce0dc8ad4bc607aae48ba84b4a19507222 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:45:14 +0530 Subject: [PATCH 08/24] fix(workspace): always create requested worktrees --- src/workspaces.test.ts | 63 ++++++++++++++++++++++++++++++-------- src/workspaces.ts | 69 +++++++++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index ba05cdcb..6b3355cd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -167,7 +167,9 @@ try { conversationScopeHash: "chat-checkout", }); assert.equal(persistentWorkspace.includeBootstrapContext, true); + assert.equal(persistentWorkspace.workspaceReused, false); assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); + assert.equal(reusedPersistentWorkspace.workspaceReused, true); assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); assert.deepEqual( reusedPersistentWorkspace.agentsFiles.map((file) => file.content), @@ -182,6 +184,7 @@ try { conversationScopeHash: "chat-checkout-other", }); assert.equal(otherConversationWorkspace.includeBootstrapContext, true); + assert.equal(otherConversationWorkspace.workspaceReused, false); assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); @@ -193,30 +196,61 @@ try { const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { conversationScopeHash: "chat-stale", }); - assert.equal(replacementWorkspace.includeBootstrapContext, true); + assert.equal(replacementWorkspace.includeBootstrapContext, false); + assert.equal(replacementWorkspace.workspaceReused, false); assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + const firstProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const secondProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const reusedProjectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + assert.equal(projectCheckout.includeBootstrapContext, true); + assert.equal(projectCheckout.workspaceReused, false); + assert.equal(firstProjectWorktree.includeBootstrapContext, false); + assert.equal(firstProjectWorktree.workspaceReused, false); + assert.equal(secondProjectWorktree.includeBootstrapContext, false); + assert.equal(secondProjectWorktree.workspaceReused, false); + assert.notEqual(firstProjectWorktree.workspace.id, projectCheckout.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.id, secondProjectWorktree.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.root, secondProjectWorktree.workspace.root); + assert.equal(reusedProjectCheckout.workspace.id, projectCheckout.workspace.id); + assert.equal(reusedProjectCheckout.workspaceReused, true); + assert.equal(reusedProjectCheckout.includeBootstrapContext, false); + const [persistentWorktree, concurrentWorktree] = await Promise.all([ persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }), persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }), ]); - assert.equal(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); - assert.equal(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.notEqual(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.equal(persistentWorktree.workspaceReused, false); + assert.equal(concurrentWorktree.workspaceReused, false); const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; assert.equal( concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, 1, ); - assert.deepEqual(concurrentWorktree.agentsFiles, persistentWorktree.agentsFiles); assert.deepEqual( - concurrentWorktree.availableAgentsFiles, - persistentWorktree.availableAgentsFiles, + concurrentWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles.map((file) => file.path.replace(concurrentWorktree.workspace.root, "")), + persistentWorktree.availableAgentsFiles.map((file) => file.path.replace(persistentWorktree.workspace.root, "")), ); firstStore.close(); @@ -230,6 +264,7 @@ try { conversationScopeHash: "chat-checkout", }); assert.equal(reboundWorkspace.includeBootstrapContext, false); + assert.equal(reboundWorkspace.workspaceReused, true); assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); assert.deepEqual( reboundWorkspace.agentsFiles.map((file) => file.content), @@ -248,12 +283,16 @@ try { assert.equal(restoredWorktree.worktree?.managed, true); const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree", + conversationScopeHash: "chat-worktree-concurrent", }); assert.equal(reboundWorktree.includeBootstrapContext, false); - assert.equal(reboundWorktree.workspace.id, persistentWorktree.workspace.id); - assert.equal(reboundWorktree.workspace.root, persistentWorktree.workspace.root); - assert.deepEqual(reboundWorktree.agentsFiles, persistentWorktree.agentsFiles); + assert.equal(reboundWorktree.workspaceReused, false); + assert.notEqual(reboundWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(reboundWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual( + reboundWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); secondStore.close(); if (platform() !== "win32") { diff --git a/src/workspaces.ts b/src/workspaces.ts index 77f98e41..b68030c5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -53,6 +53,7 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + workspaceReused: boolean; includeBootstrapContext: boolean; } @@ -80,7 +81,7 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); - private readonly pendingConversationOpens = new Map>(); + private readonly pendingCheckoutOpens = new Map>(); constructor( private readonly config: ServerConfig, @@ -97,29 +98,44 @@ export class WorkspaceRegistry { return this.openNewWorkspace(workspaceInput); } - const targetKey = await this.conversationTargetKey(workspaceInput); + const projectKey = await this.conversationProjectKey(workspaceInput); + const mode = workspaceInput.mode ?? "checkout"; + if (mode === "worktree") { + const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); + return { + ...context, + includeBootstrapContext: this.store.claimConversationBootstrap( + conversationScopeHash, + projectKey, + ), + }; + } + + const targetKey = this.conversationCheckoutTargetKey(projectKey); const operationKey = JSON.stringify([conversationScopeHash, targetKey]); - const pending = this.pendingConversationOpens.get(operationKey); + const pending = this.pendingCheckoutOpens.get(operationKey); if (pending) { const context = await pending; return { ...context, + workspaceReused: true, includeBootstrapContext: false, }; } - const open = this.openConversationWorkspace( + const open = this.openConversationCheckout( workspaceInput, conversationScopeHash, targetKey, + projectKey, ); - this.pendingConversationOpens.set(operationKey, open); + this.pendingCheckoutOpens.set(operationKey, open); try { return await open; } finally { - if (this.pendingConversationOpens.get(operationKey) === open) { - this.pendingConversationOpens.delete(operationKey); + if (this.pendingCheckoutOpens.get(operationKey) === open) { + this.pendingCheckoutOpens.delete(operationKey); } } } @@ -134,10 +150,11 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } - private async openConversationWorkspace( + private async openConversationCheckout( input: OpenWorkspaceInput, conversationScopeHash: string, targetKey: string, + projectKey: string, ): Promise { const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); if (binding) { @@ -146,7 +163,10 @@ export class WorkspaceRegistry { const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { this.store?.touchConversationBinding(conversationScopeHash, targetKey); - return await this.reusedWorkspaceContext(workspace); + return await this.reusedWorkspaceContext( + workspace, + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + ); } } catch { // The persisted workspace is no longer usable; replace its binding below. @@ -156,27 +176,32 @@ export class WorkspaceRegistry { this.store?.deleteConversationBinding(conversationScopeHash, targetKey); } - const context = await this.openNewWorkspace(input); + const context = await this.openCheckoutWorkspace(input.path); this.store?.setConversationBinding({ conversationScopeHash, targetKey, workspaceSessionId: context.workspace.id, }); - return context; + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + }; } - private async conversationTargetKey(input: OpenWorkspaceInput): Promise { - const mode = input.mode ?? "checkout"; + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { const path = assertAllowedPath(input.path, this.config.allowedRoots); - const canonicalPath = await realpath(path).catch(() => path); - return JSON.stringify([ - mode, - canonicalPath, - mode === "worktree" ? input.baseRef ?? "HEAD" : null, - ]); + return await realpath(path).catch(() => path); } - private async reusedWorkspaceContext(workspace: Workspace): Promise { + private conversationCheckoutTargetKey(projectKey: string): string { + return JSON.stringify(["checkout", projectKey, null]); + } + + private async reusedWorkspaceContext( + workspace: Workspace, + includeBootstrapContext: boolean, + ): Promise { workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); @@ -185,7 +210,8 @@ export class WorkspaceRegistry { workspace, agentsFiles, availableAgentsFiles, - includeBootstrapContext: false, + workspaceReused: true, + includeBootstrapContext, }; } @@ -329,6 +355,7 @@ export class WorkspaceRegistry { workspace, agentsFiles, availableAgentsFiles, + workspaceReused: false, includeBootstrapContext: true, }; } From f5ab680271e7febb22e5b651b61c425dcebc14e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:48:17 +0530 Subject: [PATCH 09/24] fix(server): separate workspace reuse from bootstrap --- docs/chatgpt-coding-workflow.md | 33 ++++++++++++++--------- src/oauth-store.test.ts | 47 +++++++++++++++++++++++++++++++++ src/server.ts | 21 ++++++++------- src/workspaces.test.ts | 19 +++++++++++++ 4 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 5b77e782..5b3c71d1 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -19,21 +19,25 @@ and shell calls should reuse that same `workspaceId`. ChatGPT sends an anonymized conversation identifier in `_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: -if `open_workspace` is called again for the same path, mode, and base ref in the -same ChatGPT conversation, DevSpace returns the existing `workspaceId` and omits -the project instructions, skills, subagent metadata, and diagnostics already -returned by the first call. The conversation binding is persisted so reconnecting -the MCP transport or restarting DevSpace does not create another managed worktree -for the same ChatGPT conversation and target. The workspace card still receives -the complete hidden display payload, so first and repeated calls render the same -workspace details without adding those fields to the model transcript again. - -Do not reopen the same folder unless: +if checkout mode is called again for the same canonical project path in the same +ChatGPT conversation, DevSpace returns the existing checkout `workspaceId`. +Worktree mode is deliberately different: every call creates a new managed +worktree and a new workspace session, even for the same path and base ref. + +Project bootstrap delivery is tracked separately from workspace reuse. The first +open for a canonical project path in a ChatGPT conversation returns project +instructions, skills, subagent metadata, and diagnostics. Later checkout or +worktree opens for that project omit those fields from the model response, even +when a new worktree workspace is created. This state is persisted across MCP +reconnects and DevSpace restarts. The workspace card still receives the complete +hidden display payload, so every call renders full workspace details without +adding the bootstrap fields to the model transcript again. + +Do not reopen the same checkout folder unless: - the `workspaceId` is rejected as unknown - the user switches to another folder -- the user switches between checkout and worktree mode -- the user explicitly asks to reopen +- the user asks for a new isolated worktree ## Checkout Mode @@ -67,6 +71,11 @@ Managed worktrees are created under: Worktree mode requires a Git repository with at least one commit. It starts from `HEAD` unless `baseRef` is provided. +Each worktree-mode call creates a new managed worktree and returns a new +`workspaceId`. Reuse that ID for work inside that worktree; call +`open_workspace` in worktree mode again only when another isolated worktree is +actually required. + Uncommitted source checkout changes are not copied into the managed worktree. DevSpace reports when the source checkout was dirty so the model can decide how to proceed with the user. diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index fe69797f..2aadaf1e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -21,6 +21,7 @@ const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { await testDatabaseConfiguration(join(root, "database-configuration")); + testConversationBootstrapMigration(join(root, "bootstrap-migration")); testPersistenceAndTokenHashing(join(root, "persistence")); testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); @@ -29,6 +30,52 @@ try { await rm(root, { recursive: true, force: true }); } +function testConversationBootstrapMigration(stateDir: string): void { + const initial = openDatabase(stateDir); + try { + initial.sqlite.prepare(` + insert into workspace_sessions ( + id, root, status, mode, managed, created_at, last_used_at + ) values (?, ?, 'active', 'worktree', 'true', ?, ?) + `).run("ws_existing", "/tmp/project-worktree", "2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_hash, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["worktree", "/tmp/project", "HEAD"]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ); + initial.sqlite.exec(` + drop table workspace_conversation_bootstraps; + delete from devspace_schema_migrations where version = 5; + `); + } finally { + initial.close(); + } + + const migrated = openDatabase(stateDir); + try { + assert.deepEqual( + migrated.sqlite.prepare(` + select conversation_scope_hash, project_key, created_at, last_used_at + from workspace_conversation_bootstraps + `).all(), + [{ + conversation_scope_hash: "chat-existing", + project_key: "/tmp/project", + created_at: "2026-01-01T00:00:00.000Z", + last_used_at: "2026-01-02T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { diff --git a/src/server.ts b/src/server.ts index 737724a0..f1930e82 100644 --- a/src/server.ts +++ b/src/server.ts @@ -752,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder. In ChatGPT, repeated calls for the same target in one conversation return the existing workspaceId and omit bootstrap details already returned. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session.", + "Open a local project directory as a coding workspace. Call this before reading, editing, searching, writing, showing changes, or running commands, then reuse the returned workspaceId. In ChatGPT, checkout mode reuses the existing checkout workspace for the same project and conversation. Every worktree-mode call creates a new managed worktree and workspace. After the first open for a project in one ChatGPT conversation, later opens omit bootstrap details already returned. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for a new isolated or parallel coding session.", inputSchema: { path: z .string() @@ -763,7 +763,7 @@ function createMcpServer( .enum(["checkout", "worktree"]) .optional() .describe( - "Defaults to checkout. Use checkout to work in the actual directory. Use worktree to create an isolated managed Git worktree for parallel work.", + "Defaults to checkout. Use checkout to work in the actual directory. Each worktree-mode call creates a new isolated managed Git worktree and workspace for parallel work.", ), baseRef: z .string() @@ -802,12 +802,13 @@ function createMcpServer( workspace, agentsFiles, availableAgentsFiles, + workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace( { path, mode, baseRef }, { conversationScopeHash: openAiConversationScopeHash(_meta) }, ); - const reused = !includeBootstrapContext; + const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, @@ -846,18 +847,20 @@ function createMcpServer( const cardInstruction = config.skillsEnabled ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; - const instruction = reused - ? "Reuse this workspaceId for subsequent tool calls. Workspace instructions, nested instruction paths, skills, subagent metadata, and diagnostics were already returned earlier in this ChatGPT conversation and are intentionally omitted here." - : cardInstruction; + const instruction = includeBootstrapContext + ? cardInstruction + : workspaceReused + ? "Reuse this workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here." + : "Use this new workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here."; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `${reused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, + `${workspaceReused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, - reused - ? "Bootstrap details omitted because they were already returned in this ChatGPT conversation." + bootstrapOmitted + ? "Project bootstrap details omitted because they were already returned for this project in this ChatGPT conversation." : undefined, loadedAgentsFiles.length > 0 ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 6b3355cd..2c4963fd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -227,6 +227,25 @@ try { assert.equal(reusedProjectCheckout.workspaceReused, true); assert.equal(reusedProjectCheckout.includeBootstrapContext, false); + const worktreeFirst = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-first", + }); + const checkoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", + }); + const reusedCheckoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", + }); + assert.equal(worktreeFirst.includeBootstrapContext, true); + assert.equal(worktreeFirst.workspaceReused, false); + assert.equal(checkoutAfterWorktree.includeBootstrapContext, false); + assert.equal(checkoutAfterWorktree.workspaceReused, false); + assert.equal(checkoutAfterWorktree.workspace.mode, "checkout"); + assert.notEqual(checkoutAfterWorktree.workspace.id, worktreeFirst.workspace.id); + assert.equal(reusedCheckoutAfterWorktree.includeBootstrapContext, false); + assert.equal(reusedCheckoutAfterWorktree.workspaceReused, true); + assert.equal(reusedCheckoutAfterWorktree.workspace.id, checkoutAfterWorktree.workspace.id); + const [persistentWorktree, concurrentWorktree] = await Promise.all([ persistentRegistry.openWorkspace(worktreeInput, { conversationScopeHash: "chat-worktree-concurrent", From b38e46cbb3bd045bbb2d002d7977bafcf4927abb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:47:29 +0530 Subject: [PATCH 10/24] refactor(workspace): store conversation scope ids directly --- docs/chatgpt-coding-workflow.md | 4 ++-- src/db/migrations.ts | 16 +++++++------- src/db/schema.ts | 8 +++---- src/oauth-store.test.ts | 6 +++--- src/request-meta.test.ts | 24 ++++++--------------- src/request-meta.ts | 11 ++-------- src/server.ts | 4 ++-- src/workspace-store.ts | 38 ++++++++++++++++----------------- src/workspaces.test.ts | 36 +++++++++++++++---------------- src/workspaces.ts | 26 +++++++++++----------- 10 files changed, 78 insertions(+), 95 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 5b3c71d1..0a78e768 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,8 +17,8 @@ ChatGPT should call `open_workspace` once for a project folder: The result includes a `workspaceId`. All later file, search, edit, show-changes, and shell calls should reuse that same `workspaceId`. -ChatGPT sends an anonymized conversation identifier in -`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: +ChatGPT sends an opaque conversation identifier in `_meta["openai/session"]`. +DevSpace stores that value locally and uses it only as a correlation scope: if checkout mode is called again for the same canonical project path in the same ChatGPT conversation, DevSpace returns the existing checkout `workspaceId`. Worktree mode is deliberately different: every call creates a new managed diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 47887eb5..67724586 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -187,12 +187,12 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void { function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { sqlite.exec(` create table if not exists workspace_conversation_bindings ( - conversation_scope_hash text not null, + conversation_scope_id text not null, target_key text not null, workspace_session_id text not null, created_at text not null, last_used_at text not null, - primary key (conversation_scope_hash, target_key), + primary key (conversation_scope_id, target_key), foreign key (workspace_session_id) references workspace_sessions(id) on delete cascade @@ -206,26 +206,26 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void { sqlite.exec(` create table if not exists workspace_conversation_bootstraps ( - conversation_scope_hash text not null, + conversation_scope_id text not null, project_key text not null, created_at text not null, last_used_at text not null, - primary key (conversation_scope_hash, project_key) + primary key (conversation_scope_id, project_key) ); `); const bindings = sqlite.prepare(` - select conversation_scope_hash, target_key, created_at, last_used_at + select conversation_scope_id, target_key, created_at, last_used_at from workspace_conversation_bindings `).all() as Array<{ - conversation_scope_hash: string; + conversation_scope_id: string; target_key: string; created_at: string; last_used_at: string; }>; const insertBootstrap = sqlite.prepare(` insert or ignore into workspace_conversation_bootstraps ( - conversation_scope_hash, + conversation_scope_id, project_key, created_at, last_used_at @@ -236,7 +236,7 @@ function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void const projectKey = projectKeyFromConversationTarget(binding.target_key); if (!projectKey) continue; insertBootstrap.run( - binding.conversation_scope_hash, + binding.conversation_scope_id, projectKey, binding.created_at, binding.last_used_at, diff --git a/src/db/schema.ts b/src/db/schema.ts index 7d557e97..dd87ab5b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -41,7 +41,7 @@ export const loadedAgentFiles = sqliteTable( export const workspaceConversationBindings = sqliteTable( "workspace_conversation_bindings", { - conversationScopeHash: text("conversation_scope_hash").notNull(), + conversationScopeId: text("conversation_scope_id").notNull(), targetKey: text("target_key").notNull(), workspaceSessionId: text("workspace_session_id") .notNull() @@ -50,7 +50,7 @@ export const workspaceConversationBindings = sqliteTable( lastUsedAt: text("last_used_at").notNull(), }, (table) => [ - primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }), + primaryKey({ columns: [table.conversationScopeId, table.targetKey] }), index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId), ], ); @@ -58,13 +58,13 @@ export const workspaceConversationBindings = sqliteTable( export const workspaceConversationBootstraps = sqliteTable( "workspace_conversation_bootstraps", { - conversationScopeHash: text("conversation_scope_hash").notNull(), + conversationScopeId: text("conversation_scope_id").notNull(), projectKey: text("project_key").notNull(), createdAt: text("created_at").notNull(), lastUsedAt: text("last_used_at").notNull(), }, (table) => [ - primaryKey({ columns: [table.conversationScopeHash, table.projectKey] }), + primaryKey({ columns: [table.conversationScopeId, table.projectKey] }), ], ); diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2aadaf1e..642708e9 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -40,7 +40,7 @@ function testConversationBootstrapMigration(stateDir: string): void { `).run("ws_existing", "/tmp/project-worktree", "2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"); initial.sqlite.prepare(` insert into workspace_conversation_bindings ( - conversation_scope_hash, target_key, workspace_session_id, created_at, last_used_at + conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at ) values (?, ?, ?, ?, ?) `).run( "chat-existing", @@ -61,11 +61,11 @@ function testConversationBootstrapMigration(stateDir: string): void { try { assert.deepEqual( migrated.sqlite.prepare(` - select conversation_scope_hash, project_key, created_at, last_used_at + select conversation_scope_id, project_key, created_at, last_used_at from workspace_conversation_bootstraps `).all(), [{ - conversation_scope_hash: "chat-existing", + conversation_scope_id: "chat-existing", project_key: "/tmp/project", created_at: "2026-01-01T00:00:00.000Z", last_used_at: "2026-01-02T00:00:00.000Z", diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index de63e61f..5fffdabe 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -1,26 +1,16 @@ import assert from "node:assert/strict"; -import { openAiConversationScopeHash } from "./request-meta.js"; +import { openAiConversationScopeId } from "./request-meta.js"; -assert.equal(openAiConversationScopeHash(undefined), undefined); -assert.equal(openAiConversationScopeHash({}), undefined); -assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined); +assert.equal(openAiConversationScopeId(undefined), undefined); +assert.equal(openAiConversationScopeId({}), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); -const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" }); -assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/); assert.equal( - sessionOnly, - openAiConversationScopeHash({ "openai/session": "chat-1" }), -); -assert.notEqual( - sessionOnly, - openAiConversationScopeHash({ "openai/session": "chat-2" }), -); - -assert.equal( - sessionOnly, - openAiConversationScopeHash({ + openAiConversationScopeId({ "openai/session": "chat-1", "openai/subject": "user-1", "openai/organization": "org-1", }), + "chat-1", ); diff --git a/src/request-meta.ts b/src/request-meta.ts index 4b17f6c2..40662373 100644 --- a/src/request-meta.ts +++ b/src/request-meta.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - function metadataString( meta: Record | undefined, key: string, @@ -8,13 +6,8 @@ function metadataString( return typeof value === "string" && value.length > 0 ? value : undefined; } -export function openAiConversationScopeHash( +export function openAiConversationScopeId( meta: Record | undefined, ): string | undefined { - const session = metadataString(meta, "openai/session"); - if (!session) return undefined; - - return createHash("sha256") - .update(JSON.stringify(["openai", session])) - .digest("hex"); + return metadataString(meta, "openai/session"); } diff --git a/src/server.ts b/src/server.ts index f1930e82..4eb2499e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -50,7 +50,7 @@ import { } from "./mcp-sessions.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; -import { openAiConversationScopeHash } from "./request-meta.js"; +import { openAiConversationScopeId } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -806,7 +806,7 @@ function createMcpServer( includeBootstrapContext, } = await workspaces.openWorkspace( { path, mode, baseRef }, - { conversationScopeHash: openAiConversationScopeHash(_meta) }, + { conversationScopeId: openAiConversationScopeId(_meta) }, ); const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 5fb66169..f7375701 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -24,7 +24,7 @@ export interface WorkspaceSession { } export interface WorkspaceConversationBinding { - conversationScopeHash: string; + conversationScopeId: string; targetKey: string; workspaceSessionId: string; createdAt: string; @@ -44,17 +44,17 @@ export interface WorkspaceStore { getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; getConversationBinding( - conversationScopeHash: string, + conversationScopeId: string, targetKey: string, ): WorkspaceConversationBinding | undefined; setConversationBinding(input: { - conversationScopeHash: string; + conversationScopeId: string; targetKey: string; workspaceSessionId: string; }): WorkspaceConversationBinding; - touchConversationBinding(conversationScopeHash: string, targetKey: string): void; - deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; - claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean; + touchConversationBinding(conversationScopeId: string, targetKey: string): void; + deleteConversationBinding(conversationScopeId: string, targetKey: string): void; + claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean; close?(): void; } @@ -126,7 +126,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { } getConversationBinding( - conversationScopeHash: string, + conversationScopeId: string, targetKey: string, ): WorkspaceConversationBinding | undefined { const row = this.database.db @@ -134,7 +134,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .from(workspaceConversationBindings) .where( and( - eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey), ), ) @@ -144,7 +144,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { } setConversationBinding(input: { - conversationScopeHash: string; + conversationScopeId: string; targetKey: string; workspaceSessionId: string; }): WorkspaceConversationBinding { @@ -152,7 +152,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { const row = this.database.db .insert(workspaceConversationBindings) .values({ - conversationScopeHash: input.conversationScopeHash, + conversationScopeId: input.conversationScopeId, targetKey: input.targetKey, workspaceSessionId: input.workspaceSessionId, createdAt: now, @@ -160,7 +160,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { }) .onConflictDoUpdate({ target: [ - workspaceConversationBindings.conversationScopeHash, + workspaceConversationBindings.conversationScopeId, workspaceConversationBindings.targetKey, ], set: { @@ -178,37 +178,37 @@ export class SqliteWorkspaceStore implements WorkspaceStore { return rowToWorkspaceConversationBinding(row); } - touchConversationBinding(conversationScopeHash: string, targetKey: string): void { + touchConversationBinding(conversationScopeId: string, targetKey: string): void { this.database.db .update(workspaceConversationBindings) .set({ lastUsedAt: new Date().toISOString() }) .where( and( - eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey), ), ) .run(); } - deleteConversationBinding(conversationScopeHash: string, targetKey: string): void { + deleteConversationBinding(conversationScopeId: string, targetKey: string): void { this.database.db .delete(workspaceConversationBindings) .where( and( - eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey), ), ) .run(); } - claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean { + claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean { const now = new Date().toISOString(); const [inserted] = this.database.db .insert(workspaceConversationBootstraps) .values({ - conversationScopeHash, + conversationScopeId, projectKey, createdAt: now, lastUsedAt: now, @@ -224,7 +224,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .set({ lastUsedAt: now }) .where( and( - eq(workspaceConversationBootstraps.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBootstraps.conversationScopeId, conversationScopeId), eq(workspaceConversationBootstraps.projectKey, projectKey), ), ) @@ -261,7 +261,7 @@ function rowToWorkspaceConversationBinding( row: WorkspaceConversationBindingRow, ): WorkspaceConversationBinding { return { - conversationScopeHash: row.conversationScopeHash, + conversationScopeId: row.conversationScopeId, targetKey: row.targetKey, workspaceSessionId: row.workspaceSessionId, createdAt: row.createdAt, diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 2c4963fd..bca98560 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -161,10 +161,10 @@ try { const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); const persistentWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeHash: "chat-checkout", + conversationScopeId: "chat-checkout", }); const reusedPersistentWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeHash: "chat-checkout", + conversationScopeId: "chat-checkout", }); assert.equal(persistentWorkspace.includeBootstrapContext, true); assert.equal(persistentWorkspace.workspaceReused, false); @@ -181,7 +181,7 @@ try { ); const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeHash: "chat-checkout-other", + conversationScopeId: "chat-checkout-other", }); assert.equal(otherConversationWorkspace.includeBootstrapContext, true); assert.equal(otherConversationWorkspace.workspaceReused, false); @@ -190,11 +190,11 @@ try { const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); await mkdir(staleWorkspaceRoot); const staleWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { - conversationScopeHash: "chat-stale", + conversationScopeId: "chat-stale", }); await rm(staleWorkspaceRoot, { recursive: true, force: true }); const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { - conversationScopeHash: "chat-stale", + conversationScopeId: "chat-stale", }); assert.equal(replacementWorkspace.includeBootstrapContext, false); assert.equal(replacementWorkspace.workspaceReused, false); @@ -203,16 +203,16 @@ try { const worktreeInput = { path: gitRoot, mode: "worktree" as const }; const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeHash: "chat-project-modes", + conversationScopeId: "chat-project-modes", }); const firstProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-project-modes", + conversationScopeId: "chat-project-modes", }); const secondProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-project-modes", + conversationScopeId: "chat-project-modes", }); const reusedProjectCheckout = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeHash: "chat-project-modes", + conversationScopeId: "chat-project-modes", }); assert.equal(projectCheckout.includeBootstrapContext, true); assert.equal(projectCheckout.workspaceReused, false); @@ -228,13 +228,13 @@ try { assert.equal(reusedProjectCheckout.includeBootstrapContext, false); const worktreeFirst = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree-first", + conversationScopeId: "chat-worktree-first", }); const checkoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeHash: "chat-worktree-first", + conversationScopeId: "chat-worktree-first", }); const reusedCheckoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeHash: "chat-worktree-first", + conversationScopeId: "chat-worktree-first", }); assert.equal(worktreeFirst.includeBootstrapContext, true); assert.equal(worktreeFirst.workspaceReused, false); @@ -248,10 +248,10 @@ try { const [persistentWorktree, concurrentWorktree] = await Promise.all([ persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree-concurrent", + conversationScopeId: "chat-worktree-concurrent", }), persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree-concurrent", + conversationScopeId: "chat-worktree-concurrent", }), ]); assert.notEqual(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); @@ -280,7 +280,7 @@ try { assert.equal(restoredWorkspace.mode, "checkout"); const reboundWorkspace = await restoredRegistry.openWorkspace(root, { - conversationScopeHash: "chat-checkout", + conversationScopeId: "chat-checkout", }); assert.equal(reboundWorkspace.includeBootstrapContext, false); assert.equal(reboundWorkspace.workspaceReused, true); @@ -302,7 +302,7 @@ try { assert.equal(restoredWorktree.worktree?.managed, true); const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { - conversationScopeHash: "chat-worktree-concurrent", + conversationScopeId: "chat-worktree-concurrent", }); assert.equal(reboundWorktree.includeBootstrapContext, false); assert.equal(reboundWorktree.workspaceReused, false); @@ -322,10 +322,10 @@ try { const aliasStore = new SqliteWorkspaceStore(aliasStateDir); const aliasRegistry = new WorkspaceRegistry(config, aliasStore); const directConversationWorkspace = await aliasRegistry.openWorkspace(root, { - conversationScopeHash: "chat-alias", + conversationScopeId: "chat-alias", }); const aliasedConversationWorkspace = await aliasRegistry.openWorkspace(aliasRoot, { - conversationScopeHash: "chat-alias", + conversationScopeId: "chat-alias", }); assert.equal(aliasedConversationWorkspace.includeBootstrapContext, false); assert.equal( diff --git a/src/workspaces.ts b/src/workspaces.ts index b68030c5..3bedaa0e 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -70,7 +70,7 @@ export interface OpenWorkspaceInput { } export interface OpenWorkspaceOptions { - conversationScopeHash?: string; + conversationScopeId?: string; } type PathStats = Stats; @@ -93,8 +93,8 @@ export class WorkspaceRegistry { openOptions: OpenWorkspaceOptions = {}, ): Promise { const workspaceInput = typeof input === "string" ? { path: input } : input; - const conversationScopeHash = openOptions.conversationScopeHash; - if (!conversationScopeHash || !this.store) { + const conversationScopeId = openOptions.conversationScopeId; + if (!conversationScopeId || !this.store) { return this.openNewWorkspace(workspaceInput); } @@ -105,14 +105,14 @@ export class WorkspaceRegistry { return { ...context, includeBootstrapContext: this.store.claimConversationBootstrap( - conversationScopeHash, + conversationScopeId, projectKey, ), }; } const targetKey = this.conversationCheckoutTargetKey(projectKey); - const operationKey = JSON.stringify([conversationScopeHash, targetKey]); + const operationKey = JSON.stringify([conversationScopeId, targetKey]); const pending = this.pendingCheckoutOpens.get(operationKey); if (pending) { const context = await pending; @@ -125,7 +125,7 @@ export class WorkspaceRegistry { const open = this.openConversationCheckout( workspaceInput, - conversationScopeHash, + conversationScopeId, targetKey, projectKey, ); @@ -152,20 +152,20 @@ export class WorkspaceRegistry { private async openConversationCheckout( input: OpenWorkspaceInput, - conversationScopeHash: string, + conversationScopeId: string, targetKey: string, projectKey: string, ): Promise { - const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); + const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); if (binding) { try { const workspace = this.getWorkspace(binding.workspaceSessionId); const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { - this.store?.touchConversationBinding(conversationScopeHash, targetKey); + this.store?.touchConversationBinding(conversationScopeId, targetKey); return await this.reusedWorkspaceContext( workspace, - this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, ); } } catch { @@ -173,19 +173,19 @@ export class WorkspaceRegistry { } this.workspaces.delete(binding.workspaceSessionId); - this.store?.deleteConversationBinding(conversationScopeHash, targetKey); + this.store?.deleteConversationBinding(conversationScopeId, targetKey); } const context = await this.openCheckoutWorkspace(input.path); this.store?.setConversationBinding({ - conversationScopeHash, + conversationScopeId, targetKey, workspaceSessionId: context.workspace.id, }); return { ...context, includeBootstrapContext: - this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, }; } From a8701f437dad2b1b03cdf2e51e2545369ca9015b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:48:52 +0530 Subject: [PATCH 11/24] fix(review): preserve partial checkpoints safely --- src/review-checkpoints.test.ts | 30 +++++++++++++++++++++++++ src/review-checkpoints.ts | 40 +++++++++++++++++++--------------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 227edd0d..c67c56e9 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -50,6 +50,15 @@ try { assert.equal(afterRestart.summary.files, 2); assert.match(afterRestart.patch, /world/); + const sinceOpenAfterRestart = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + since: "workspace_open", + markReviewed: false, + }); + assert.equal(sinceOpenAfterRestart.summary.files, 2); + assert.match(sinceOpenAfterRestart.patch, /world/); + const stillUnreviewed = await manager.reviewChanges({ workspaceId: "ws_review", root, @@ -59,6 +68,27 @@ try { const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); assert.equal(afterReviewed.summary.files, 0); + + await writeFile(join(root, "README.md"), "hello\nworld\nlater\n"); + + const concurrentManager = createReviewCheckpointManager(); + const [, concurrentReview] = await Promise.all([ + concurrentManager.initializeWorkspace({ workspaceId: "ws_review", root }), + concurrentManager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false }), + ]); + assert.equal(concurrentReview.summary.files, 1); + assert.match(concurrentReview.patch, /later/); + + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); + const partiallyRestoredManager = createReviewCheckpointManager(); + await partiallyRestoredManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const afterPartialRestore = await partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.equal(afterPartialRestore.summary.files, 2); + assert.match(afterPartialRestore.patch, /later/); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 07ef212f..32352443 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -79,7 +79,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); - if (!state) { + if (!isInitializedState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } @@ -124,7 +124,6 @@ async function initializeWorkspaceState( ): Promise { const refs = reviewRefs(workspaceId); const state: WorkspaceReviewState = { root, ...refs }; - states.set(workspaceId, state); try { const eligibility = await getGitEligibility(root); @@ -133,31 +132,38 @@ async function initializeWorkspaceState( return; } - state.gitRoot = eligibility.gitRoot; - const [hasOpenRef, hasBaselineRef] = await Promise.all([ - hasCommitRef(eligibility.gitRoot, state.openRef), - hasCommitRef(eligibility.gitRoot, state.baselineRef), + const [openCommit, baselineCommit] = await Promise.all([ + commitForRef(eligibility.gitRoot, state.openRef), + commitForRef(eligibility.gitRoot, state.baselineRef), ]); - if (hasOpenRef && hasBaselineRef) return; - const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); - if (!hasOpenRef) { - await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); - } - if (!hasBaselineRef) { - await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); + if (!openCommit && !baselineCommit) { + const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot); + await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); + await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); + } else if (openCommit && !baselineCommit) { + await git(eligibility.gitRoot, ["update-ref", state.baselineRef, openCommit]); + } else if (!openCommit && baselineCommit) { + await git(eligibility.gitRoot, ["update-ref", state.openRef, baselineCommit]); } + + state.gitRoot = eligibility.gitRoot; } catch (error) { state.diagnostic = error instanceof Error ? error.message : String(error); + } finally { + states.set(workspaceId, state); } } -async function hasCommitRef(gitRoot: string, ref: string): Promise { +function isInitializedState(state: WorkspaceReviewState | undefined): boolean { + return state?.gitRoot !== undefined || state?.diagnostic !== undefined; +} + +async function commitForRef(gitRoot: string, ref: string): Promise { try { - await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`]); - return true; + return (await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`])).stdout.trim(); } catch { - return false; + return undefined; } } From e13fd528fe57966cf3b1f4060a1bd90522fcb5c8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:50:04 +0530 Subject: [PATCH 12/24] fix(workspace): preserve valid bindings on context errors --- src/workspaces.test.ts | 23 ++++++++++++++++++++++- src/workspaces.ts | 15 ++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index bca98560..8b373ca4 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -180,6 +180,27 @@ try { persistentWorkspace.availableAgentsFiles, ); + const projectAgentsDir = join(root, ".devspace", "agents"); + const projectAgentsBackup = join(root, ".devspace", "agents-backup"); + await rename(projectAgentsDir, projectAgentsBackup); + await writeFile(projectAgentsDir, "not a directory\n"); + try { + await assert.rejects( + () => persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-checkout" }), + /directory|ENOTDIR/i, + ); + assert.equal( + firstStore.getConversationBinding( + "chat-checkout", + JSON.stringify(["checkout", root, null]), + )?.workspaceSessionId, + persistentWorkspace.workspace.id, + ); + } finally { + await rm(projectAgentsDir, { force: true }); + await rename(projectAgentsBackup, projectAgentsDir); + } + const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-checkout-other", }); diff --git a/src/workspaces.ts b/src/workspaces.ts index 3bedaa0e..14fd6f17 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -158,20 +158,25 @@ export class WorkspaceRegistry { ): Promise { const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); if (binding) { + let reusableWorkspace: Workspace | undefined; try { const workspace = this.getWorkspace(binding.workspaceSessionId); const workspaceStats = await stat(workspace.root); if (workspaceStats.isDirectory()) { - this.store?.touchConversationBinding(conversationScopeId, targetKey); - return await this.reusedWorkspaceContext( - workspace, - this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, - ); + reusableWorkspace = workspace; } } catch { // The persisted workspace is no longer usable; replace its binding below. } + if (reusableWorkspace) { + this.store?.touchConversationBinding(conversationScopeId, targetKey); + return await this.reusedWorkspaceContext( + reusableWorkspace, + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + ); + } + this.workspaces.delete(binding.workspaceSessionId); this.store?.deleteConversationBinding(conversationScopeId, targetKey); } From 190b1dc95e09f5ad963ef459ab94566cdd29e7d2 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:50:58 +0530 Subject: [PATCH 13/24] fix(ui): stringify empty diagnostics safely --- src/ui/workspace-app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index cfeec817..da32cd32 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -517,7 +517,7 @@ function formatDiagnostic(diagnostic: unknown): string { if (typeof diagnostic === "string") return diagnostic; if (diagnostic instanceof Error) return diagnostic.message; try { - return JSON.stringify(diagnostic); + return JSON.stringify(diagnostic) ?? String(diagnostic); } catch { return String(diagnostic); } From 35049a8c6df9b09618417fa4ed9e3cf39d7cc429 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:51:52 +0530 Subject: [PATCH 14/24] docs(workspace): remove unsupported reopen guidance --- AGENTS.md | 4 ++-- docs/chatgpt-coding-workflow.md | 4 ++-- src/server.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a5ad6cc9..889d9fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ The model-facing workflow is workspace based. MCP clients should call `open_workspace` once per local project directory or worktree, then reuse the returned `workspaceId` for subsequent tool calls in that same folder. Do not call `open_workspace` again for the same folder unless the `workspaceId` is -rejected as unknown, the client switches folders/worktrees or checkout/worktree -mode, or the user explicitly asks to reopen. `AGENTS.md` files are returned +rejected as unknown or the client switches folders/worktrees or checkout/worktree +mode. `AGENTS.md` files are returned automatically by `open_workspace` and by later tool calls when the requested path enters a directory with instructions that have not been loaded for that workspace. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 0a78e768..c2f9336e 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -33,10 +33,10 @@ reconnects and DevSpace restarts. The workspace card still receives the complete hidden display payload, so every call renders full workspace details without adding the bootstrap fields to the model transcript again. -Do not reopen the same checkout folder unless: +Do not call `open_workspace` again for the same checkout folder unless: - the `workspaceId` is rejected as unknown -- the user switches to another folder +- work moves to a different project folder - the user asks for a new isolated worktree ## Checkout Mode diff --git a/src/server.ts b/src/server.ts index 4eb2499e..3fb6e9bd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -210,7 +210,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${artifactInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching to a different project folder, changing checkout/worktree mode, the workspaceId is rejected as unknown, or a new isolated worktree is requested. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${artifactInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -845,8 +845,8 @@ function createMcpServer( const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, you switch to a different project folder or checkout/worktree mode, or the user requests a new isolated worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, you switch to a different project folder or checkout/worktree mode, or the user requests a new isolated worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; const instruction = includeBootstrapContext ? cardInstruction : workspaceReused From 62dc7c1559de675017f25554be2e526bf3681249 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:52:18 +0530 Subject: [PATCH 15/24] fix(db): make bootstrap backfill deterministic --- src/db/migrations.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 67724586..058b71d5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -217,6 +217,7 @@ function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void const bindings = sqlite.prepare(` select conversation_scope_id, target_key, created_at, last_used_at from workspace_conversation_bindings + order by created_at asc, target_key asc `).all() as Array<{ conversation_scope_id: string; target_key: string; @@ -244,6 +245,8 @@ function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void } } +// Historical target keys are JSON tuples of [mode, projectKey, baseRef]. +// This migration intentionally parses that frozen shape rather than importing the current producer. function projectKeyFromConversationTarget(targetKey: string): string | undefined { try { const parsed = JSON.parse(targetKey) as unknown; From ae546019a93cdc98e8846bbbf70e4f825a2680b2 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:54:39 +0530 Subject: [PATCH 16/24] fix(workspace): canonicalize missing checkout targets --- src/workspaces.test.ts | 16 ++++++++++++++++ src/workspaces.ts | 20 ++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8b373ca4..3cedd4aa 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -353,6 +353,22 @@ try { aliasedConversationWorkspace.workspace.id, directConversationWorkspace.workspace.id, ); + + const aliasedStaleRoot = join(aliasRoot, "stale-alias-workspace"); + await mkdir(aliasedStaleRoot); + const aliasedStaleWorkspace = await aliasRegistry.openWorkspace(aliasedStaleRoot, { + conversationScopeId: "chat-alias-stale", + }); + await rm(aliasedStaleRoot, { recursive: true, force: true }); + const aliasedReplacementWorkspace = await aliasRegistry.openWorkspace(aliasedStaleRoot, { + conversationScopeId: "chat-alias-stale", + }); + assert.equal(aliasedReplacementWorkspace.includeBootstrapContext, false); + assert.equal(aliasedReplacementWorkspace.workspaceReused, false); + assert.notEqual( + aliasedReplacementWorkspace.workspace.id, + aliasedStaleWorkspace.workspace.id, + ); aliasStore.close(); const aliasConfig = loadConfig({ diff --git a/src/workspaces.ts b/src/workspaces.ts index 14fd6f17..8b829ecb 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { createManagedWorktree } from "./git-worktrees.js"; @@ -196,7 +196,7 @@ export class WorkspaceRegistry { private async conversationProjectKey(input: OpenWorkspaceInput): Promise { const path = assertAllowedPath(input.path, this.config.allowedRoots); - return await realpath(path).catch(() => path); + return canonicalPath(path); } private conversationCheckoutTargetKey(projectKey: string): string { @@ -437,6 +437,22 @@ export class WorkspaceRegistry { } } +async function canonicalPath(path: string): Promise { + const missingSegments: string[] = []; + let candidate = path; + + while (true) { + try { + return resolve(await realpath(candidate), ...missingSegments.reverse()); + } catch { + const parent = dirname(candidate); + if (parent === candidate) return path; + missingSegments.push(basename(candidate)); + candidate = parent; + } + } +} + export async function ensureCheckoutWorkspaceRoot( path: string, ops: DirectoryOps = { stat, mkdir }, From 7942f2b853b81e114193b5313b0d66d9c7ed2776 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:02:17 +0530 Subject: [PATCH 17/24] test(workspace): use canonical binding keys --- src/workspaces.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 3cedd4aa..09f23b76 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -192,7 +192,7 @@ try { assert.equal( firstStore.getConversationBinding( "chat-checkout", - JSON.stringify(["checkout", root, null]), + JSON.stringify(["checkout", await realpath(root), null]), )?.workspaceSessionId, persistentWorkspace.workspace.id, ); From 7105d1d1224b996ad8d752a452d971187967f896 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:08:07 +0530 Subject: [PATCH 18/24] fix(workspace): claim bootstrap after context loads --- src/workspaces.test.ts | 20 +++++++++++++++++--- src/workspaces.ts | 17 ++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 09f23b76..76e17508 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -180,19 +180,26 @@ try { persistentWorkspace.availableAgentsFiles, ); + const checkoutTargetKey = JSON.stringify(["checkout", await realpath(root), null]); + firstStore.setConversationBinding({ + conversationScopeId: "chat-context-failure", + targetKey: checkoutTargetKey, + workspaceSessionId: persistentWorkspace.workspace.id, + }); + const projectAgentsDir = join(root, ".devspace", "agents"); const projectAgentsBackup = join(root, ".devspace", "agents-backup"); await rename(projectAgentsDir, projectAgentsBackup); await writeFile(projectAgentsDir, "not a directory\n"); try { await assert.rejects( - () => persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-checkout" }), + () => persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-context-failure" }), /directory|ENOTDIR/i, ); assert.equal( firstStore.getConversationBinding( - "chat-checkout", - JSON.stringify(["checkout", await realpath(root), null]), + "chat-context-failure", + checkoutTargetKey, )?.workspaceSessionId, persistentWorkspace.workspace.id, ); @@ -201,6 +208,13 @@ try { await rename(projectAgentsBackup, projectAgentsDir); } + const recoveredContextWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeId: "chat-context-failure", + }); + assert.equal(recoveredContextWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.equal(recoveredContextWorkspace.workspaceReused, true); + assert.equal(recoveredContextWorkspace.includeBootstrapContext, true); + const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-checkout-other", }); diff --git a/src/workspaces.ts b/src/workspaces.ts index 8b829ecb..939fbf0c 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -171,10 +171,12 @@ export class WorkspaceRegistry { if (reusableWorkspace) { this.store?.touchConversationBinding(conversationScopeId, targetKey); - return await this.reusedWorkspaceContext( - reusableWorkspace, - this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, - ); + const context = await this.reusedWorkspaceContext(reusableWorkspace); + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + }; } this.workspaces.delete(binding.workspaceSessionId); @@ -203,10 +205,7 @@ export class WorkspaceRegistry { return JSON.stringify(["checkout", projectKey, null]); } - private async reusedWorkspaceContext( - workspace: Workspace, - includeBootstrapContext: boolean, - ): Promise { + private async reusedWorkspaceContext(workspace: Workspace): Promise { workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); @@ -216,7 +215,7 @@ export class WorkspaceRegistry { agentsFiles, availableAgentsFiles, workspaceReused: true, - includeBootstrapContext, + includeBootstrapContext: true, }; } From 95b08f21c9d97849bd4992d3b280008ef85477c7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:08:57 +0530 Subject: [PATCH 19/24] fix(review): preserve checkpoint meanings after ref loss --- src/review-checkpoints.test.ts | 39 +++++++++++++++++++++++++++++++--- src/review-checkpoints.ts | 29 ++++++++++++++++++++----- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index c67c56e9..0cb12646 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -82,13 +82,46 @@ try { await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); const partiallyRestoredManager = createReviewCheckpointManager(); await partiallyRestoredManager.initializeWorkspace({ workspaceId: "ws_review", root }); - const afterPartialRestore = await partiallyRestoredManager.reviewChanges({ + await assert.rejects( + () => partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }), + /last-shown review checkpoint is missing/, + ); + const afterPartialRestoreSinceOpen = await partiallyRestoredManager.reviewChanges({ workspaceId: "ws_review", root, + since: "workspace_open", + markReviewed: false, + }); + assert.equal(afterPartialRestoreSinceOpen.summary.files, 2); + assert.match(afterPartialRestoreSinceOpen.patch, /later/); + + const openMissingSetupManager = createReviewCheckpointManager(); + await openMissingSetupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); + await writeFile(join(root, "open-missing.txt"), "still visible from baseline\n"); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_open_missing/open"]); + + const openMissingManager = createReviewCheckpointManager(); + await openMissingManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); + const afterOpenRefLoss = await openMissingManager.reviewChanges({ + workspaceId: "ws_open_missing", + root, markReviewed: false, }); - assert.equal(afterPartialRestore.summary.files, 2); - assert.match(afterPartialRestore.patch, /later/); + assert.equal(afterOpenRefLoss.summary.files, 1); + assert.match(afterOpenRefLoss.patch, /still visible from baseline/); + await assert.rejects( + () => openMissingManager.reviewChanges({ + workspaceId: "ws_open_missing", + root, + since: "workspace_open", + markReviewed: false, + }), + /workspace-open review checkpoint is missing/, + ); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 32352443..dc22f66d 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -31,6 +31,8 @@ interface WorkspaceReviewState { gitRoot?: string; openRef: string; baselineRef: string; + openRefAvailable: boolean; + baselineRefAvailable: boolean; diagnostic?: string; } @@ -88,6 +90,17 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); } + const baselineAvailable = since === "workspace_open" + ? state.openRefAvailable + : state.baselineRefAvailable; + if (!baselineAvailable) { + throw new Error( + since === "workspace_open" + ? "The workspace-open review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"last_shown\" if that checkpoint is available." + : "The last-shown review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"workspace_open\" if that checkpoint is available.", + ); + } + const baselineRef = since === "workspace_open" ? state.openRef : state.baselineRef; const baseline = (await git(state.gitRoot, ["rev-parse", "--verify", `${baselineRef}^{commit}`])).stdout.trim(); const current = await createWorkingTreeSnapshot(state.gitRoot); @@ -123,7 +136,12 @@ async function initializeWorkspaceState( root: string, ): Promise { const refs = reviewRefs(workspaceId); - const state: WorkspaceReviewState = { root, ...refs }; + const state: WorkspaceReviewState = { + root, + ...refs, + openRefAvailable: false, + baselineRefAvailable: false, + }; try { const eligibility = await getGitEligibility(root); @@ -141,10 +159,11 @@ async function initializeWorkspaceState( const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot); await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); - } else if (openCommit && !baselineCommit) { - await git(eligibility.gitRoot, ["update-ref", state.baselineRef, openCommit]); - } else if (!openCommit && baselineCommit) { - await git(eligibility.gitRoot, ["update-ref", state.openRef, baselineCommit]); + state.openRefAvailable = true; + state.baselineRefAvailable = true; + } else { + state.openRefAvailable = openCommit !== undefined; + state.baselineRefAvailable = baselineCommit !== undefined; } state.gitRoot = eligibility.gitRoot; From 13e9fefafa6d748df7d3e1dad84035bc4348b4f4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:09:15 +0530 Subject: [PATCH 20/24] docs(workspace): include mode switches in reopen guidance --- docs/chatgpt-coding-workflow.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c2f9336e..0279a083 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -37,6 +37,7 @@ Do not call `open_workspace` again for the same checkout folder unless: - the `workspaceId` is rejected as unknown - work moves to a different project folder +- work switches between checkout and worktree mode - the user asks for a new isolated worktree ## Checkout Mode From ffb83dea7a8d6749c7d96c96899a474551bc6c0a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:09:54 +0530 Subject: [PATCH 21/24] fix(review): activate recreated baselines --- src/review-checkpoints.test.ts | 14 ++++++++++++++ src/review-checkpoints.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 0cb12646..1a16b722 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -99,6 +99,20 @@ try { assert.equal(afterPartialRestoreSinceOpen.summary.files, 2); assert.match(afterPartialRestoreSinceOpen.patch, /later/); + const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", + root, + since: "workspace_open", + markReviewed: true, + }); + assert.equal(reestablishedBaseline.summary.files, 2); + const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.equal(afterBaselineReestablished.summary.files, 0); + const openMissingSetupManager = createReviewCheckpointManager(); await openMissingSetupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); await writeFile(join(root, "open-missing.txt"), "still visible from baseline\n"); diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index dc22f66d..4f291833 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -115,6 +115,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { if (markReviewed) { await git(state.gitRoot, ["update-ref", state.baselineRef, current]); + state.baselineRefAvailable = true; } return { From 598c737fa43591a647e16fad38a5703097ee0323 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:13:45 +0530 Subject: [PATCH 22/24] fix(review): retry eligibility after repository changes --- src/review-checkpoints.test.ts | 25 +++++++++++++++++++++++++ src/review-checkpoints.ts | 11 ++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 1a16b722..f7b0ede3 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -8,6 +8,7 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-review-checkpoints-test-")); +const unbornRoot = await mkdtemp(join(tmpdir(), "devspace-review-unborn-test-")); try { await git(root, ["init"]); @@ -136,8 +137,32 @@ try { }), /workspace-open review checkpoint is missing/, ); + + await git(unbornRoot, ["init"]); + await git(unbornRoot, ["config", "user.email", "devspace@example.com"]); + await git(unbornRoot, ["config", "user.name", "DevSpace Test"]); + + const unbornManager = createReviewCheckpointManager(); + await unbornManager.initializeWorkspace({ workspaceId: "ws_unborn", root: unbornRoot }); + await assert.rejects( + () => unbornManager.reviewChanges({ workspaceId: "ws_unborn", root: unbornRoot }), + /commit|HEAD|Git/i, + ); + + await writeFile(join(unbornRoot, "README.md"), "first commit\n"); + await git(unbornRoot, ["add", "README.md"]); + await git(unbornRoot, ["commit", "-m", "Initial commit"]); + + const afterFirstCommit = await unbornManager.reviewChanges({ + workspaceId: "ws_unborn", + root: unbornRoot, + markReviewed: false, + }); + assert.equal(afterFirstCommit.summary.files, 0); + assert.equal(afterFirstCommit.patch, ""); } finally { await rm(root, { recursive: true, force: true }); + await rm(unbornRoot, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 4f291833..9c303b4c 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -55,10 +55,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { return { async initializeWorkspace({ workspaceId, root }) { const existingState = states.get(workspaceId); - if ( - existingState?.root === root && - (existingState.gitRoot !== undefined || existingState.diagnostic !== undefined) - ) { + if (existingState?.root === root && existingState.gitRoot !== undefined) { return; } @@ -81,7 +78,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); - if (!isInitializedState(state)) { + if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } @@ -175,8 +172,8 @@ async function initializeWorkspaceState( } } -function isInitializedState(state: WorkspaceReviewState | undefined): boolean { - return state?.gitRoot !== undefined || state?.diagnostic !== undefined; +function isReadyState(state: WorkspaceReviewState | undefined): boolean { + return state?.gitRoot !== undefined; } async function commitForRef(gitRoot: string, ref: string): Promise { From 13f146442db1fe93cc91c557011191dec142e54b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:20:20 +0530 Subject: [PATCH 23/24] fix(review): fall back when last-shown checkpoint is missing --- src/review-checkpoints.test.ts | 18 +++++------------- src/review-checkpoints.ts | 29 ++++++++++++++++++----------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index f7b0ede3..20c0ae72 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -83,30 +83,22 @@ try { await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); const partiallyRestoredManager = createReviewCheckpointManager(); await partiallyRestoredManager.initializeWorkspace({ workspaceId: "ws_review", root }); - await assert.rejects( - () => partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", - root, - markReviewed: false, - }), - /last-shown review checkpoint is missing/, - ); - const afterPartialRestoreSinceOpen = await partiallyRestoredManager.reviewChanges({ + const afterPartialRestore = await partiallyRestoredManager.reviewChanges({ workspaceId: "ws_review", root, - since: "workspace_open", markReviewed: false, }); - assert.equal(afterPartialRestoreSinceOpen.summary.files, 2); - assert.match(afterPartialRestoreSinceOpen.patch, /later/); + assert.equal(afterPartialRestore.summary.files, 2); + assert.match(afterPartialRestore.patch, /later/); + assert.match(afterPartialRestore.result, /compared from workspace open/); const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ workspaceId: "ws_review", root, - since: "workspace_open", markReviewed: true, }); assert.equal(reestablishedBaseline.summary.files, 2); + assert.match(reestablishedBaseline.result, /baseline was re-established/); const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ workspaceId: "ws_review", root, diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 9c303b4c..f718d96a 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -87,18 +87,21 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); } - const baselineAvailable = since === "workspace_open" - ? state.openRefAvailable - : state.baselineRefAvailable; - if (!baselineAvailable) { + let effectiveSince = since; + let usedWorkspaceOpenFallback = false; + if (since === "last_shown" && !state.baselineRefAvailable) { + if (!state.openRefAvailable) { + throw new Error("Review checkpoints are missing; show_changes cannot reconstruct that history safely."); + } + effectiveSince = "workspace_open"; + usedWorkspaceOpenFallback = true; + } else if (since === "workspace_open" && !state.openRefAvailable) { throw new Error( - since === "workspace_open" - ? "The workspace-open review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"last_shown\" if that checkpoint is available." - : "The last-shown review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"workspace_open\" if that checkpoint is available.", + "The workspace-open review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"last_shown\" if that checkpoint is available.", ); } - const baselineRef = since === "workspace_open" ? state.openRef : state.baselineRef; + const baselineRef = effectiveSince === "workspace_open" ? state.openRef : state.baselineRef; const baseline = (await git(state.gitRoot, ["rev-parse", "--verify", `${baselineRef}^{commit}`])).stdout.trim(); const current = await createWorkingTreeSnapshot(state.gitRoot); const patch = (await git(state.gitRoot, ["diff", "--binary", "--no-color", baseline, current], { @@ -115,11 +118,15 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { state.baselineRefAvailable = true; } + const fallbackNote = usedWorkspaceOpenFallback + ? ` The last-shown checkpoint was missing, so changes were compared from workspace open${markReviewed ? " and the baseline was re-established" : ""}.` + : ""; return { - result: + result: `${ summary.files === 0 - ? `No changes since ${since === "workspace_open" ? "workspace open" : "last shown changes"}.` - : `Changed ${summary.files} ${summary.files === 1 ? "file" : "files"} (+${summary.additions} -${summary.removals}).`, + ? `No changes since ${effectiveSince === "workspace_open" ? "workspace open" : "last shown changes"}.` + : `Changed ${summary.files} ${summary.files === 1 ? "file" : "files"} (+${summary.additions} -${summary.removals}).` + }${fallbackNote}`, summary, files, patch, From 076c7fb98b86d7a733234cc0e9b2ce7d4d95793e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:17:55 +0530 Subject: [PATCH 24/24] test(workspace): align regressions with public seams --- .github/workflows/ci.yml | 4 +- package.json | 2 +- src/oauth-store.test.ts | 27 ++-- src/request-meta.test.ts | 33 ++-- src/review-checkpoints.test.ts | 196 ++++++++++++----------- src/ui/card-types.test.ts | 15 -- src/workspace-conversation.test.ts | 244 +++++++++++++++++++++++++++++ src/workspaces.test.ts | 212 +------------------------ 8 files changed, 393 insertions(+), 340 deletions(-) create mode 100644 src/workspace-conversation.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf3d7ea..ac9f895b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: pull_request: jobs: - smoke: - name: Smoke (${{ matrix.os }}) + verify: + name: Verify (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/package.json b/package.json index cb8f4618..f42be15d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 642708e9..26ee3608 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -7,6 +7,7 @@ import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/ import { databasePath, openDatabase } from "./db/client.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); const oauthConfig = { @@ -57,19 +58,23 @@ function testConversationBootstrapMigration(stateDir: string): void { initial.close(); } - const migrated = openDatabase(stateDir); + const migrated = new SqliteWorkspaceStore(stateDir); try { assert.deepEqual( - migrated.sqlite.prepare(` - select conversation_scope_id, project_key, created_at, last_used_at - from workspace_conversation_bootstraps - `).all(), - [{ - conversation_scope_id: "chat-existing", - project_key: "/tmp/project", - created_at: "2026-01-01T00:00:00.000Z", - last_used_at: "2026-01-02T00:00:00.000Z", - }], + { + existingProjectAlreadyClaimed: migrated.claimConversationBootstrap( + "chat-existing", + "/tmp/project", + ), + newProjectCanClaim: migrated.claimConversationBootstrap( + "chat-existing", + "/tmp/other-project", + ), + }, + { + existingProjectAlreadyClaimed: false, + newProjectCanClaim: true, + }, ); } finally { migrated.close(); diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index 5fffdabe..9f454129 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -1,16 +1,25 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { openAiConversationScopeId } from "./request-meta.js"; -assert.equal(openAiConversationScopeId(undefined), undefined); -assert.equal(openAiConversationScopeId({}), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); +test("OpenAI session metadata supplies the opaque conversation scope", () => { + assert.equal( + openAiConversationScopeId({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), + "chat-1", + ); +}); -assert.equal( - openAiConversationScopeId({ - "openai/session": "chat-1", - "openai/subject": "user-1", - "openai/organization": "org-1", - }), - "chat-1", -); +test("missing or empty OpenAI session metadata has no conversation scope", () => { + assert.deepEqual( + [ + openAiConversationScopeId(undefined), + openAiConversationScopeId({}), + openAiConversationScopeId({ "openai/session": "" }), + ], + [undefined, undefined, undefined], + ); +}); diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 20c0ae72..a9b699c0 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,160 +1,172 @@ import { execFile } from "node:child_process"; +import assert from "node:assert/strict"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import test, { type TestContext } from "node:test"; import { promisify } from "node:util"; -import assert from "node:assert/strict"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; const execFileAsync = promisify(execFile); -const root = await mkdtemp(join(tmpdir(), "devspace-review-checkpoints-test-")); -const unbornRoot = await mkdtemp(join(tmpdir(), "devspace-review-unborn-test-")); - -try { - await git(root, ["init"]); - await git(root, ["config", "user.email", "devspace@example.com"]); - await git(root, ["config", "user.name", "DevSpace Test"]); - await writeFile(join(root, "README.md"), "hello\n"); - await git(root, ["add", "README.md"]); - await git(root, ["commit", "-m", "Initial commit"]); +test("review changes reports edits and advances the last-shown checkpoint", async (t) => { + const root = await repository(t); const manager = createReviewCheckpointManager(); await manager.initializeWorkspace({ workspaceId: "ws_review", root }); - - const clean = await manager.reviewChanges({ workspaceId: "ws_review", root }); - assert.equal(clean.summary.files, 0); - assert.equal(clean.patch, ""); - assert.match(clean.result, /No changes/); + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root })).summary.files, 0); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); - - const firstReview = await manager.reviewChanges({ + const changed = await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(firstReview.summary.files, 2); - assert.equal(firstReview.summary.additions, 2); - assert.equal(firstReview.summary.removals, 0); - assert.equal(firstReview.files.some((file) => file.path === "README.md"), true); - assert.equal(firstReview.files.some((file) => file.path === "new.txt"), true); - assert.match(firstReview.patch, /world/); + assert.deepEqual(changed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); + assert.equal(changed.summary.additions, 2); - const restartedManager = createReviewCheckpointManager(); - await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); - const afterRestart = await restartedManager.reviewChanges({ + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root })).summary.files, 2); + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, - }); - assert.equal(afterRestart.summary.files, 2); - assert.match(afterRestart.patch, /world/); + })).summary.files, 0); +}); + +test("review checkpoints survive a manager restart", async (t) => { + const root = await repository(t); + const firstManager = createReviewCheckpointManager(); + await firstManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nworld\n"); - const sinceOpenAfterRestart = await restartedManager.reviewChanges({ + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const sinceLastShown = await restartedManager.reviewChanges({ workspaceId: "ws_review", root, - since: "workspace_open", markReviewed: false, }); - assert.equal(sinceOpenAfterRestart.summary.files, 2); - assert.match(sinceOpenAfterRestart.patch, /world/); - - const stillUnreviewed = await manager.reviewChanges({ + const sinceWorkspaceOpen = await restartedManager.reviewChanges({ workspaceId: "ws_review", root, - markReviewed: true, + since: "workspace_open", + markReviewed: false, }); - assert.equal(stillUnreviewed.summary.files, 2); - const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); - assert.equal(afterReviewed.summary.files, 0); + assert.equal(sinceLastShown.summary.files, 1); + assert.equal(sinceWorkspaceOpen.summary.files, 1); +}); - await writeFile(join(root, "README.md"), "hello\nworld\nlater\n"); +test("review waits for concurrent checkpoint initialization", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nlater\n"); - const concurrentManager = createReviewCheckpointManager(); - const [, concurrentReview] = await Promise.all([ - concurrentManager.initializeWorkspace({ workspaceId: "ws_review", root }), - concurrentManager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false }), + const manager = createReviewCheckpointManager(); + const [, review] = await Promise.all([ + manager.initializeWorkspace({ workspaceId: "ws_review", root }), + manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false }), ]); - assert.equal(concurrentReview.summary.files, 1); - assert.match(concurrentReview.patch, /later/); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); - const partiallyRestoredManager = createReviewCheckpointManager(); - await partiallyRestoredManager.initializeWorkspace({ workspaceId: "ws_review", root }); - const afterPartialRestore = await partiallyRestoredManager.reviewChanges({ + assert.equal(review.summary.files, 1); + assert.match(review.patch, /later/); +}); + +test("a missing last-shown checkpoint falls back and is re-established", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nlater\n"); + await deleteCheckpointRef(root, "ws_review", "baseline"); + + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + const fallback = await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(afterPartialRestore.summary.files, 2); - assert.match(afterPartialRestore.patch, /later/); - assert.match(afterPartialRestore.result, /compared from workspace open/); + assert.equal(fallback.summary.files, 1); + assert.match(fallback.result, /compared from workspace open/); - const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", - root, - markReviewed: true, - }); - assert.equal(reestablishedBaseline.summary.files, 2); - assert.match(reestablishedBaseline.result, /baseline was re-established/); - const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ + const reestablished = await manager.reviewChanges({ workspaceId: "ws_review", root }); + assert.match(reestablished.result, /baseline was re-established/); + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, - }); - assert.equal(afterBaselineReestablished.summary.files, 0); + })).summary.files, 0); +}); - const openMissingSetupManager = createReviewCheckpointManager(); - await openMissingSetupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - await writeFile(join(root, "open-missing.txt"), "still visible from baseline\n"); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_open_missing/open"]); +test("a missing workspace-open checkpoint preserves last-shown behavior", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "new.txt"), "still visible from baseline\n"); + await deleteCheckpointRef(root, "ws_review", "open"); - const openMissingManager = createReviewCheckpointManager(); - await openMissingManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - const afterOpenRefLoss = await openMissingManager.reviewChanges({ - workspaceId: "ws_open_missing", + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + const sinceLastShown = await manager.reviewChanges({ + workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(afterOpenRefLoss.summary.files, 1); - assert.match(afterOpenRefLoss.patch, /still visible from baseline/); + assert.equal(sinceLastShown.summary.files, 1); + assert.match(sinceLastShown.patch, /still visible from baseline/); await assert.rejects( - () => openMissingManager.reviewChanges({ - workspaceId: "ws_open_missing", + () => manager.reviewChanges({ + workspaceId: "ws_review", root, since: "workspace_open", markReviewed: false, }), /workspace-open review checkpoint is missing/, ); +}); - await git(unbornRoot, ["init"]); - await git(unbornRoot, ["config", "user.email", "devspace@example.com"]); - await git(unbornRoot, ["config", "user.name", "DevSpace Test"]); - - const unbornManager = createReviewCheckpointManager(); - await unbornManager.initializeWorkspace({ workspaceId: "ws_unborn", root: unbornRoot }); +test("an unborn repository becomes reviewable after its first commit", async (t) => { + const root = await repository(t, false); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); await assert.rejects( - () => unbornManager.reviewChanges({ workspaceId: "ws_unborn", root: unbornRoot }), + () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), /commit|HEAD|Git/i, ); - await writeFile(join(unbornRoot, "README.md"), "first commit\n"); - await git(unbornRoot, ["add", "README.md"]); - await git(unbornRoot, ["commit", "-m", "Initial commit"]); - - const afterFirstCommit = await unbornManager.reviewChanges({ + await writeFile(join(root, "README.md"), "first commit\n"); + await git(root, ["add", "README.md"]); + await git(root, ["commit", "-m", "Initial commit"]); + const afterFirstCommit = await manager.reviewChanges({ workspaceId: "ws_unborn", - root: unbornRoot, + root, markReviewed: false, }); assert.equal(afterFirstCommit.summary.files, 0); assert.equal(afterFirstCommit.patch, ""); -} finally { - await rm(root, { recursive: true, force: true }); - await rm(unbornRoot, { recursive: true, force: true }); +}); + +async function repository(t: TestContext, initialCommit = true): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-review-checkpoints-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + if (initialCommit) { + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["add", "README.md"]); + await git(root, ["commit", "-m", "Initial commit"]); + } + return root; +} + +async function deleteCheckpointRef( + root: string, + workspaceId: string, + checkpoint: "open" | "baseline", +): Promise { + await git(root, ["update-ref", "-d", `refs/devspace/review/${workspaceId}/${checkpoint}`]); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 4e0f2263..eb47e9a0 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -23,18 +23,3 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); - -assert.equal( - isExpandableCard({ - tool: "open_workspace", - agentProviders: [{ name: "codex", available: true }], - }), - true, -); -assert.equal( - isExpandableCard({ - tool: "open_workspace", - agents: [{ name: "reviewer", provider: "codex" }], - }), - true, -); diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts new file mode 100644 index 00000000..3e42171c --- /dev/null +++ b/src/workspace-conversation.test.ts @@ -0,0 +1,244 @@ +import { execFile } from "node:child_process"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { promisify } from "node:util"; +import { loadConfig, type ServerConfig } from "./config.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +const execFileAsync = promisify(execFile); + +test("a conversation reuses its checkout and receives bootstrap once", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(second.workspace.id, first.workspace.id); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, true); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, false); +}); + +test("different conversations receive different checkout workspaces", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(project, { conversationScopeId: "chat-2" }); + + assert.notEqual(second.workspace.id, first.workspace.id); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, true); +}); + +test("worktree requests stay fresh without replacing the reusable checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const firstWorktree = await registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + const secondWorktree = await registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); + assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); + assert.equal(firstWorktree.workspaceReused, false); + assert.equal(secondWorktree.workspaceReused, false); + assert.equal(firstWorktree.includeBootstrapContext, false); + assert.equal(secondWorktree.includeBootstrapContext, false); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); +}); + +test("concurrent worktree opens deliver project bootstrap once", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const opens = await Promise.all([ + registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + ]); + + assert.notEqual(opens[0]?.workspace.id, opens[1]?.workspace.id); + assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); +}); + +test("checkout reuse survives a registry restart", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const restored = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.equal(restored.workspace.id, first.workspace.id); + assert.equal(restored.workspaceReused, true); + assert.equal(restored.includeBootstrapContext, false); +}); + +test("context failures neither consume bootstrap nor discard a checkout binding", async (t) => { + const { project, registry } = await fixture(t); + const agentsDir = join(project, ".devspace", "agents"); + const backupDir = join(project, ".devspace", "agents-backup"); + + await breakAgentsDirectory(agentsDir, backupDir); + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + await restoreAgentsDirectory(agentsDir, backupDir); + + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(first.includeBootstrapContext, true); + + await breakAgentsDirectory(agentsDir, backupDir); + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + await restoreAgentsDirectory(agentsDir, backupDir); + + const recovered = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(recovered.workspace.id, first.workspace.id); + assert.equal(recovered.workspaceReused, true); + assert.equal(recovered.includeBootstrapContext, false); +}); + +test("a deleted checkout is replaced without repeating project bootstrap", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + await rm(project, { recursive: true, force: true }); + const replacement = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); + assert.equal((await stat(project)).isDirectory(), true); +}); + +test("canonical checkout identity survives symlink aliases and a missing target", { skip: platform() === "win32" }, async (t) => { + const { root, project, registry } = await fixture(t); + const alias = join(root, "project-alias"); + const target = join(project, "temporary-checkout"); + const aliasedTarget = join(alias, "temporary-checkout"); + await symlink(project, alias, "dir"); + await mkdir(target); + + const direct = await registry.openWorkspace(target, { conversationScopeId: "chat-1" }); + const aliased = await registry.openWorkspace(aliasedTarget, { conversationScopeId: "chat-1" }); + assert.equal(aliased.workspace.id, direct.workspace.id); + + await rm(target, { recursive: true, force: true }); + const replacement = await registry.openWorkspace(aliasedTarget, { + conversationScopeId: "chat-1", + }); + assert.notEqual(replacement.workspace.id, direct.workspace.id); + assert.equal(replacement.includeBootstrapContext, false); +}); + +interface WorkspaceFixture { + root: string; + project: string; + stateDir: string; + config: ServerConfig; + store: SqliteWorkspaceStore; + registry: WorkspaceRegistry; + openStore: () => SqliteWorkspaceStore; + closeStore: (store: SqliteWorkspaceStore) => void; +} + +async function fixture( + t: TestContext, + options: { git?: boolean } = {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-conversation-test-")); + const project = join(root, "project"); + const agentDir = join(root, "agent"); + const stateDir = join(root, ".state"); + const stores = new Set(); + await mkdir(join(project, ".devspace", "agents"), { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + await writeFile(join(project, "AGENTS.md"), "project instructions\n"); + await writeFile(join(project, ".devspace", "agents", "reviewer.md"), [ + "---", + "name: reviewer", + "description: Reviews project changes.", + "provider: codex", + "---", + "Review changes.", + ].join("\n")); + + if (options.git) await initializeGitRepository(project); + + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const openStore = () => { + const store = new SqliteWorkspaceStore(stateDir); + stores.add(store); + return store; + }; + const closeStore = (store: SqliteWorkspaceStore) => { + if (stores.delete(store)) store.close(); + }; + const store = openStore(); + t.after(async () => { + for (const openStore of stores) openStore.close(); + await rm(root, { recursive: true, force: true }); + }); + + return { + root, + project, + stateDir, + config, + store, + registry: new WorkspaceRegistry(config, store), + openStore, + closeStore, + }; +} + +async function breakAgentsDirectory(agentsDir: string, backupDir: string): Promise { + await rename(agentsDir, backupDir); + await writeFile(agentsDir, "not a directory\n"); +} + +async function restoreAgentsDirectory(agentsDir: string, backupDir: string): Promise { + await rm(agentsDir, { force: true }); + await rename(backupDir, agentsDir); +} + +async function initializeGitRepository(root: string): Promise { + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "Initial commit"]); +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 76e17508..52e655b6 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -59,7 +59,6 @@ try { agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); - assert.deepEqual( availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], @@ -160,152 +159,11 @@ try { const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); - const persistentWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeId: "chat-checkout", - }); - const reusedPersistentWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeId: "chat-checkout", - }); - assert.equal(persistentWorkspace.includeBootstrapContext, true); - assert.equal(persistentWorkspace.workspaceReused, false); - assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); - assert.equal(reusedPersistentWorkspace.workspaceReused, true); - assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); - assert.deepEqual( - reusedPersistentWorkspace.agentsFiles.map((file) => file.content), - persistentWorkspace.agentsFiles.map((file) => file.content), - ); - assert.deepEqual( - reusedPersistentWorkspace.availableAgentsFiles, - persistentWorkspace.availableAgentsFiles, - ); - - const checkoutTargetKey = JSON.stringify(["checkout", await realpath(root), null]); - firstStore.setConversationBinding({ - conversationScopeId: "chat-context-failure", - targetKey: checkoutTargetKey, - workspaceSessionId: persistentWorkspace.workspace.id, - }); - - const projectAgentsDir = join(root, ".devspace", "agents"); - const projectAgentsBackup = join(root, ".devspace", "agents-backup"); - await rename(projectAgentsDir, projectAgentsBackup); - await writeFile(projectAgentsDir, "not a directory\n"); - try { - await assert.rejects( - () => persistentRegistry.openWorkspace(root, { conversationScopeId: "chat-context-failure" }), - /directory|ENOTDIR/i, - ); - assert.equal( - firstStore.getConversationBinding( - "chat-context-failure", - checkoutTargetKey, - )?.workspaceSessionId, - persistentWorkspace.workspace.id, - ); - } finally { - await rm(projectAgentsDir, { force: true }); - await rename(projectAgentsBackup, projectAgentsDir); - } - - const recoveredContextWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeId: "chat-context-failure", - }); - assert.equal(recoveredContextWorkspace.workspace.id, persistentWorkspace.workspace.id); - assert.equal(recoveredContextWorkspace.workspaceReused, true); - assert.equal(recoveredContextWorkspace.includeBootstrapContext, true); - - const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { - conversationScopeId: "chat-checkout-other", - }); - assert.equal(otherConversationWorkspace.includeBootstrapContext, true); - assert.equal(otherConversationWorkspace.workspaceReused, false); - assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); - - const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); - await mkdir(staleWorkspaceRoot); - const staleWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { - conversationScopeId: "chat-stale", - }); - await rm(staleWorkspaceRoot, { recursive: true, force: true }); - const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { - conversationScopeId: "chat-stale", - }); - assert.equal(replacementWorkspace.includeBootstrapContext, false); - assert.equal(replacementWorkspace.workspaceReused, false); - assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); - assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); - - const worktreeInput = { path: gitRoot, mode: "worktree" as const }; - const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeId: "chat-project-modes", - }); - const firstProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-project-modes", - }); - const secondProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-project-modes", - }); - const reusedProjectCheckout = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeId: "chat-project-modes", - }); - assert.equal(projectCheckout.includeBootstrapContext, true); - assert.equal(projectCheckout.workspaceReused, false); - assert.equal(firstProjectWorktree.includeBootstrapContext, false); - assert.equal(firstProjectWorktree.workspaceReused, false); - assert.equal(secondProjectWorktree.includeBootstrapContext, false); - assert.equal(secondProjectWorktree.workspaceReused, false); - assert.notEqual(firstProjectWorktree.workspace.id, projectCheckout.workspace.id); - assert.notEqual(firstProjectWorktree.workspace.id, secondProjectWorktree.workspace.id); - assert.notEqual(firstProjectWorktree.workspace.root, secondProjectWorktree.workspace.root); - assert.equal(reusedProjectCheckout.workspace.id, projectCheckout.workspace.id); - assert.equal(reusedProjectCheckout.workspaceReused, true); - assert.equal(reusedProjectCheckout.includeBootstrapContext, false); - - const worktreeFirst = await persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-worktree-first", - }); - const checkoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeId: "chat-worktree-first", - }); - const reusedCheckoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { - conversationScopeId: "chat-worktree-first", + const persistentWorkspace = await persistentRegistry.openWorkspace(root); + const persistentWorktree = await persistentRegistry.openWorkspace({ + path: gitRoot, + mode: "worktree", }); - assert.equal(worktreeFirst.includeBootstrapContext, true); - assert.equal(worktreeFirst.workspaceReused, false); - assert.equal(checkoutAfterWorktree.includeBootstrapContext, false); - assert.equal(checkoutAfterWorktree.workspaceReused, false); - assert.equal(checkoutAfterWorktree.workspace.mode, "checkout"); - assert.notEqual(checkoutAfterWorktree.workspace.id, worktreeFirst.workspace.id); - assert.equal(reusedCheckoutAfterWorktree.includeBootstrapContext, false); - assert.equal(reusedCheckoutAfterWorktree.workspaceReused, true); - assert.equal(reusedCheckoutAfterWorktree.workspace.id, checkoutAfterWorktree.workspace.id); - - const [persistentWorktree, concurrentWorktree] = await Promise.all([ - persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-worktree-concurrent", - }), - persistentRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-worktree-concurrent", - }), - ]); - assert.notEqual(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); - assert.notEqual(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); - assert.equal(persistentWorktree.workspaceReused, false); - assert.equal(concurrentWorktree.workspaceReused, false); - const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; - assert.equal( - concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, - 1, - ); - assert.deepEqual( - concurrentWorktree.agentsFiles.map((file) => file.content), - persistentWorktree.agentsFiles.map((file) => file.content), - ); - assert.deepEqual( - concurrentWorktree.availableAgentsFiles.map((file) => file.path.replace(concurrentWorktree.workspace.root, "")), - persistentWorktree.availableAgentsFiles.map((file) => file.path.replace(persistentWorktree.workspace.root, "")), - ); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -314,77 +172,17 @@ try { assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); - const reboundWorkspace = await restoredRegistry.openWorkspace(root, { - conversationScopeId: "chat-checkout", - }); - assert.equal(reboundWorkspace.includeBootstrapContext, false); - assert.equal(reboundWorkspace.workspaceReused, true); - assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); - assert.deepEqual( - reboundWorkspace.agentsFiles.map((file) => file.content), - persistentWorkspace.agentsFiles.map((file) => file.content), - ); - assert.deepEqual(reboundWorkspace.availableAgentsFiles, persistentWorkspace.availableAgentsFiles); - assert.deepEqual( - reboundWorkspace.workspace.agentProfiles.map((profile) => profile.name), - persistentWorkspace.workspace.agentProfiles.map((profile) => profile.name), - ); - const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); assert.equal(restoredWorktree.sourceRoot, gitRoot); assert.equal(restoredWorktree.root, persistentWorktree.workspace.root); assert.equal(restoredWorktree.worktree?.managed, true); - - const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-worktree-concurrent", - }); - assert.equal(reboundWorktree.includeBootstrapContext, false); - assert.equal(reboundWorktree.workspaceReused, false); - assert.notEqual(reboundWorktree.workspace.id, persistentWorktree.workspace.id); - assert.notEqual(reboundWorktree.workspace.root, persistentWorktree.workspace.root); - assert.deepEqual( - reboundWorktree.agentsFiles.map((file) => file.content), - persistentWorktree.agentsFiles.map((file) => file.content), - ); secondStore.close(); if (platform() !== "win32") { const aliasRoot = join(root, "alias-root"); await symlink(root, aliasRoot, "dir"); - const aliasStateDir = join(root, ".alias-state"); - const aliasStore = new SqliteWorkspaceStore(aliasStateDir); - const aliasRegistry = new WorkspaceRegistry(config, aliasStore); - const directConversationWorkspace = await aliasRegistry.openWorkspace(root, { - conversationScopeId: "chat-alias", - }); - const aliasedConversationWorkspace = await aliasRegistry.openWorkspace(aliasRoot, { - conversationScopeId: "chat-alias", - }); - assert.equal(aliasedConversationWorkspace.includeBootstrapContext, false); - assert.equal( - aliasedConversationWorkspace.workspace.id, - directConversationWorkspace.workspace.id, - ); - - const aliasedStaleRoot = join(aliasRoot, "stale-alias-workspace"); - await mkdir(aliasedStaleRoot); - const aliasedStaleWorkspace = await aliasRegistry.openWorkspace(aliasedStaleRoot, { - conversationScopeId: "chat-alias-stale", - }); - await rm(aliasedStaleRoot, { recursive: true, force: true }); - const aliasedReplacementWorkspace = await aliasRegistry.openWorkspace(aliasedStaleRoot, { - conversationScopeId: "chat-alias-stale", - }); - assert.equal(aliasedReplacementWorkspace.includeBootstrapContext, false); - assert.equal(aliasedReplacementWorkspace.workspaceReused, false); - assert.notEqual( - aliasedReplacementWorkspace.workspace.id, - aliasedStaleWorkspace.workspace.id, - ); - aliasStore.close(); - const aliasConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: aliasRoot, DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"),