From 26472a424e415ef887fc1acb5cdea4b3a09682fd Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 13:38:24 +0530 Subject: [PATCH 01/59] 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 e027d46686d338c377bbd911a121412e4514c7e5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 15:49:55 +0530 Subject: [PATCH 02/59] 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 5c399877c54adf260bb5dd85a088c82a92d81f3f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:22:56 +0530 Subject: [PATCH 03/59] 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 5b2e56ef5664d7b335bb204c5ce812512b745c42 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:25:06 +0530 Subject: [PATCH 04/59] 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 44c94cad8ed790b4fcf3857bd63946e157da960c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:26:15 +0530 Subject: [PATCH 05/59] 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 a87e40c5a2ccd43b66ec3e79ccd1fb23e70886e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 16:34:35 +0530 Subject: [PATCH 06/59] 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 9892c4a68c0c6b6f7a6d2b8fa08101245aace664 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:41:39 +0530 Subject: [PATCH 07/59] 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 969d5bc74af2eee762186506957a2234ce6981a8 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:45:14 +0530 Subject: [PATCH 08/59] 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 826c0b177eb3e61ac4ca8d8cf5d39e1ea6ddf925 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 31 Jul 2026 19:48:17 +0530 Subject: [PATCH 09/59] 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 e1e2d48c66897623b561e99234d2f1e755562a98 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:47:29 +0530 Subject: [PATCH 10/59] 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 b1ee6a8bdd50fc6ec6b10f221130369756b83b75 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:48:52 +0530 Subject: [PATCH 11/59] 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 733a4cf2a23ed8687ebf74577f7641ed0df985fb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:50:04 +0530 Subject: [PATCH 12/59] 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 73566a82648e6e3dd6a00c90b7c2a1b5122d279b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:50:58 +0530 Subject: [PATCH 13/59] 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 7a13ba01bff58381c71dd78bb9998132e55a4527 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:51:52 +0530 Subject: [PATCH 14/59] docs(workspace): remove unsupported reopen guidance --- docs/chatgpt-coding-workflow.md | 4 ++-- src/server.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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 3a9b60ed275847bbefda45cde23cddb53b284cec Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:52:18 +0530 Subject: [PATCH 15/59] 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 08fabf7292b0cb465f3bced83e31f0b837073141 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 14:54:39 +0530 Subject: [PATCH 16/59] 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 cf68ee6f5a93db0b04a6bf27f030530e1197e631 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:02:17 +0530 Subject: [PATCH 17/59] 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 5540eefd4c8998cc562677f9cdb4499c4d9e40d3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:08:07 +0530 Subject: [PATCH 18/59] 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 12e57cf8854ad19118b617c22844ad0877b0c9ed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:08:57 +0530 Subject: [PATCH 19/59] 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 1d132fe70f15e0e944f5fd4e846533fd5356478c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:09:15 +0530 Subject: [PATCH 20/59] 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 7cea9823ee1d5eae0e5658c37c5075e63c4300fc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:09:54 +0530 Subject: [PATCH 21/59] 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 603feb6c4eb03f7b9e4a11563f39f4463fe32ef9 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:13:45 +0530 Subject: [PATCH 22/59] 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 2557bc3a69c08e9825f82cd4476e6b8db6154545 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 15:20:20 +0530 Subject: [PATCH 23/59] 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 b926238d43339bcf7faa4de00132dbb289e1b402 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:24:50 +0530 Subject: [PATCH 24/59] fix(review): recover missing checkpoint history safely --- src/review-checkpoints.test.ts | 21 ++++++++++++++++ src/review-checkpoints.ts | 46 +++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 20c0ae72..ecbd835c 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -106,6 +106,27 @@ try { }); assert.equal(afterBaselineReestablished.summary.files, 0); + const inProcessPartialManager = createReviewCheckpointManager(); + await inProcessPartialManager.initializeWorkspace({ workspaceId: "ws_in_process_partial", root }); + await writeFile(join(root, "in-process-partial.txt"), "visible after ref loss\n"); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_in_process_partial/baseline"]); + const inProcessPartialReview = await inProcessPartialManager.reviewChanges({ + workspaceId: "ws_in_process_partial", + root, + markReviewed: false, + }); + assert.equal(inProcessPartialReview.summary.files, 1); + assert.match(inProcessPartialReview.result, /compared from workspace open/); + + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/open"]); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); + const bothMissingManager = createReviewCheckpointManager(); + await bothMissingManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await assert.rejects( + () => bothMissingManager.reviewChanges({ workspaceId: "ws_review", root }), + /Review checkpoints are missing|cannot reconstruct that history safely/, + ); + 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 f718d96a..d3311232 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -31,8 +31,10 @@ interface WorkspaceReviewState { gitRoot?: string; openRef: string; baselineRef: string; + historyRef: string; openRefAvailable: boolean; baselineRefAvailable: boolean; + historyEstablished: boolean; diagnostic?: string; } @@ -87,9 +89,11 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); } + await refreshCheckpointAvailability(state); + let effectiveSince = since; let usedWorkspaceOpenFallback = false; - if (since === "last_shown" && !state.baselineRefAvailable) { + if ((since === "last_shown" || since === "last_review") && !state.baselineRefAvailable) { if (!state.openRefAvailable) { throw new Error("Review checkpoints are missing; show_changes cannot reconstruct that history safely."); } @@ -146,6 +150,7 @@ async function initializeWorkspaceState( ...refs, openRefAvailable: false, baselineRefAvailable: false, + historyEstablished: false, }; try { @@ -155,20 +160,37 @@ async function initializeWorkspaceState( return; } - const [openCommit, baselineCommit] = await Promise.all([ + const [openCommit, baselineCommit, historyCommit] = await Promise.all([ commitForRef(eligibility.gitRoot, state.openRef), commitForRef(eligibility.gitRoot, state.baselineRef), + commitForRef(eligibility.gitRoot, state.historyRef), ]); if (!openCommit && !baselineCommit) { + if (historyCommit) { + state.gitRoot = eligibility.gitRoot; + state.historyEstablished = true; + state.diagnostic = "Review checkpoints are missing; show_changes cannot reconstruct that history safely."; + return; + } + const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot); await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); + await git(eligibility.gitRoot, ["update-ref", state.historyRef, initialCommit]); state.openRefAvailable = true; state.baselineRefAvailable = true; + state.historyEstablished = true; } else { state.openRefAvailable = openCommit !== undefined; state.baselineRefAvailable = baselineCommit !== undefined; + state.historyEstablished = true; + if (!historyCommit) { + const historyCommit = openCommit ?? baselineCommit; + if (historyCommit) { + await git(eligibility.gitRoot, ["update-ref", state.historyRef, historyCommit]); + } + } } state.gitRoot = eligibility.gitRoot; @@ -191,11 +213,29 @@ async function commitForRef(gitRoot: string, ref: string): Promise { +async function refreshCheckpointAvailability(state: WorkspaceReviewState): Promise { + const gitRoot = state.gitRoot; + if (!gitRoot) return; + + const [openCommit, baselineCommit] = await Promise.all([ + commitForRef(gitRoot, state.openRef), + commitForRef(gitRoot, state.baselineRef), + ]); + state.openRefAvailable = openCommit !== undefined; + state.baselineRefAvailable = baselineCommit !== undefined; + state.diagnostic = state.historyEstablished && !openCommit && !baselineCommit + ? "Review checkpoints are missing; show_changes cannot reconstruct that history safely." + : undefined; +} + +function reviewRefs( + workspaceId: string, +): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { openRef: `${REVIEW_REF_PREFIX}/${segment}/open`, baselineRef: `${REVIEW_REF_PREFIX}/${segment}/baseline`, + historyRef: `${REVIEW_REF_PREFIX}/${segment}/history`, }; } From ac7d32eb324b264378a5e514ee994460bd7ff109 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:24:57 +0530 Subject: [PATCH 25/59] fix(request): validate conversation scope metadata --- src/request-meta.test.ts | 4 ++++ src/request-meta.ts | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index 5fffdabe..b5949a2c 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -4,6 +4,10 @@ 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": 42 }), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); +assert.equal(openAiConversationScopeId(null), undefined); +assert.equal(openAiConversationScopeId(42), undefined); assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); assert.equal( diff --git a/src/request-meta.ts b/src/request-meta.ts index 40662373..5b8cb1ea 100644 --- a/src/request-meta.ts +++ b/src/request-meta.ts @@ -1,13 +1,14 @@ function metadataString( - meta: Record | undefined, + meta: unknown, key: string, ): string | undefined { - const value = meta?.[key]; + if (typeof meta !== "object" || meta === null) return undefined; + const value = (meta as Record)[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } export function openAiConversationScopeId( - meta: Record | undefined, + meta: unknown, ): string | undefined { return metadataString(meta, "openai/session"); } From 35e1d8ccae9b148bbc6074e1d0f95272b2f5225e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:25:04 +0530 Subject: [PATCH 26/59] fix(workspace): isolate stale binding recovery --- src/workspace-store.ts | 48 ++++++++++++++++++++++-------------------- src/workspaces.test.ts | 17 +++++++++++++++ src/workspaces.ts | 47 ++++++++++++++++++++++++++++++----------- 3 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/workspace-store.ts b/src/workspace-store.ts index f7375701..6fa773ea 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -205,31 +205,33 @@ export class SqliteWorkspaceStore implements WorkspaceStore { claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean { const now = new Date().toISOString(); - const [inserted] = this.database.db - .insert(workspaceConversationBootstraps) - .values({ - conversationScopeId, - projectKey, - createdAt: now, - lastUsedAt: now, - }) - .onConflictDoNothing() - .returning() - .all(); + return this.database.db.transaction((transaction) => { + const [inserted] = transaction + .insert(workspaceConversationBootstraps) + .values({ + conversationScopeId, + projectKey, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoNothing() + .returning() + .all(); - if (inserted) return true; + if (inserted) return true; - this.database.db - .update(workspaceConversationBootstraps) - .set({ lastUsedAt: now }) - .where( - and( - eq(workspaceConversationBootstraps.conversationScopeId, conversationScopeId), - eq(workspaceConversationBootstraps.projectKey, projectKey), - ), - ) - .run(); - return false; + transaction + .update(workspaceConversationBootstraps) + .set({ lastUsedAt: now }) + .where( + and( + eq(workspaceConversationBootstraps.conversationScopeId, conversationScopeId), + eq(workspaceConversationBootstraps.projectKey, projectKey), + ), + ) + .run(); + return false; + }); } close(): void { diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 76e17508..c0300018 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -236,6 +236,23 @@ try { assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); + const missingTarget = join(root, "missing-canonical-target", "project"); + const missingTargetWorkspace = await persistentRegistry.openWorkspace(missingTarget, { + conversationScopeId: "chat-missing-canonical", + }); + assert.equal( + firstStore.getConversationBinding( + "chat-missing-canonical", + JSON.stringify(["checkout", await realpath(missingTarget), null]), + )?.workspaceSessionId, + missingTargetWorkspace.workspace.id, + ); + const missingTargetAgain = await persistentRegistry.openWorkspace(missingTarget, { + conversationScopeId: "chat-missing-canonical", + }); + assert.equal(missingTargetAgain.workspace.id, missingTargetWorkspace.workspace.id); + assert.equal(missingTargetAgain.workspaceReused, true); + const worktreeInput = { path: gitRoot, mode: "worktree" as const }; const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { conversationScopeId: "chat-project-modes", diff --git a/src/workspaces.ts b/src/workspaces.ts index 939fbf0c..d2e7842e 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -1,6 +1,10 @@ import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; -import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; +import type { + WorkspaceConversationBinding, + WorkspaceMode, + WorkspaceStore, +} from "./workspace-store.js"; import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; @@ -158,16 +162,7 @@ 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()) { - reusableWorkspace = workspace; - } - } catch { - // The persisted workspace is no longer usable; replace its binding below. - } + const reusableWorkspace = await this.findReusableCheckoutWorkspace(binding); if (reusableWorkspace) { this.store?.touchConversationBinding(conversationScopeId, targetKey); @@ -196,6 +191,30 @@ export class WorkspaceRegistry { }; } + private async findReusableCheckoutWorkspace( + binding: WorkspaceConversationBinding, + ): Promise { + const session = this.store?.getSession(binding.workspaceSessionId); + if (!session || session.status !== "active" || session.mode !== "checkout") { + return undefined; + } + + let root: string; + try { + root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); + const rootStats = await stat(root); + if (!rootStats.isDirectory()) return undefined; + } catch { + // Path containment and filesystem checks are binding validation. Context + // discovery happens below, outside this recovery boundary. + return undefined; + } + + const workspace = this.getWorkspace(binding.workspaceSessionId); + if (workspace.mode !== "checkout" || workspace.root !== root) return undefined; + return workspace; + } + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { const path = assertAllowedPath(input.path, this.config.allowedRoots); return canonicalPath(path); @@ -443,7 +462,11 @@ async function canonicalPath(path: string): Promise { while (true) { try { return resolve(await realpath(candidate), ...missingSegments.reverse()); - } catch { + } catch (error) { + if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) { + throw error; + } + const parent = dirname(candidate); if (parent === candidate) return path; missingSegments.push(basename(candidate)); From c8e1f6778f789931d3dc483495dab7ab2473fe25 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:25:12 +0530 Subject: [PATCH 27/59] fix(server): expose workspace lifecycle state --- docs/gotchas.md | 6 ++++-- src/server.ts | 18 ++++++++++++++---- src/ui/card-types.ts | 2 ++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/gotchas.md b/docs/gotchas.md index 779e37ef..cff72411 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -136,8 +136,10 @@ npx @waishnav/devspace init --force client receives an unknown workspace error, call `open_workspace` again for that project. -Workspace session metadata is persisted, but clients should still treat -`open_workspace` as the way to begin a fresh working session. +Workspace session metadata is persisted. In a ChatGPT conversation, calling +`open_workspace` again for the same checkout project can return the existing +conversation-scoped workspace; worktree mode always creates a new isolated +workspace. ## Workspace Path Rejected diff --git a/src/server.ts b/src/server.ts index 3fb6e9bd..d17c42a2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -197,7 +197,7 @@ function serverInstructions(config: ServerConfig): string { : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Checkout mode can reuse the conversation-scoped workspace; each worktree-mode open creates a new isolated worktree. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -774,6 +774,8 @@ function createMcpServer( workspaceId: z.string(), root: z.string(), mode: z.enum(["checkout", "worktree"]), + workspaceReused: z.boolean(), + includeBootstrapContext: z.boolean(), sourceRoot: z.string().optional(), worktree: z .object({ @@ -901,6 +903,8 @@ function createMcpServer( root: workspace.root, path: workspace.root, mode: workspace.mode, + workspaceReused, + includeBootstrapContext, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, agentsFiles: cardAgentsFiles, @@ -925,6 +929,8 @@ function createMcpServer( workspaceId: workspace.id, root: workspace.root, mode: workspace.mode, + workspaceReused, + includeBootstrapContext, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, ...(includeBootstrapContext @@ -1288,23 +1294,27 @@ function createMcpServer( { title: "Show changes", description: - "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs.", + "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs. By default, compare from the last shown checkpoint; pass since=\"workspace_open\" only when an explicit comparison from workspace open is required.", inputSchema: { workspaceId: z .string() .describe("Workspace identifier returned by open_workspace."), + since: z + .enum(["last_shown", "last_review", "workspace_open"]) + .optional() + .describe("Checkpoint to compare from. Defaults to last_shown."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "show_changes"), annotations: { readOnlyHint: true }, }, - async ({ workspaceId }) => { + async ({ workspaceId, since }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const review = await reviewCheckpoints.reviewChanges({ workspaceId, root: workspace.root, - since: "last_shown", + since, markReviewed: true, }); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index cb3ab0fa..596e2e84 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,8 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + workspaceReused?: boolean; + includeBootstrapContext?: boolean; mode?: "checkout" | "worktree"; sourceRoot?: string; worktree?: { From 32ddfa8b493578a1726001beb2e62e6d992f7b68 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 16:25:16 +0530 Subject: [PATCH 28/59] test(db): cover deterministic bootstrap backfill --- src/oauth-store.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 642708e9..f1317656 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -47,7 +47,18 @@ function testConversationBootstrapMigration(stateDir: string): void { JSON.stringify(["worktree", "/tmp/project", "HEAD"]), "ws_existing", "2026-01-01T00:00:00.000Z", - "2026-01-02T00:00:00.000Z", + "2026-01-04T00:00:00.000Z", + ); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["checkout", "/tmp/project", null]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-03T00:00:00.000Z", ); initial.sqlite.exec(` drop table workspace_conversation_bootstraps; @@ -68,7 +79,7 @@ function testConversationBootstrapMigration(stateDir: string): void { 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", + last_used_at: "2026-01-03T00:00:00.000Z", }], ); } finally { From 5ef1e478d476fb8e5665efa49410b6778565d924 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:15:46 +0530 Subject: [PATCH 29/59] fix(review): remove unsupported last-review selector --- src/review-checkpoints.ts | 4 ++-- src/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index d3311232..b6527ff4 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js"; -export type ReviewSince = "last_shown" | "last_review" | "workspace_open"; +export type ReviewSince = "last_shown" | "workspace_open"; export interface ReviewSummary { files: number; @@ -93,7 +93,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { let effectiveSince = since; let usedWorkspaceOpenFallback = false; - if ((since === "last_shown" || since === "last_review") && !state.baselineRefAvailable) { + if (since === "last_shown" && !state.baselineRefAvailable) { if (!state.openRefAvailable) { throw new Error("Review checkpoints are missing; show_changes cannot reconstruct that history safely."); } diff --git a/src/server.ts b/src/server.ts index d17c42a2..8a683bf8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1300,7 +1300,7 @@ function createMcpServer( .string() .describe("Workspace identifier returned by open_workspace."), since: z - .enum(["last_shown", "last_review", "workspace_open"]) + .enum(["last_shown", "workspace_open"]) .optional() .describe("Checkpoint to compare from. Defaults to last_shown."), }, From 8690d9d57ae7b247c6a2c3cf8f25a34a684ec24b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:15:49 +0530 Subject: [PATCH 30/59] docs(workflow): clarify bootstrap delivery --- docs/chatgpt-coding-workflow.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 0279a083..e228a814 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -25,13 +25,15 @@ 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. +successful open, after project context discovery completes, for a canonical +project path in a ChatGPT conversation returns project instructions and +diagnostics, plus skills when `DEVSPACE_SKILLS` is enabled and subagent metadata +when `DEVSPACE_SUBAGENTS=1`. 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 call `open_workspace` again for the same checkout folder unless: From 3c8a39cb33e3973119865128c203c8889725ef9e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:30:43 +0530 Subject: [PATCH 31/59] test(workspace): isolate conversation lifecycle coverage --- package.json | 2 +- src/workspace-conversation.test.ts | 348 +++++++++++++++++++++++++++++ src/workspaces.test.ts | 230 +------------------ 3 files changed, 354 insertions(+), 226 deletions(-) create mode 100644 src/workspace-conversation.test.ts 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/workspace-conversation.test.ts b/src/workspace-conversation.test.ts new file mode 100644 index 00000000..04f3cbb2 --- /dev/null +++ b/src/workspace-conversation.test.ts @@ -0,0 +1,348 @@ +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 { openDatabase } from "./db/client.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(first.workspaceReused, false); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.workspaceReused, true); + assert.equal(second.includeBootstrapContext, false); + assert.equal(second.workspace.id, first.workspace.id); + assert.deepEqual(second.agentsFiles, first.agentsFiles); + assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); + assert.deepEqual( + second.workspace.agentProfiles.map((profile) => profile.name), + first.workspace.agentProfiles.map((profile) => profile.name), + ); +}); + +test("different conversations receive separate 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); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); +}); + +test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const firstWorktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const secondWorktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(checkout.includeBootstrapContext, true); + assert.equal(firstWorktree.includeBootstrapContext, false); + assert.equal(secondWorktree.includeBootstrapContext, false); + assert.equal(firstWorktree.workspaceReused, false); + assert.equal(secondWorktree.workspaceReused, false); + assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); + assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); + assert.equal(checkoutAgain.workspaceReused, true); + assert.equal(checkoutAgain.includeBootstrapContext, false); +}); + +test("a worktree-first conversation creates and then reuses its checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const worktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(worktree.includeBootstrapContext, true); + assert.equal(worktree.workspaceReused, false); + assert.equal(checkout.includeBootstrapContext, false); + assert.equal(checkout.workspaceReused, false); + assert.equal(checkout.workspace.mode, "checkout"); + assert.notEqual(checkout.workspace.id, worktree.workspace.id); + assert.equal(checkoutAgain.includeBootstrapContext, false); + assert.equal(checkoutAgain.workspaceReused, true); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); +}); + +test("concurrent worktree opens claim bootstrap exactly once and return complete context", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const [first, second] = await Promise.all([ + registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), + registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), + ]); + + assert.equal([first, second].filter((open) => open.includeBootstrapContext).length, 1); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); + assert.notEqual(first.workspace.id, second.workspace.id); + assert.notEqual(first.workspace.root, second.workspace.root); + assert.deepEqual( + first.agentsFiles.map((file) => file.content), + second.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + first.availableAgentsFiles.map((file) => file.path.replace(first.workspace.root, "")), + second.availableAgentsFiles.map((file) => file.path.replace(second.workspace.root, "")), + ); +}); + +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("a failed first context load does not consume bootstrap", 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); + try { + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + } finally { + await restoreAgentsDirectory(agentsDir, backupDir); + } + + const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(successfulOpen.includeBootstrapContext, true); + assert.equal(successfulOpen.workspaceReused, false); +}); + +test("a context-loading failure preserves a valid checkout binding", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const agentsDir = join(project, ".devspace", "agents"); + const backupDir = join(project, ".devspace", "agents-backup"); + + await breakAgentsDirectory(agentsDir, backupDir); + try { + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + } finally { + 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 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 remains stable when the requested target starts missing", async (t) => { + const { project, registry } = await fixture(t); + const missingTarget = join(project, "generated", "checkout"); + + const first = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); + + assert.equal(first.workspace.root, missingTarget); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.workspace.id, first.workspace.id); + assert.equal(second.workspaceReused, true); + assert.equal(second.includeBootstrapContext, false); +}); + +test("canonical checkout identity survives symlink aliases", { skip: platform() === "win32" }, async (t) => { + const { root, project, registry } = await fixture(t); + const alias = join(root, "project-alias"); + await symlink(project, alias, "dir"); + + const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const aliased = await registry.openWorkspace(alias, { conversationScopeId: "chat-1" }); + + assert.equal(aliased.workspace.id, direct.workspace.id); + assert.equal(aliased.workspaceReused, true); + assert.equal(aliased.includeBootstrapContext, false); +}); + +test("an invalid persisted checkout binding is not reused", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set mode = 'worktree' where id = ?") + .run(first.workspace.id); + } finally { + database.close(); + } + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const replacement = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); +}); + +test("unexpected storage errors are not mistaken for stale bindings", async (t) => { + const context = await fixture(t); + await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); + context.closeStore(context.store); + + await assert.rejects( + () => context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), + (error: unknown) => error instanceof Error && /database connection is not open/i.test(error.message), + ); +}); + +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-workspace-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 c0300018..4f3eb769 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,169 +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 missingTarget = join(root, "missing-canonical-target", "project"); - const missingTargetWorkspace = await persistentRegistry.openWorkspace(missingTarget, { - conversationScopeId: "chat-missing-canonical", - }); - assert.equal( - firstStore.getConversationBinding( - "chat-missing-canonical", - JSON.stringify(["checkout", await realpath(missingTarget), null]), - )?.workspaceSessionId, - missingTargetWorkspace.workspace.id, - ); - const missingTargetAgain = await persistentRegistry.openWorkspace(missingTarget, { - conversationScopeId: "chat-missing-canonical", - }); - assert.equal(missingTargetAgain.workspace.id, missingTargetWorkspace.workspace.id); - assert.equal(missingTargetAgain.workspaceReused, 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); @@ -331,77 +172,16 @@ 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"), From 6c776d352aa521f22a8dc93e4736982be16528f7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:30:46 +0530 Subject: [PATCH 32/59] test(review): name isolated behavior scenarios --- src/request-meta.test.ts | 52 +++++-- src/review-checkpoints.test.ts | 258 ++++++++++++++++++++------------- src/ui/card-types.test.ts | 72 +++++---- 3 files changed, 239 insertions(+), 143 deletions(-) diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index b5949a2c..43f9602c 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -1,20 +1,40 @@ 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": 42 }), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); -assert.equal(openAiConversationScopeId(null), undefined); -assert.equal(openAiConversationScopeId(42), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); +test("undefined request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(undefined), undefined); +}); -assert.equal( - openAiConversationScopeId({ - "openai/session": "chat-1", - "openai/subject": "user-1", - "openai/organization": "org-1", - }), - "chat-1", -); +test("null request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(null), undefined); +}); + +test("non-object request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(42), undefined); + assert.equal(openAiConversationScopeId("metadata"), undefined); +}); + +test("missing session metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId({}), undefined); +}); + +test("an empty session string has no conversation scope", () => { + assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); +}); + +test("a non-string session value has no conversation scope", () => { + assert.equal(openAiConversationScopeId({ "openai/session": 42 }), undefined); + assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); +}); + +test("valid OpenAI session metadata returns the raw opaque session value", () => { + assert.equal( + openAiConversationScopeId({ + "openai/session": "chat-session-opaque-value", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), + "chat-session-opaque-value", + ); +}); diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index ecbd835c..81a63549 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,148 +1,166 @@ 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("show_changes advances the last-shown checkpoint for incremental reviews", async (t) => { + const root = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + 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.match(clean.result, /No changes since last shown changes/); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); - const firstReview = await manager.reviewChanges({ + const unreviewed = 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(unreviewed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); + assert.equal(unreviewed.summary.additions, 2); + assert.equal(unreviewed.summary.removals, 0); + assert.match(unreviewed.patch, /world/); + + const markedReviewed = await manager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: true, + }); + assert.equal(markedReviewed.summary.files, 2); + + const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); + assert.equal(afterReviewed.summary.files, 0); + assert.equal(afterReviewed.patch, ""); +}); + +test("review checkpoints survive a manager restart", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); + await writeFile(join(root, "README.md"), "hello\nworld\n"); const restartedManager = createReviewCheckpointManager(); - await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await restartedManager.initializeWorkspace({ workspaceId: "ws_restart", root }); + const afterRestart = await restartedManager.reviewChanges({ - workspaceId: "ws_review", + workspaceId: "ws_restart", root, markReviewed: false, }); - assert.equal(afterRestart.summary.files, 2); + assert.equal(afterRestart.summary.files, 1); assert.match(afterRestart.patch, /world/); - const sinceOpenAfterRestart = await restartedManager.reviewChanges({ - workspaceId: "ws_review", + const sinceWorkspaceOpen = await restartedManager.reviewChanges({ + workspaceId: "ws_restart", root, since: "workspace_open", markReviewed: false, }); - assert.equal(sinceOpenAfterRestart.summary.files, 2); - assert.match(sinceOpenAfterRestart.patch, /world/); + assert.equal(sinceWorkspaceOpen.summary.files, 1); + assert.match(sinceWorkspaceOpen.patch, /world/); +}); - const stillUnreviewed = await manager.reviewChanges({ - workspaceId: "ws_review", +test("concurrent initialization produces a usable shared checkpoint state", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + + const [, concurrentReview] = await Promise.all([ + manager.initializeWorkspace({ workspaceId: "ws_concurrent", root }), + manager.reviewChanges({ workspaceId: "ws_concurrent", root, markReviewed: false }), + ]); + assert.equal(concurrentReview.summary.files, 0); + + await writeFile(join(root, "later.txt"), "visible after initialization\n"); + const afterInitialization = await manager.reviewChanges({ + workspaceId: "ws_concurrent", root, - markReviewed: true, + markReviewed: false, }); - assert.equal(stillUnreviewed.summary.files, 2); + assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); +}); - 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"); +test("a missing last-shown checkpoint falls back to workspace open and re-establishes its baseline", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); + await writeFile(join(root, "README.md"), "hello\nchanged\n"); + await deleteReviewRef(root, "ws_missing_baseline", "baseline"); - 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/); + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); - 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", + const fallback = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", 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/); + assert.match(fallback.patch, /changed/); - const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", + const reestablished = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", root, markReviewed: true, }); - assert.equal(reestablishedBaseline.summary.files, 2); - assert.match(reestablishedBaseline.result, /baseline was re-established/); - const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", + assert.equal(reestablished.summary.files, 1); + assert.match(reestablished.result, /baseline was re-established/); + + const afterReestablished = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", root, markReviewed: false, }); - assert.equal(afterBaselineReestablished.summary.files, 0); - - const inProcessPartialManager = createReviewCheckpointManager(); - await inProcessPartialManager.initializeWorkspace({ workspaceId: "ws_in_process_partial", root }); - await writeFile(join(root, "in-process-partial.txt"), "visible after ref loss\n"); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_in_process_partial/baseline"]); - const inProcessPartialReview = await inProcessPartialManager.reviewChanges({ - workspaceId: "ws_in_process_partial", + assert.equal(afterReestablished.summary.files, 0); +}); + +test("baseline loss during a running manager falls back to workspace open", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_in_process", root }); + await writeFile(join(root, "visible.txt"), "visible after ref loss\n"); + await deleteReviewRef(root, "ws_in_process", "baseline"); + + const review = await manager.reviewChanges({ + workspaceId: "ws_in_process", root, markReviewed: false, }); - assert.equal(inProcessPartialReview.summary.files, 1); - assert.match(inProcessPartialReview.result, /compared from workspace open/); + assert.deepEqual(review.files.map((file) => file.path), ["visible.txt"]); + assert.match(review.result, /compared from workspace open/); +}); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/open"]); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); - const bothMissingManager = createReviewCheckpointManager(); - await bothMissingManager.initializeWorkspace({ workspaceId: "ws_review", root }); - await assert.rejects( - () => bothMissingManager.reviewChanges({ workspaceId: "ws_review", root }), - /Review checkpoints are missing|cannot reconstruct that history safely/, - ); +test("a missing workspace-open checkpoint preserves incremental review but rejects explicit workspace-open comparison", async (t) => { + const root = await committedRepository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); + await writeFile(join(root, "baseline.txt"), "still visible from baseline\n"); + await deleteReviewRef(root, "ws_open_missing", "open"); - 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 manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - const openMissingManager = createReviewCheckpointManager(); - await openMissingManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - const afterOpenRefLoss = await openMissingManager.reviewChanges({ + const incremental = await manager.reviewChanges({ workspaceId: "ws_open_missing", root, markReviewed: false, }); - assert.equal(afterOpenRefLoss.summary.files, 1); - assert.match(afterOpenRefLoss.patch, /still visible from baseline/); + assert.equal(incremental.summary.files, 1); + assert.match(incremental.patch, /still visible from baseline/); + await assert.rejects( - () => openMissingManager.reviewChanges({ + () => manager.reviewChanges({ workspaceId: "ws_open_missing", root, since: "workspace_open", @@ -150,32 +168,74 @@ 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"]); +test("missing historical checkpoints do not silently fabricate review history", async (t) => { + const root = await committedRepository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); + await deleteReviewRef(root, "ws_history_missing", "open"); + await deleteReviewRef(root, "ws_history_missing", "baseline"); + + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); - 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, + () => manager.reviewChanges({ workspaceId: "ws_history_missing", root }), + /Review checkpoints are missing; show_changes cannot reconstruct that history safely/, ); +}); - await writeFile(join(unbornRoot, "README.md"), "first commit\n"); - await git(unbornRoot, ["add", "README.md"]); - await git(unbornRoot, ["commit", "-m", "Initial commit"]); +test("an unborn repository becomes reviewable after its first commit", async (t) => { + const root = await unbornRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); + + await assert.rejects( + () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), + /repository has no HEAD 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 committedRepository(t: TestContext): 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"]); + 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 unbornRepository(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-review-unborn-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"]); + return root; +} + +async function deleteReviewRef( + 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..7f0220fd 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { isEditTool, isExpandableCard, @@ -7,34 +8,49 @@ import { isToolName, } from "./card-types.js"; -for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { - assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); -} +test("the supported coding tools are recognized as card tools", () => { + for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { + assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); + } +}); -assert.equal(isPatchTool("apply_patch"), true); -assert.equal(isEditTool("apply_patch"), false); -assert.equal(isShellTool("exec_command"), true); -assert.equal(isShellTool("write_stdin"), true); -assert.equal(isEditTool("exec_command"), false); -assert.equal(isShellTool("apply_patch"), false); +test("tool classification distinguishes patch, edit, and shell operations", () => { + assert.equal(isPatchTool("apply_patch"), true); + assert.equal(isEditTool("apply_patch"), false); + assert.equal(isShellTool("apply_patch"), false); + assert.equal(isShellTool("exec_command"), true); + assert.equal(isShellTool("write_stdin"), true); + assert.equal(isEditTool("exec_command"), false); +}); -assert.equal( - isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), - true, -); -assert.equal(isExpandableCard({ tool: "apply_patch" }), false); +test("a patch card expands only when it contains patch content", () => { + assert.equal( + isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), + 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, -); +test("a workspace card expands when it contains provider metadata", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + agentProviders: [{ name: "codex", available: true }], + }), + true, + ); +}); + +test("a workspace card expands when it contains agent metadata", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + agents: [{ name: "reviewer", provider: "codex" }], + }), + true, + ); +}); + +test("an empty workspace card stays collapsed", () => { + assert.equal(isExpandableCard({ tool: "open_workspace" }), false); +}); From 5d7347639c5bedd86a911d0b3d36603d3734e917 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:31:02 +0530 Subject: [PATCH 33/59] test(db): isolate workspace migration coverage --- package.json | 2 +- src/oauth-store.test.ts | 58 --------------------------- src/workspace-store.test.ts | 79 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 59 deletions(-) create mode 100644 src/workspace-store.test.ts diff --git a/package.json b/package.json index f42be15d..d2812a84 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/workspace-conversation.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/workspace-store.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 f1317656..fe69797f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -21,7 +21,6 @@ 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")); @@ -30,63 +29,6 @@ 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_id, 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-04T00:00:00.000Z", - ); - initial.sqlite.prepare(` - insert into workspace_conversation_bindings ( - conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at - ) values (?, ?, ?, ?, ?) - `).run( - "chat-existing", - JSON.stringify(["checkout", "/tmp/project", null]), - "ws_existing", - "2026-01-01T00:00:00.000Z", - "2026-01-03T00: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_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-03T00:00:00.000Z", - }], - ); - } finally { - migrated.close(); - } -} - async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { diff --git a/src/workspace-store.test.ts b/src/workspace-store.test.ts new file mode 100644 index 00000000..35b250c8 --- /dev/null +++ b/src/workspace-store.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { openDatabase } from "./db/client.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +test("migration backfills a deterministic bootstrap row from historical target keys", async (t) => { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-workspace-store-test-")); + t.after(() => rm(stateDir, { recursive: true, force: true })); + + 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_id, 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-04T00:00:00.000Z", + ); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["checkout", "/tmp/project", null]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-03T00: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_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-03T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } + + const migratedStore = new SqliteWorkspaceStore(stateDir); + try { + assert.equal(migratedStore.claimConversationBootstrap("chat-existing", "/tmp/project"), false); + } finally { + migratedStore.close(); + } +}); From 6be2e3af025532f1d4d9ea132a57e5dbc004d5cb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 19:12:10 +0530 Subject: [PATCH 34/59] fix(review): reject checkpoint root mismatches --- src/review-checkpoints.test.ts | 46 ++++++++++++++++++++++++++++++++++ src/review-checkpoints.ts | 14 +++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 81a63549..a78cc987 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -90,6 +90,52 @@ test("concurrent initialization produces a usable shared checkpoint state", asyn assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); }); +test("a checkpoint workspace rejects review requests for a different root", async (t) => { + const root = await committedRepository(t); + const otherRoot = await committedRepository(t); + const manager = createReviewCheckpointManager(); + + await manager.initializeWorkspace({ workspaceId: "ws_root_mismatch", root }); + + await assert.rejects( + () => manager.reviewChanges({ + workspaceId: "ws_root_mismatch", + root: otherRoot, + markReviewed: false, + }), + /workspace root mismatch/, + ); + + await writeFile(join(root, "only-first-root.txt"), "first root\n"); + const review = await manager.reviewChanges({ + workspaceId: "ws_root_mismatch", + root, + markReviewed: false, + }); + assert.deepEqual(review.files.map((file) => file.path), ["only-first-root.txt"]); +}); + +test("a concurrent review rejects a different root after initialization", async (t) => { + const root = await committedRepository(t); + const otherRoot = await committedRepository(t); + const manager = createReviewCheckpointManager(); + + const [initialization, review] = await Promise.allSettled([ + manager.initializeWorkspace({ workspaceId: "ws_concurrent_root_mismatch", root }), + manager.reviewChanges({ + workspaceId: "ws_concurrent_root_mismatch", + root: otherRoot, + markReviewed: false, + }), + ]); + + assert.equal(initialization.status, "fulfilled"); + assert.equal(review.status, "rejected"); + if (review.status === "rejected") { + assert.match(String(review.reason), /workspace root mismatch/); + } +}); + test("a missing last-shown checkpoint falls back to workspace open and re-establishes its baseline", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index b6527ff4..a1dd47b7 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -57,6 +57,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { return { async initializeWorkspace({ workspaceId, root }) { const existingState = states.get(workspaceId); + assertWorkspaceRoot(existingState, workspaceId, root); if (existingState?.root === root && existingState.gitRoot !== undefined) { return; } @@ -64,6 +65,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const pending = initializations.get(workspaceId); if (pending) { await pending; + assertWorkspaceRoot(states.get(workspaceId), workspaceId, root); return; } @@ -80,10 +82,12 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); + assertWorkspaceRoot(state, workspaceId, root); if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } + assertWorkspaceRoot(state, workspaceId, root); if (!state?.gitRoot) { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); @@ -139,6 +143,16 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +function assertWorkspaceRoot( + state: WorkspaceReviewState | undefined, + workspaceId: string, + root: string, +): void { + if (state && state.root !== root) { + throw new Error(`Review checkpoint workspace root mismatch for ${workspaceId}.`); + } +} + async function initializeWorkspaceState( states: Map, workspaceId: string, From 245cb2aea018fa2454cedca879bfe26fcafc8bc0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 19:19:07 +0530 Subject: [PATCH 35/59] test(workspace): strengthen conversation lifecycle coverage --- src/workspace-conversation.test.ts | 164 +++++++++++++- src/workspaces.test.ts | 335 ++++++++++++++++------------- 2 files changed, 346 insertions(+), 153 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 04f3cbb2..91a8c027 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -25,10 +25,9 @@ test("a conversation reuses its checkout and receives bootstrap once", async (t) assert.equal(second.workspace.id, first.workspace.id); assert.deepEqual(second.agentsFiles, first.agentsFiles); assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); - assert.deepEqual( - second.workspace.agentProfiles.map((profile) => profile.name), - first.workspace.agentProfiles.map((profile) => profile.name), - ); + assert.deepEqual(second.workspace.skills, first.workspace.skills); + assert.deepEqual(second.workspace.skillDiagnostics, first.workspace.skillDiagnostics); + assert.deepEqual(second.workspace.agentProfiles, first.workspace.agentProfiles); }); test("different conversations receive separate checkout workspaces", async (t) => { @@ -44,6 +43,62 @@ test("different conversations receive separate checkout workspaces", async (t) = assert.equal(second.workspaceReused, false); }); +test("a conversation can bootstrap each canonical project once", async (t) => { + const { root, project, registry } = await fixture(t); + const otherProject = join(root, "other-project"); + await mkdir(otherProject); + await writeFile(join(otherProject, "AGENTS.md"), "other project instructions\n"); + + const firstProjectOpen = await registry.openWorkspace(project, { + conversationScopeId: "chat-1", + }); + const otherProjectOpen = await registry.openWorkspace(otherProject, { + conversationScopeId: "chat-1", + }); + const repeatedProjectOpen = await registry.openWorkspace(project, { + conversationScopeId: "chat-1", + }); + const repeatedOtherProjectOpen = await registry.openWorkspace(otherProject, { + conversationScopeId: "chat-1", + }); + + assert.equal(firstProjectOpen.includeBootstrapContext, true); + assert.equal(otherProjectOpen.includeBootstrapContext, true); + assert.equal(repeatedProjectOpen.includeBootstrapContext, false); + assert.equal(repeatedOtherProjectOpen.includeBootstrapContext, false); + assert.equal(repeatedProjectOpen.workspace.id, firstProjectOpen.workspace.id); + assert.equal(repeatedOtherProjectOpen.workspace.id, otherProjectOpen.workspace.id); + assert.notEqual(otherProjectOpen.workspace.id, firstProjectOpen.workspace.id); +}); + +test("concurrent checkout opens reuse one workspace and claim bootstrap once", async (t) => { + const { project, registry } = await fixture(t); + + const opens = await Promise.all([ + registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + ]); + + assert.equal(new Set(opens.map((open) => open.workspace.id)).size, 1); + assert.equal(opens.filter((open) => open.workspaceReused).length, 1); + assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); + assert.deepEqual(opens[0].agentsFiles, opens[1].agentsFiles); + assert.deepEqual(opens[0].availableAgentsFiles, opens[1].availableAgentsFiles); +}); + +test("a checkout without a conversation scope does not use conversation reuse", async (t) => { + const { project, registry } = await fixture(t); + + const first = await registry.openWorkspace(project); + const second = await registry.openWorkspace(project); + + assert.notEqual(second.workspace.id, first.workspace.id); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, true); +}); + test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { const { project, registry } = await fixture(t, { git: true }); const worktreeInput = { path: project, mode: "worktree" as const }; @@ -214,6 +269,55 @@ test("canonical checkout identity survives symlink aliases", { skip: platform() assert.equal(aliased.includeBootstrapContext, false); }); +test("canonical checkout identity survives macOS var path aliases", { skip: platform() !== "darwin" }, async (t) => { + const context = await fixture(t); + const macAlias = context.root.startsWith("/private/var/") + ? `/var/${context.root.slice("/private/var/".length)}` + : context.root.startsWith("/var/") + ? `/private/var/${context.root.slice("/var/".length)}` + : undefined; + if (!macAlias) { + t.skip("temporary directory is not under /var"); + return; + } + + const aliasConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(context.root, ".alias-config"), + DEVSPACE_ALLOWED_ROOTS: `${context.root},${macAlias}`, + DEVSPACE_WORKTREE_ROOT: join(context.root, ".worktrees"), + DEVSPACE_AGENT_DIR: join(context.root, "agent"), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); + + const direct = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + const aliased = await aliasRegistry.openWorkspace( + `${macAlias}/${context.project.slice(context.root.length + 1)}`, + { conversationScopeId: "chat-1" }, + ); + + assert.equal(aliased.workspace.id, direct.workspace.id); + assert.equal(aliased.workspaceReused, true); + assert.equal(aliased.includeBootstrapContext, false); +}); + +test("canonical checkout identity survives equivalent path spellings", async (t) => { + const { project, registry } = await fixture(t); + const equivalentPath = join(project, "..", "project"); + + const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const equivalent = await registry.openWorkspace(equivalentPath, { + conversationScopeId: "chat-1", + }); + + assert.equal(equivalent.workspace.id, direct.workspace.id); + assert.equal(equivalent.workspaceReused, true); + assert.equal(equivalent.includeBootstrapContext, false); +}); + test("an invalid persisted checkout binding is not reused", async (t) => { const context = await fixture(t); const first = await context.registry.openWorkspace(context.project, { @@ -241,6 +345,54 @@ test("an invalid persisted checkout binding is not reused", async (t) => { assert.equal(replacement.includeBootstrapContext, false); }); +test("an inactive persisted checkout binding is not reused", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set status = 'inactive' where id = ?") + .run(first.workspace.id); + } finally { + database.close(); + } + + const restoredRegistry = new WorkspaceRegistry(context.config, context.openStore()); + const replacement = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); +}); + +test("a project outside the allowed roots is rejected", async (t) => { + const { outsideRoot, registry } = await fixture(t); + + await assert.rejects( + () => registry.openWorkspace(outsideRoot, { conversationScopeId: "chat-1" }), + /outside allowed roots/, + ); +}); + +test("a checkout replaced by a file reports the filesystem error", async (t) => { + const context = await fixture(t); + const target = join(context.root, "file-target"); + await context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }); + await rm(target, { recursive: true, force: true }); + await writeFile(target, "not a directory\n"); + + await assert.rejects( + () => context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }), + /Workspace root must be a directory/, + ); +}); + test("unexpected storage errors are not mistaken for stale bindings", async (t) => { const context = await fixture(t); await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); @@ -254,6 +406,7 @@ test("unexpected storage errors are not mistaken for stale bindings", async (t) interface WorkspaceFixture { root: string; + outsideRoot: string; project: string; stateDir: string; config: ServerConfig; @@ -268,6 +421,7 @@ async function fixture( options: { git?: boolean } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-test-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-outside-test-")); const project = join(root, "project"); const agentDir = join(root, "agent"); const stateDir = join(root, ".state"); @@ -310,10 +464,12 @@ async function fixture( t.after(async () => { for (const openStore of stores) openStore.close(); await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); }); return { root, + outsideRoot, project, stateDir, config, diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..fac8fd81 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,28 +1,192 @@ +import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, mkdir, 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 assert from "node:assert/strict"; -import { loadConfig } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -import { ensureCheckoutWorkspaceRoot, WorkspaceRegistry } from "./workspaces.js"; +import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); -const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); -const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); -try { +test("a checkout exposes initial and nested instruction context while filtering outside symlinks", async (t) => { + const context = await fixture(t); + const opened = await context.registry.openWorkspace(context.root); + + assert.equal(opened.workspace.mode, "checkout"); + assert.deepEqual( + opened.agentsFiles.map((file) => file.content), + ["global instructions\n", "root instructions\n"], + ); + assert.deepEqual( + opened.availableAgentsFiles.map((file) => file.path), + [join(context.root, "nested", "AGENTS.md")], + ); + assert.deepEqual( + opened.workspace.agentProfiles.map((profile) => ({ + name: profile.name, + description: profile.description, + provider: profile.provider, + body: profile.body, + })), + [{ + name: "reviewer", + description: "Read-only project reviewer.", + provider: "codex", + body: "Review only.", + }], + ); + + if (platform() !== "win32") { + const unsafeAgentDir = join(context.root, ".pi", "unsafe-agent"); + await mkdir(unsafeAgentDir, { recursive: true }); + await writeFile(join(context.outsideRoot, "secret.txt"), "outside secret\n"); + await symlink(join(context.outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); + + const unsafeConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(context.root, ".devspace-unsafe-home"), + DEVSPACE_ALLOWED_ROOTS: context.root, + DEVSPACE_WORKTREE_ROOT: join(context.root, ".devspace", "unsafe-worktrees"), + DEVSPACE_AGENT_DIR: unsafeAgentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(context.root); + + assert.deepEqual( + unsafeWorkspace.agentsFiles.map((file) => file.content), + ["root instructions\n"], + ); + } +}); + +test("opening a missing checkout creates its workspace root", async (t) => { + const context = await fixture(t); + const missingRoot = join(context.root, "missing", "workspace"); + + const opened = await context.registry.openWorkspace(missingRoot); + assert.equal(opened.workspace.root, missingRoot); + assert.equal((await stat(missingRoot)).isDirectory(), true); +}); + +test("worktree opens require Git and create an isolated managed workspace", async (t) => { + const context = await fixture(t); + + await assert.rejects( + () => context.registry.openWorkspace({ path: context.root, mode: "worktree" }), + (error: unknown) => + error instanceof GitWorktreeError && error.code === "GIT_REPOSITORY_NOT_FOUND", + ); + + const gitRoot = await createGitProject(context.root); + await writeFile(join(gitRoot, "dirty.txt"), "not copied\n"); + + const opened = await context.registry.openWorkspace({ path: gitRoot, mode: "worktree" }); + + assert.equal(opened.workspace.mode, "worktree"); + assert.notEqual(opened.workspace.root, gitRoot); + assert.equal(opened.workspace.sourceRoot, gitRoot); + assert.equal(opened.workspace.worktree?.baseRef, "HEAD"); + assert.equal(opened.workspace.worktree?.dirtySource, true); + assert.equal(opened.workspace.worktree?.managed, true); + assert.equal((await stat(opened.workspace.root)).isDirectory(), true); + assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); + assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); + + const resolvedReadme = context.registry.resolvePath(opened.workspace, "README.md"); + assert.equal(resolvedReadme.startsWith(opened.workspace.root), true); +}); + +test("persisted checkout and worktree sessions restore after recreating the registry", async (t) => { + const context = await fixture(t); + const gitRoot = await createGitProject(context.root); + const stateDir = join(context.root, ".state"); + const firstStore = new SqliteWorkspaceStore(stateDir); + const firstRegistry = new WorkspaceRegistry(context.config, firstStore); + + const checkout = await firstRegistry.openWorkspace(context.root); + const worktree = await firstRegistry.openWorkspace({ path: gitRoot, mode: "worktree" }); + firstStore.close(); + + const secondStore = new SqliteWorkspaceStore(stateDir); + try { + const restoredRegistry = new WorkspaceRegistry(context.config, secondStore); + const restoredCheckout = restoredRegistry.getWorkspace(checkout.workspace.id); + const restoredWorktree = restoredRegistry.getWorkspace(worktree.workspace.id); + + assert.equal(restoredCheckout.root, context.root); + assert.equal(restoredCheckout.mode, "checkout"); + assert.equal(restoredWorktree.root, worktree.workspace.root); + assert.equal(restoredWorktree.mode, "worktree"); + assert.equal(restoredWorktree.sourceRoot, gitRoot); + assert.equal(restoredWorktree.worktree?.managed, true); + } finally { + secondStore.close(); + } +}); + +test("workspace paths outside the allowed roots are rejected", async (t) => { + const context = await fixture(t); + + await assert.rejects( + () => context.registry.openWorkspace(context.outsideRoot), + /outside allowed roots/, + ); +}); + +test("a symlinked allowed root preserves checkout and worktree path behavior", { skip: platform() === "win32" }, async (t) => { + const context = await fixture(t); + const aliasRoot = join(context.root, "alias-root"); + await symlink(context.root, aliasRoot, "dir"); + await createGitProject(context.root); + + const aliasConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: aliasRoot, + DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), + DEVSPACE_AGENT_DIR: context.agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const aliasRegistry = new WorkspaceRegistry(aliasConfig); + + const worktree = await aliasRegistry.openWorkspace({ + path: join(aliasRoot, "git-project"), + mode: "worktree", + }); + const checkout = await aliasRegistry.openWorkspace(aliasRoot); + + assert.equal(worktree.workspace.sourceRoot, join(aliasRoot, "git-project")); + assert.deepEqual( + checkout.agentsFiles.map((file) => file.content), + ["global instructions\n", "root instructions\n"], + ); +}); + +interface WorkspaceFixture { + root: string; + outsideRoot: string; + agentDir: string; + config: ServerConfig; + registry: WorkspaceRegistry; +} + +async function fixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); const agentDir = join(root, ".pi", "agent"); - await mkdir(agentDir, { recursive: true }); + if (platform() === "win32") { + await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); } else { await mkdir(join(agentDir, "skills"), { recursive: true }); await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); } + await writeFile(join(root, "AGENTS.md"), "root instructions\n"); await mkdir(join(root, ".devspace", "agents"), { recursive: true }); await writeFile( @@ -51,83 +215,23 @@ try { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); - const registry = new WorkspaceRegistry(config); - const { workspace, agentsFiles, availableAgentsFiles } = await registry.openWorkspace(root); - - assert.equal(workspace.mode, "checkout"); - assert.deepEqual( - agentsFiles.map((file) => file.content), - ["global instructions\n", "root instructions\n"], - ); - assert.deepEqual( - availableAgentsFiles.map((file) => file.path), - [join(root, "nested", "AGENTS.md")], - ); - assert.deepEqual( - workspace.agentProfiles.map((profile) => ({ - name: profile.name, - description: profile.description, - provider: profile.provider, - body: profile.body, - })), - [ - { - name: "reviewer", - description: "Read-only project reviewer.", - provider: "codex", - body: "Review only.", - }, - ], - ); - - if (platform() !== "win32") { - const unsafeAgentDir = join(root, ".pi", "unsafe-agent"); - await mkdir(unsafeAgentDir, { recursive: true }); - await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n"); - await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); - const unsafeConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"), - DEVSPACE_AGENT_DIR: unsafeAgentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); - const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root); - assert.deepEqual( - unsafeWorkspace.agentsFiles.map((file) => file.content), - ["root instructions\n"], - ); - } - const missingWorkspaceRoot = join(root, "missing", "workspace"); - const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); - assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); - assert.equal(missingWorkspace.workspace.mode, "checkout"); - assert.equal((await stat(missingWorkspaceRoot)).isDirectory(), true); - - { - let mkdirCalls = 0; - const existingStats = await ensureCheckoutWorkspaceRoot(root, { - stat: async (path) => { - assert.equal(path, root); - return await stat(path); - }, - mkdir: async () => { - mkdirCalls += 1; - }, - }); - assert.equal(existingStats.isDirectory(), true); - assert.equal(mkdirCalls, 0); - } + t.after(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); + }); - await assert.rejects( - () => registry.openWorkspace({ path: root, mode: "worktree" }), - (error: unknown) => - error instanceof GitWorktreeError && error.code === "GIT_REPOSITORY_NOT_FOUND", - ); + return { + root, + outsideRoot, + agentDir, + config, + registry: new WorkspaceRegistry(config), + }; +} - const gitRoot = join(root, "git-project"); +async function createGitProject(parent: string): Promise { + const gitRoot = join(parent, "git-project"); await mkdir(gitRoot); await writeFile(join(gitRoot, "AGENTS.md"), "git root instructions\n"); await writeFile(join(gitRoot, "README.md"), "hello\n"); @@ -136,74 +240,7 @@ try { await git(gitRoot, ["config", "user.name", "DevSpace Test"]); await git(gitRoot, ["add", "."]); await git(gitRoot, ["commit", "-m", "Initial commit"]); - await writeFile(join(gitRoot, "dirty.txt"), "not copied\n"); - - const worktreeWorkspace = await registry.openWorkspace({ - path: gitRoot, - mode: "worktree", - }); - assert.equal(worktreeWorkspace.workspace.mode, "worktree"); - assert.notEqual(worktreeWorkspace.workspace.root, gitRoot); - assert.match(worktreeWorkspace.workspace.root, /git-project-[a-f0-9]{8}$/); - assert.equal(worktreeWorkspace.workspace.sourceRoot, gitRoot); - assert.equal(worktreeWorkspace.workspace.worktree?.baseRef, "HEAD"); - assert.equal(worktreeWorkspace.workspace.worktree?.dirtySource, true); - assert.equal(worktreeWorkspace.workspace.worktree?.managed, true); - assert.equal((await stat(worktreeWorkspace.workspace.root)).isDirectory(), true); - assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); - assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); - - const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md"); - assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true); - - 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", - }); - firstStore.close(); - - const secondStore = new SqliteWorkspaceStore(stateDir); - const restoredRegistry = new WorkspaceRegistry(config, secondStore); - const restoredWorkspace = restoredRegistry.getWorkspace(persistentWorkspace.workspace.id); - assert.equal(restoredWorkspace.root, root); - assert.equal(restoredWorkspace.mode, "checkout"); - - 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); - secondStore.close(); - - if (platform() !== "win32") { - const aliasRoot = join(root, "alias-root"); - await symlink(root, aliasRoot, "dir"); - const aliasConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: aliasRoot, - DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); - const aliasWorkspace = await new WorkspaceRegistry(aliasConfig).openWorkspace({ - path: join(aliasRoot, "git-project"), - mode: "worktree", - }); - assert.equal(aliasWorkspace.workspace.sourceRoot, join(aliasRoot, "git-project")); - - const aliasCheckout = await new WorkspaceRegistry(aliasConfig).openWorkspace(aliasRoot); - assert.deepEqual( - aliasCheckout.agentsFiles.map((file) => file.content), - ["global instructions\n", "root instructions\n"], - ); - } -} finally { - await rm(root, { recursive: true, force: true }); - await rm(outsideRoot, { recursive: true, force: true }); + return gitRoot; } async function git(cwd: string, args: string[]): Promise { From 548f0ac5a03ee164eb3ef53653f918777b10992a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 19:19:11 +0530 Subject: [PATCH 36/59] test(review): clarify isolated behavior scenarios --- src/request-meta.test.ts | 18 +++++++++++++----- src/review-checkpoints.test.ts | 25 +++++++++++++++++++++---- src/ui/card-types.test.ts | 10 ++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index 43f9602c..1c78224d 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -10,11 +10,6 @@ test("null request metadata has no conversation scope", () => { assert.equal(openAiConversationScopeId(null), undefined); }); -test("non-object request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(42), undefined); - assert.equal(openAiConversationScopeId("metadata"), undefined); -}); - test("missing session metadata has no conversation scope", () => { assert.equal(openAiConversationScopeId({}), undefined); }); @@ -28,7 +23,20 @@ test("a non-string session value has no conversation scope", () => { assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); }); +test("primitive request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(42), undefined); + assert.equal(openAiConversationScopeId("metadata"), undefined); + assert.equal(openAiConversationScopeId(true), undefined); +}); + test("valid OpenAI session metadata returns the raw opaque session value", () => { + assert.equal( + openAiConversationScopeId({ "openai/session": "chat-session-opaque-value" }), + "chat-session-opaque-value", + ); +}); + +test("unrelated metadata fields do not alter the selected conversation scope", () => { assert.equal( openAiConversationScopeId({ "openai/session": "chat-session-opaque-value", diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index a78cc987..c048db6a 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -9,7 +9,7 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; const execFileAsync = promisify(execFile); -test("show_changes advances the last-shown checkpoint for incremental reviews", async (t) => { +test("a clean workspace reports no changes", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); @@ -18,6 +18,12 @@ test("show_changes advances the last-shown checkpoint for incremental reviews", assert.equal(clean.summary.files, 0); assert.equal(clean.patch, ""); assert.match(clean.result, /No changes since last shown changes/); +}); + +test("show_changes reports changes from the last-shown checkpoint", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); @@ -31,13 +37,21 @@ test("show_changes advances the last-shown checkpoint for incremental reviews", assert.equal(unreviewed.summary.additions, 2); assert.equal(unreviewed.summary.removals, 0); assert.match(unreviewed.patch, /world/); +}); + +test("marking changes reviewed advances the last-shown checkpoint", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + + await writeFile(join(root, "README.md"), "hello\nworld\n"); const markedReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: true, }); - assert.equal(markedReviewed.summary.files, 2); + assert.equal(markedReviewed.summary.files, 1); const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); assert.equal(afterReviewed.summary.files, 0); @@ -49,6 +63,8 @@ test("review checkpoints survive a manager restart", async (t) => { const manager = createReviewCheckpointManager(); await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); await writeFile(join(root, "README.md"), "hello\nworld\n"); + await manager.reviewChanges({ workspaceId: "ws_restart", root, markReviewed: true }); + await writeFile(join(root, "later.txt"), "after restart\n"); const restartedManager = createReviewCheckpointManager(); await restartedManager.initializeWorkspace({ workspaceId: "ws_restart", root }); @@ -59,7 +75,7 @@ test("review checkpoints survive a manager restart", async (t) => { markReviewed: false, }); assert.equal(afterRestart.summary.files, 1); - assert.match(afterRestart.patch, /world/); + assert.match(afterRestart.patch, /after restart/); const sinceWorkspaceOpen = await restartedManager.reviewChanges({ workspaceId: "ws_restart", @@ -67,8 +83,9 @@ test("review checkpoints survive a manager restart", async (t) => { since: "workspace_open", markReviewed: false, }); - assert.equal(sinceWorkspaceOpen.summary.files, 1); + assert.equal(sinceWorkspaceOpen.summary.files, 2); assert.match(sinceWorkspaceOpen.patch, /world/); + assert.match(sinceWorkspaceOpen.patch, /after restart/); }); test("concurrent initialization produces a usable shared checkpoint state", async (t) => { diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 7f0220fd..eb16ad87 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -51,6 +51,16 @@ test("a workspace card expands when it contains agent metadata", () => { ); }); +test("a workspace card expands when it contains available instruction files", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + availableAgentsFiles: [{ path: "nested/AGENTS.md" }], + }), + true, + ); +}); + test("an empty workspace card stays collapsed", () => { assert.equal(isExpandableCard({ tool: "open_workspace" }), false); }); From e7235188e00b3c921f2c160164bb588a42483674 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 19:19:14 +0530 Subject: [PATCH 37/59] test(db): split workspace migration coverage --- src/workspace-store.test.ts | 65 ++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/src/workspace-store.test.ts b/src/workspace-store.test.ts index 35b250c8..c79c8c0f 100644 --- a/src/workspace-store.test.ts +++ b/src/workspace-store.test.ts @@ -2,11 +2,45 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { openDatabase } from "./db/client.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -test("migration backfills a deterministic bootstrap row from historical target keys", async (t) => { +test("migrated bootstrap history suppresses repeats without blocking another project", async (t) => { + const stateDir = await createLegacyBindingState(t); + const store = new SqliteWorkspaceStore(stateDir); + + try { + assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/project"), false); + assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/other-project"), true); + } finally { + store.close(); + } +}); + +test("migration preserves its deterministic timestamp choice for duplicate historical targets", async (t) => { + const stateDir = await createLegacyBindingState(t); + const migrated = openDatabase(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-03T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +}); + +async function createLegacyBindingState(t: TestContext): Promise { const stateDir = await mkdtemp(join(tmpdir(), "devspace-workspace-store-test-")); t.after(() => rm(stateDir, { recursive: true, force: true })); @@ -52,28 +86,5 @@ test("migration backfills a deterministic bootstrap row from historical target k initial.close(); } - const migrated = openDatabase(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-03T00:00:00.000Z", - }], - ); - } finally { - migrated.close(); - } - - const migratedStore = new SqliteWorkspaceStore(stateDir); - try { - assert.equal(migratedStore.claimConversationBootstrap("chat-existing", "/tmp/project"), false); - } finally { - migratedStore.close(); - } -}); + return stateDir; +} From 2d83fa4c214b10ebfaefaff34503c98472c4709a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 19:24:32 +0530 Subject: [PATCH 38/59] revert: restore 8690d9d tree --- package.json | 2 +- src/oauth-store.test.ts | 58 ++++ src/request-meta.test.ts | 62 +--- src/review-checkpoints.test.ts | 319 ++++++----------- src/review-checkpoints.ts | 14 - src/ui/card-types.test.ts | 82 ++--- src/workspace-conversation.test.ts | 504 --------------------------- src/workspace-store.test.ts | 90 ----- src/workspaces.test.ts | 537 +++++++++++++++++++---------- 9 files changed, 562 insertions(+), 1106 deletions(-) delete mode 100644 src/workspace-conversation.test.ts delete mode 100644 src/workspace-store.test.ts diff --git a/package.json b/package.json index d2812a84..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/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/workspace-store.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/oauth-store.test.ts b/src/oauth-store.test.ts index fe69797f..f1317656 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,63 @@ 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_id, 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-04T00:00:00.000Z", + ); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["checkout", "/tmp/project", null]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-03T00: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_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-03T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index 1c78224d..b5949a2c 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -1,48 +1,20 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { openAiConversationScopeId } from "./request-meta.js"; -test("undefined request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(undefined), undefined); -}); - -test("null request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(null), undefined); -}); - -test("missing session metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId({}), undefined); -}); - -test("an empty session string has no conversation scope", () => { - assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); -}); - -test("a non-string session value has no conversation scope", () => { - assert.equal(openAiConversationScopeId({ "openai/session": 42 }), undefined); - assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); -}); - -test("primitive request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(42), undefined); - assert.equal(openAiConversationScopeId("metadata"), undefined); - assert.equal(openAiConversationScopeId(true), undefined); -}); - -test("valid OpenAI session metadata returns the raw opaque session value", () => { - assert.equal( - openAiConversationScopeId({ "openai/session": "chat-session-opaque-value" }), - "chat-session-opaque-value", - ); -}); - -test("unrelated metadata fields do not alter the selected conversation scope", () => { - assert.equal( - openAiConversationScopeId({ - "openai/session": "chat-session-opaque-value", - "openai/subject": "user-1", - "openai/organization": "org-1", - }), - "chat-session-opaque-value", - ); -}); +assert.equal(openAiConversationScopeId(undefined), undefined); +assert.equal(openAiConversationScopeId({}), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": 42 }), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); +assert.equal(openAiConversationScopeId(null), undefined); +assert.equal(openAiConversationScopeId(42), undefined); +assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); + +assert.equal( + openAiConversationScopeId({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), + "chat-1", +); diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index c048db6a..ecbd835c 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,229 +1,148 @@ 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-")); -test("a clean workspace reports no changes", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); +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"]); + 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 since last shown changes/); -}); - -test("show_changes reports changes from the last-shown checkpoint", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + assert.match(clean.result, /No changes/); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); - const unreviewed = await manager.reviewChanges({ + const firstReview = await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, }); - assert.deepEqual(unreviewed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); - assert.equal(unreviewed.summary.additions, 2); - assert.equal(unreviewed.summary.removals, 0); - assert.match(unreviewed.patch, /world/); -}); - -test("marking changes reviewed advances the last-shown checkpoint", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); - - await writeFile(join(root, "README.md"), "hello\nworld\n"); - - const markedReviewed = await manager.reviewChanges({ - workspaceId: "ws_review", - root, - markReviewed: true, - }); - assert.equal(markedReviewed.summary.files, 1); - - const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); - assert.equal(afterReviewed.summary.files, 0); - assert.equal(afterReviewed.patch, ""); -}); - -test("review checkpoints survive a manager restart", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); - await writeFile(join(root, "README.md"), "hello\nworld\n"); - await manager.reviewChanges({ workspaceId: "ws_restart", root, markReviewed: true }); - await writeFile(join(root, "later.txt"), "after restart\n"); + 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/); const restartedManager = createReviewCheckpointManager(); - await restartedManager.initializeWorkspace({ workspaceId: "ws_restart", root }); - + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); const afterRestart = await restartedManager.reviewChanges({ - workspaceId: "ws_restart", + workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(afterRestart.summary.files, 1); - assert.match(afterRestart.patch, /after restart/); + assert.equal(afterRestart.summary.files, 2); + assert.match(afterRestart.patch, /world/); - const sinceWorkspaceOpen = await restartedManager.reviewChanges({ - workspaceId: "ws_restart", + const sinceOpenAfterRestart = await restartedManager.reviewChanges({ + workspaceId: "ws_review", root, since: "workspace_open", markReviewed: false, }); - assert.equal(sinceWorkspaceOpen.summary.files, 2); - assert.match(sinceWorkspaceOpen.patch, /world/); - assert.match(sinceWorkspaceOpen.patch, /after restart/); -}); - -test("concurrent initialization produces a usable shared checkpoint state", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - - const [, concurrentReview] = await Promise.all([ - manager.initializeWorkspace({ workspaceId: "ws_concurrent", root }), - manager.reviewChanges({ workspaceId: "ws_concurrent", root, markReviewed: false }), - ]); - assert.equal(concurrentReview.summary.files, 0); + assert.equal(sinceOpenAfterRestart.summary.files, 2); + assert.match(sinceOpenAfterRestart.patch, /world/); - await writeFile(join(root, "later.txt"), "visible after initialization\n"); - const afterInitialization = await manager.reviewChanges({ - workspaceId: "ws_concurrent", + const stillUnreviewed = await manager.reviewChanges({ + workspaceId: "ws_review", root, - markReviewed: false, + markReviewed: true, }); - assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); -}); - -test("a checkpoint workspace rejects review requests for a different root", async (t) => { - const root = await committedRepository(t); - const otherRoot = await committedRepository(t); - const manager = createReviewCheckpointManager(); - - await manager.initializeWorkspace({ workspaceId: "ws_root_mismatch", root }); - - await assert.rejects( - () => manager.reviewChanges({ - workspaceId: "ws_root_mismatch", - root: otherRoot, - markReviewed: false, - }), - /workspace root mismatch/, - ); + assert.equal(stillUnreviewed.summary.files, 2); - await writeFile(join(root, "only-first-root.txt"), "first root\n"); - const review = await manager.reviewChanges({ - workspaceId: "ws_root_mismatch", - root, - markReviewed: false, - }); - assert.deepEqual(review.files.map((file) => file.path), ["only-first-root.txt"]); -}); + const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); + assert.equal(afterReviewed.summary.files, 0); -test("a concurrent review rejects a different root after initialization", async (t) => { - const root = await committedRepository(t); - const otherRoot = await committedRepository(t); - const manager = createReviewCheckpointManager(); + await writeFile(join(root, "README.md"), "hello\nworld\nlater\n"); - const [initialization, review] = await Promise.allSettled([ - manager.initializeWorkspace({ workspaceId: "ws_concurrent_root_mismatch", root }), - manager.reviewChanges({ - workspaceId: "ws_concurrent_root_mismatch", - root: otherRoot, - markReviewed: false, - }), + 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/); - assert.equal(initialization.status, "fulfilled"); - assert.equal(review.status, "rejected"); - if (review.status === "rejected") { - assert.match(String(review.reason), /workspace root mismatch/); - } -}); - -test("a missing last-shown checkpoint falls back to workspace open and re-establishes its baseline", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); - await writeFile(join(root, "README.md"), "hello\nchanged\n"); - await deleteReviewRef(root, "ws_missing_baseline", "baseline"); - - const restartedManager = createReviewCheckpointManager(); - await restartedManager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); - - const fallback = await restartedManager.reviewChanges({ - workspaceId: "ws_missing_baseline", + 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(fallback.summary.files, 1); - assert.match(fallback.result, /compared from workspace open/); - assert.match(fallback.patch, /changed/); + assert.equal(afterPartialRestore.summary.files, 2); + assert.match(afterPartialRestore.patch, /later/); + assert.match(afterPartialRestore.result, /compared from workspace open/); - const reestablished = await restartedManager.reviewChanges({ - workspaceId: "ws_missing_baseline", + const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", root, markReviewed: true, }); - assert.equal(reestablished.summary.files, 1); - assert.match(reestablished.result, /baseline was re-established/); - - const afterReestablished = await restartedManager.reviewChanges({ - workspaceId: "ws_missing_baseline", + assert.equal(reestablishedBaseline.summary.files, 2); + assert.match(reestablishedBaseline.result, /baseline was re-established/); + const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ + workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(afterReestablished.summary.files, 0); -}); - -test("baseline loss during a running manager falls back to workspace open", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_in_process", root }); - await writeFile(join(root, "visible.txt"), "visible after ref loss\n"); - await deleteReviewRef(root, "ws_in_process", "baseline"); - - const review = await manager.reviewChanges({ - workspaceId: "ws_in_process", + assert.equal(afterBaselineReestablished.summary.files, 0); + + const inProcessPartialManager = createReviewCheckpointManager(); + await inProcessPartialManager.initializeWorkspace({ workspaceId: "ws_in_process_partial", root }); + await writeFile(join(root, "in-process-partial.txt"), "visible after ref loss\n"); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_in_process_partial/baseline"]); + const inProcessPartialReview = await inProcessPartialManager.reviewChanges({ + workspaceId: "ws_in_process_partial", root, markReviewed: false, }); - assert.deepEqual(review.files.map((file) => file.path), ["visible.txt"]); - assert.match(review.result, /compared from workspace open/); -}); + assert.equal(inProcessPartialReview.summary.files, 1); + assert.match(inProcessPartialReview.result, /compared from workspace open/); -test("a missing workspace-open checkpoint preserves incremental review but rejects explicit workspace-open comparison", async (t) => { - const root = await committedRepository(t); - const setupManager = createReviewCheckpointManager(); - await setupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - await writeFile(join(root, "baseline.txt"), "still visible from baseline\n"); - await deleteReviewRef(root, "ws_open_missing", "open"); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/open"]); + await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); + const bothMissingManager = createReviewCheckpointManager(); + await bothMissingManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await assert.rejects( + () => bothMissingManager.reviewChanges({ workspaceId: "ws_review", root }), + /Review checkpoints are missing|cannot reconstruct that history safely/, + ); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); + 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 incremental = await manager.reviewChanges({ + 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(incremental.summary.files, 1); - assert.match(incremental.patch, /still visible from baseline/); - + assert.equal(afterOpenRefLoss.summary.files, 1); + assert.match(afterOpenRefLoss.patch, /still visible from baseline/); await assert.rejects( - () => manager.reviewChanges({ + () => openMissingManager.reviewChanges({ workspaceId: "ws_open_missing", root, since: "workspace_open", @@ -231,74 +150,32 @@ test("a missing workspace-open checkpoint preserves incremental review but rejec }), /workspace-open review checkpoint is missing/, ); -}); -test("missing historical checkpoints do not silently fabricate review history", async (t) => { - const root = await committedRepository(t); - const setupManager = createReviewCheckpointManager(); - await setupManager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); - await deleteReviewRef(root, "ws_history_missing", "open"); - await deleteReviewRef(root, "ws_history_missing", "baseline"); - - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); - - await assert.rejects( - () => manager.reviewChanges({ workspaceId: "ws_history_missing", root }), - /Review checkpoints are missing; show_changes cannot reconstruct that history safely/, - ); -}); - -test("an unborn repository becomes reviewable after its first commit", async (t) => { - const root = await unbornRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); + 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( - () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), - /repository has no HEAD commit/, + () => unbornManager.reviewChanges({ workspaceId: "ws_unborn", root: unbornRoot }), + /commit|HEAD|Git/i, ); - await writeFile(join(root, "README.md"), "first commit\n"); - await git(root, ["add", "README.md"]); - await git(root, ["commit", "-m", "Initial commit"]); + 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 manager.reviewChanges({ + const afterFirstCommit = await unbornManager.reviewChanges({ workspaceId: "ws_unborn", - root, + root: unbornRoot, markReviewed: false, }); assert.equal(afterFirstCommit.summary.files, 0); assert.equal(afterFirstCommit.patch, ""); -}); - -async function committedRepository(t: TestContext): 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"]); - 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 unbornRepository(t: TestContext): Promise { - const root = await mkdtemp(join(tmpdir(), "devspace-review-unborn-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"]); - return root; -} - -async function deleteReviewRef( - root: string, - workspaceId: string, - checkpoint: "open" | "baseline", -): Promise { - await git(root, ["update-ref", "-d", `refs/devspace/review/${workspaceId}/${checkpoint}`]); +} 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 a1dd47b7..b6527ff4 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -57,7 +57,6 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { return { async initializeWorkspace({ workspaceId, root }) { const existingState = states.get(workspaceId); - assertWorkspaceRoot(existingState, workspaceId, root); if (existingState?.root === root && existingState.gitRoot !== undefined) { return; } @@ -65,7 +64,6 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const pending = initializations.get(workspaceId); if (pending) { await pending; - assertWorkspaceRoot(states.get(workspaceId), workspaceId, root); return; } @@ -82,12 +80,10 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); - assertWorkspaceRoot(state, workspaceId, root); if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } - assertWorkspaceRoot(state, workspaceId, root); if (!state?.gitRoot) { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); @@ -143,16 +139,6 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } -function assertWorkspaceRoot( - state: WorkspaceReviewState | undefined, - workspaceId: string, - root: string, -): void { - if (state && state.root !== root) { - throw new Error(`Review checkpoint workspace root mismatch for ${workspaceId}.`); - } -} - async function initializeWorkspaceState( states: Map, workspaceId: string, diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb16ad87..4e0f2263 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { isEditTool, isExpandableCard, @@ -8,59 +7,34 @@ import { isToolName, } from "./card-types.js"; -test("the supported coding tools are recognized as card tools", () => { - for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { - assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); - } -}); +for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { + assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); +} -test("tool classification distinguishes patch, edit, and shell operations", () => { - assert.equal(isPatchTool("apply_patch"), true); - assert.equal(isEditTool("apply_patch"), false); - assert.equal(isShellTool("apply_patch"), false); - assert.equal(isShellTool("exec_command"), true); - assert.equal(isShellTool("write_stdin"), true); - assert.equal(isEditTool("exec_command"), false); -}); +assert.equal(isPatchTool("apply_patch"), true); +assert.equal(isEditTool("apply_patch"), false); +assert.equal(isShellTool("exec_command"), true); +assert.equal(isShellTool("write_stdin"), true); +assert.equal(isEditTool("exec_command"), false); +assert.equal(isShellTool("apply_patch"), false); -test("a patch card expands only when it contains patch content", () => { - assert.equal( - isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), - true, - ); - assert.equal(isExpandableCard({ tool: "apply_patch" }), false); -}); +assert.equal( + isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), + true, +); +assert.equal(isExpandableCard({ tool: "apply_patch" }), false); -test("a workspace card expands when it contains provider metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agentProviders: [{ name: "codex", available: true }], - }), - true, - ); -}); - -test("a workspace card expands when it contains agent metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agents: [{ name: "reviewer", provider: "codex" }], - }), - true, - ); -}); - -test("a workspace card expands when it contains available instruction files", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - availableAgentsFiles: [{ path: "nested/AGENTS.md" }], - }), - true, - ); -}); - -test("an empty workspace card stays collapsed", () => { - assert.equal(isExpandableCard({ tool: "open_workspace" }), 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 deleted file mode 100644 index 91a8c027..00000000 --- a/src/workspace-conversation.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -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 { openDatabase } from "./db/client.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(first.workspaceReused, false); - assert.equal(first.includeBootstrapContext, true); - assert.equal(second.workspaceReused, true); - assert.equal(second.includeBootstrapContext, false); - assert.equal(second.workspace.id, first.workspace.id); - assert.deepEqual(second.agentsFiles, first.agentsFiles); - assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); - assert.deepEqual(second.workspace.skills, first.workspace.skills); - assert.deepEqual(second.workspace.skillDiagnostics, first.workspace.skillDiagnostics); - assert.deepEqual(second.workspace.agentProfiles, first.workspace.agentProfiles); -}); - -test("different conversations receive separate 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); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); -}); - -test("a conversation can bootstrap each canonical project once", async (t) => { - const { root, project, registry } = await fixture(t); - const otherProject = join(root, "other-project"); - await mkdir(otherProject); - await writeFile(join(otherProject, "AGENTS.md"), "other project instructions\n"); - - const firstProjectOpen = await registry.openWorkspace(project, { - conversationScopeId: "chat-1", - }); - const otherProjectOpen = await registry.openWorkspace(otherProject, { - conversationScopeId: "chat-1", - }); - const repeatedProjectOpen = await registry.openWorkspace(project, { - conversationScopeId: "chat-1", - }); - const repeatedOtherProjectOpen = await registry.openWorkspace(otherProject, { - conversationScopeId: "chat-1", - }); - - assert.equal(firstProjectOpen.includeBootstrapContext, true); - assert.equal(otherProjectOpen.includeBootstrapContext, true); - assert.equal(repeatedProjectOpen.includeBootstrapContext, false); - assert.equal(repeatedOtherProjectOpen.includeBootstrapContext, false); - assert.equal(repeatedProjectOpen.workspace.id, firstProjectOpen.workspace.id); - assert.equal(repeatedOtherProjectOpen.workspace.id, otherProjectOpen.workspace.id); - assert.notEqual(otherProjectOpen.workspace.id, firstProjectOpen.workspace.id); -}); - -test("concurrent checkout opens reuse one workspace and claim bootstrap once", async (t) => { - const { project, registry } = await fixture(t); - - const opens = await Promise.all([ - registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - ]); - - assert.equal(new Set(opens.map((open) => open.workspace.id)).size, 1); - assert.equal(opens.filter((open) => open.workspaceReused).length, 1); - assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); - assert.deepEqual(opens[0].agentsFiles, opens[1].agentsFiles); - assert.deepEqual(opens[0].availableAgentsFiles, opens[1].availableAgentsFiles); -}); - -test("a checkout without a conversation scope does not use conversation reuse", async (t) => { - const { project, registry } = await fixture(t); - - const first = await registry.openWorkspace(project); - const second = await registry.openWorkspace(project); - - assert.notEqual(second.workspace.id, first.workspace.id); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); - assert.equal(first.includeBootstrapContext, true); - assert.equal(second.includeBootstrapContext, true); -}); - -test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { - const { project, registry } = await fixture(t, { git: true }); - const worktreeInput = { path: project, mode: "worktree" as const }; - - const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const firstWorktree = await registry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-1", - }); - const secondWorktree = await registry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-1", - }); - const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - - assert.equal(checkout.includeBootstrapContext, true); - assert.equal(firstWorktree.includeBootstrapContext, false); - assert.equal(secondWorktree.includeBootstrapContext, false); - assert.equal(firstWorktree.workspaceReused, false); - assert.equal(secondWorktree.workspaceReused, false); - assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); - assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); - assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); - assert.equal(checkoutAgain.workspaceReused, true); - assert.equal(checkoutAgain.includeBootstrapContext, false); -}); - -test("a worktree-first conversation creates and then reuses its checkout", async (t) => { - const { project, registry } = await fixture(t, { git: true }); - const worktreeInput = { path: project, mode: "worktree" as const }; - - const worktree = await registry.openWorkspace(worktreeInput, { - conversationScopeId: "chat-1", - }); - const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - - assert.equal(worktree.includeBootstrapContext, true); - assert.equal(worktree.workspaceReused, false); - assert.equal(checkout.includeBootstrapContext, false); - assert.equal(checkout.workspaceReused, false); - assert.equal(checkout.workspace.mode, "checkout"); - assert.notEqual(checkout.workspace.id, worktree.workspace.id); - assert.equal(checkoutAgain.includeBootstrapContext, false); - assert.equal(checkoutAgain.workspaceReused, true); - assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); -}); - -test("concurrent worktree opens claim bootstrap exactly once and return complete context", async (t) => { - const { project, registry } = await fixture(t, { git: true }); - const worktreeInput = { path: project, mode: "worktree" as const }; - - const [first, second] = await Promise.all([ - registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), - registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), - ]); - - assert.equal([first, second].filter((open) => open.includeBootstrapContext).length, 1); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); - assert.notEqual(first.workspace.id, second.workspace.id); - assert.notEqual(first.workspace.root, second.workspace.root); - assert.deepEqual( - first.agentsFiles.map((file) => file.content), - second.agentsFiles.map((file) => file.content), - ); - assert.deepEqual( - first.availableAgentsFiles.map((file) => file.path.replace(first.workspace.root, "")), - second.availableAgentsFiles.map((file) => file.path.replace(second.workspace.root, "")), - ); -}); - -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("a failed first context load does not consume bootstrap", 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); - try { - await assert.rejects( - () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - /directory|ENOTDIR/i, - ); - } finally { - await restoreAgentsDirectory(agentsDir, backupDir); - } - - const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.equal(successfulOpen.includeBootstrapContext, true); - assert.equal(successfulOpen.workspaceReused, false); -}); - -test("a context-loading failure preserves a valid checkout binding", async (t) => { - const { project, registry } = await fixture(t); - const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const agentsDir = join(project, ".devspace", "agents"); - const backupDir = join(project, ".devspace", "agents-backup"); - - await breakAgentsDirectory(agentsDir, backupDir); - try { - await assert.rejects( - () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), - /directory|ENOTDIR/i, - ); - } finally { - 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 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 remains stable when the requested target starts missing", async (t) => { - const { project, registry } = await fixture(t); - const missingTarget = join(project, "generated", "checkout"); - - const first = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); - const second = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); - - assert.equal(first.workspace.root, missingTarget); - assert.equal(first.includeBootstrapContext, true); - assert.equal(second.workspace.id, first.workspace.id); - assert.equal(second.workspaceReused, true); - assert.equal(second.includeBootstrapContext, false); -}); - -test("canonical checkout identity survives symlink aliases", { skip: platform() === "win32" }, async (t) => { - const { root, project, registry } = await fixture(t); - const alias = join(root, "project-alias"); - await symlink(project, alias, "dir"); - - const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const aliased = await registry.openWorkspace(alias, { conversationScopeId: "chat-1" }); - - assert.equal(aliased.workspace.id, direct.workspace.id); - assert.equal(aliased.workspaceReused, true); - assert.equal(aliased.includeBootstrapContext, false); -}); - -test("canonical checkout identity survives macOS var path aliases", { skip: platform() !== "darwin" }, async (t) => { - const context = await fixture(t); - const macAlias = context.root.startsWith("/private/var/") - ? `/var/${context.root.slice("/private/var/".length)}` - : context.root.startsWith("/var/") - ? `/private/var/${context.root.slice("/var/".length)}` - : undefined; - if (!macAlias) { - t.skip("temporary directory is not under /var"); - return; - } - - const aliasConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".alias-config"), - DEVSPACE_ALLOWED_ROOTS: `${context.root},${macAlias}`, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".worktrees"), - DEVSPACE_AGENT_DIR: join(context.root, "agent"), - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); - const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); - - const direct = await context.registry.openWorkspace(context.project, { - conversationScopeId: "chat-1", - }); - const aliased = await aliasRegistry.openWorkspace( - `${macAlias}/${context.project.slice(context.root.length + 1)}`, - { conversationScopeId: "chat-1" }, - ); - - assert.equal(aliased.workspace.id, direct.workspace.id); - assert.equal(aliased.workspaceReused, true); - assert.equal(aliased.includeBootstrapContext, false); -}); - -test("canonical checkout identity survives equivalent path spellings", async (t) => { - const { project, registry } = await fixture(t); - const equivalentPath = join(project, "..", "project"); - - const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const equivalent = await registry.openWorkspace(equivalentPath, { - conversationScopeId: "chat-1", - }); - - assert.equal(equivalent.workspace.id, direct.workspace.id); - assert.equal(equivalent.workspaceReused, true); - assert.equal(equivalent.includeBootstrapContext, false); -}); - -test("an invalid persisted checkout binding is not reused", async (t) => { - const context = await fixture(t); - const first = await context.registry.openWorkspace(context.project, { - conversationScopeId: "chat-1", - }); - context.closeStore(context.store); - - const database = openDatabase(context.stateDir); - try { - database.sqlite - .prepare("update workspace_sessions set mode = 'worktree' where id = ?") - .run(first.workspace.id); - } finally { - database.close(); - } - - const restoredStore = context.openStore(); - const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); - const replacement = await restoredRegistry.openWorkspace(context.project, { - conversationScopeId: "chat-1", - }); - - assert.notEqual(replacement.workspace.id, first.workspace.id); - assert.equal(replacement.workspaceReused, false); - assert.equal(replacement.includeBootstrapContext, false); -}); - -test("an inactive persisted checkout binding is not reused", async (t) => { - const context = await fixture(t); - const first = await context.registry.openWorkspace(context.project, { - conversationScopeId: "chat-1", - }); - context.closeStore(context.store); - - const database = openDatabase(context.stateDir); - try { - database.sqlite - .prepare("update workspace_sessions set status = 'inactive' where id = ?") - .run(first.workspace.id); - } finally { - database.close(); - } - - const restoredRegistry = new WorkspaceRegistry(context.config, context.openStore()); - const replacement = await restoredRegistry.openWorkspace(context.project, { - conversationScopeId: "chat-1", - }); - - assert.notEqual(replacement.workspace.id, first.workspace.id); - assert.equal(replacement.workspaceReused, false); - assert.equal(replacement.includeBootstrapContext, false); -}); - -test("a project outside the allowed roots is rejected", async (t) => { - const { outsideRoot, registry } = await fixture(t); - - await assert.rejects( - () => registry.openWorkspace(outsideRoot, { conversationScopeId: "chat-1" }), - /outside allowed roots/, - ); -}); - -test("a checkout replaced by a file reports the filesystem error", async (t) => { - const context = await fixture(t); - const target = join(context.root, "file-target"); - await context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }); - await rm(target, { recursive: true, force: true }); - await writeFile(target, "not a directory\n"); - - await assert.rejects( - () => context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }), - /Workspace root must be a directory/, - ); -}); - -test("unexpected storage errors are not mistaken for stale bindings", async (t) => { - const context = await fixture(t); - await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); - context.closeStore(context.store); - - await assert.rejects( - () => context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), - (error: unknown) => error instanceof Error && /database connection is not open/i.test(error.message), - ); -}); - -interface WorkspaceFixture { - root: string; - outsideRoot: 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-workspace-conversation-test-")); - const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-outside-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 }); - await rm(outsideRoot, { recursive: true, force: true }); - }); - - return { - root, - outsideRoot, - 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/workspace-store.test.ts b/src/workspace-store.test.ts deleted file mode 100644 index c79c8c0f..00000000 --- a/src/workspace-store.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test, { type TestContext } from "node:test"; -import { openDatabase } from "./db/client.js"; -import { SqliteWorkspaceStore } from "./workspace-store.js"; - -test("migrated bootstrap history suppresses repeats without blocking another project", async (t) => { - const stateDir = await createLegacyBindingState(t); - const store = new SqliteWorkspaceStore(stateDir); - - try { - assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/project"), false); - assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/other-project"), true); - } finally { - store.close(); - } -}); - -test("migration preserves its deterministic timestamp choice for duplicate historical targets", async (t) => { - const stateDir = await createLegacyBindingState(t); - const migrated = openDatabase(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-03T00:00:00.000Z", - }], - ); - } finally { - migrated.close(); - } -}); - -async function createLegacyBindingState(t: TestContext): Promise { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-workspace-store-test-")); - t.after(() => rm(stateDir, { recursive: true, force: true })); - - 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_id, 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-04T00:00:00.000Z", - ); - initial.sqlite.prepare(` - insert into workspace_conversation_bindings ( - conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at - ) values (?, ?, ?, ?, ?) - `).run( - "chat-existing", - JSON.stringify(["checkout", "/tmp/project", null]), - "ws_existing", - "2026-01-01T00:00:00.000Z", - "2026-01-03T00:00:00.000Z", - ); - initial.sqlite.exec(` - drop table workspace_conversation_bootstraps; - delete from devspace_schema_migrations where version = 5; - `); - } finally { - initial.close(); - } - - return stateDir; -} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index fac8fd81..c0300018 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,246 +1,429 @@ -import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, 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 test, { type TestContext } from "node:test"; import { promisify } from "node:util"; -import { loadConfig, type ServerConfig } from "./config.js"; +import assert from "node:assert/strict"; +import { loadConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -import { WorkspaceRegistry } from "./workspaces.js"; +import { ensureCheckoutWorkspaceRoot, WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); +const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); -test("a checkout exposes initial and nested instruction context while filtering outside symlinks", async (t) => { - const context = await fixture(t); - const opened = await context.registry.openWorkspace(context.root); +try { + const agentDir = join(root, ".pi", "agent"); + await mkdir(agentDir, { recursive: true }); + if (platform() === "win32") { + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + } else { + await mkdir(join(agentDir, "skills"), { recursive: true }); + await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); + await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); + } + await writeFile(join(root, "AGENTS.md"), "root instructions\n"); + await mkdir(join(root, ".devspace", "agents"), { recursive: true }); + await writeFile( + join(root, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only project reviewer.", + "provider: codex", + "---", + "", + "Review only.", + "", + ].join("\n"), + ); + await mkdir(join(root, "nested")); + await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); + await writeFile(join(root, "nested", "file.txt"), "hello\n"); - assert.equal(opened.workspace.mode, "checkout"); + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const registry = new WorkspaceRegistry(config); + const { workspace, agentsFiles, availableAgentsFiles } = await registry.openWorkspace(root); + + assert.equal(workspace.mode, "checkout"); assert.deepEqual( - opened.agentsFiles.map((file) => file.content), + agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); + assert.deepEqual( - opened.availableAgentsFiles.map((file) => file.path), - [join(context.root, "nested", "AGENTS.md")], + availableAgentsFiles.map((file) => file.path), + [join(root, "nested", "AGENTS.md")], ); assert.deepEqual( - opened.workspace.agentProfiles.map((profile) => ({ + workspace.agentProfiles.map((profile) => ({ name: profile.name, description: profile.description, provider: profile.provider, body: profile.body, })), - [{ - name: "reviewer", - description: "Read-only project reviewer.", - provider: "codex", - body: "Review only.", - }], + [ + { + name: "reviewer", + description: "Read-only project reviewer.", + provider: "codex", + body: "Review only.", + }, + ], ); if (platform() !== "win32") { - const unsafeAgentDir = join(context.root, ".pi", "unsafe-agent"); + const unsafeAgentDir = join(root, ".pi", "unsafe-agent"); await mkdir(unsafeAgentDir, { recursive: true }); - await writeFile(join(context.outsideRoot, "secret.txt"), "outside secret\n"); - await symlink(join(context.outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); - + await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n"); + await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); const unsafeConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(context.root, ".devspace-unsafe-home"), - DEVSPACE_ALLOWED_ROOTS: context.root, - DEVSPACE_WORKTREE_ROOT: join(context.root, ".devspace", "unsafe-worktrees"), + DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"), DEVSPACE_AGENT_DIR: unsafeAgentDir, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); - const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(context.root); - + const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root); assert.deepEqual( unsafeWorkspace.agentsFiles.map((file) => file.content), ["root instructions\n"], ); } -}); - -test("opening a missing checkout creates its workspace root", async (t) => { - const context = await fixture(t); - const missingRoot = join(context.root, "missing", "workspace"); - - const opened = await context.registry.openWorkspace(missingRoot); - assert.equal(opened.workspace.root, missingRoot); - assert.equal((await stat(missingRoot)).isDirectory(), true); -}); -test("worktree opens require Git and create an isolated managed workspace", async (t) => { - const context = await fixture(t); + const missingWorkspaceRoot = join(root, "missing", "workspace"); + const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); + assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); + assert.equal(missingWorkspace.workspace.mode, "checkout"); + assert.equal((await stat(missingWorkspaceRoot)).isDirectory(), true); + + { + let mkdirCalls = 0; + const existingStats = await ensureCheckoutWorkspaceRoot(root, { + stat: async (path) => { + assert.equal(path, root); + return await stat(path); + }, + mkdir: async () => { + mkdirCalls += 1; + }, + }); + assert.equal(existingStats.isDirectory(), true); + assert.equal(mkdirCalls, 0); + } await assert.rejects( - () => context.registry.openWorkspace({ path: context.root, mode: "worktree" }), + () => registry.openWorkspace({ path: root, mode: "worktree" }), (error: unknown) => error instanceof GitWorktreeError && error.code === "GIT_REPOSITORY_NOT_FOUND", ); - const gitRoot = await createGitProject(context.root); + const gitRoot = join(root, "git-project"); + await mkdir(gitRoot); + await writeFile(join(gitRoot, "AGENTS.md"), "git root instructions\n"); + await writeFile(join(gitRoot, "README.md"), "hello\n"); + await git(gitRoot, ["init"]); + await git(gitRoot, ["config", "user.email", "devspace@example.com"]); + await git(gitRoot, ["config", "user.name", "DevSpace Test"]); + await git(gitRoot, ["add", "."]); + await git(gitRoot, ["commit", "-m", "Initial commit"]); await writeFile(join(gitRoot, "dirty.txt"), "not copied\n"); - const opened = await context.registry.openWorkspace({ path: gitRoot, mode: "worktree" }); - - assert.equal(opened.workspace.mode, "worktree"); - assert.notEqual(opened.workspace.root, gitRoot); - assert.equal(opened.workspace.sourceRoot, gitRoot); - assert.equal(opened.workspace.worktree?.baseRef, "HEAD"); - assert.equal(opened.workspace.worktree?.dirtySource, true); - assert.equal(opened.workspace.worktree?.managed, true); - assert.equal((await stat(opened.workspace.root)).isDirectory(), true); - assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); - assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); - - const resolvedReadme = context.registry.resolvePath(opened.workspace, "README.md"); - assert.equal(resolvedReadme.startsWith(opened.workspace.root), true); -}); - -test("persisted checkout and worktree sessions restore after recreating the registry", async (t) => { - const context = await fixture(t); - const gitRoot = await createGitProject(context.root); - const stateDir = join(context.root, ".state"); + const worktreeWorkspace = await registry.openWorkspace({ + path: gitRoot, + mode: "worktree", + }); + assert.equal(worktreeWorkspace.workspace.mode, "worktree"); + assert.notEqual(worktreeWorkspace.workspace.root, gitRoot); + assert.match(worktreeWorkspace.workspace.root, /git-project-[a-f0-9]{8}$/); + assert.equal(worktreeWorkspace.workspace.sourceRoot, gitRoot); + assert.equal(worktreeWorkspace.workspace.worktree?.baseRef, "HEAD"); + assert.equal(worktreeWorkspace.workspace.worktree?.dirtySource, true); + assert.equal(worktreeWorkspace.workspace.worktree?.managed, true); + assert.equal((await stat(worktreeWorkspace.workspace.root)).isDirectory(), true); + assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); + assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); + + const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md"); + assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true); + + const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); - const firstRegistry = new WorkspaceRegistry(context.config, firstStore); + 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 checkout = await firstRegistry.openWorkspace(context.root); - const worktree = await firstRegistry.openWorkspace({ path: gitRoot, mode: "worktree" }); - firstStore.close(); + const checkoutTargetKey = JSON.stringify(["checkout", await realpath(root), null]); + firstStore.setConversationBinding({ + conversationScopeId: "chat-context-failure", + targetKey: checkoutTargetKey, + workspaceSessionId: persistentWorkspace.workspace.id, + }); - const secondStore = new SqliteWorkspaceStore(stateDir); + 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 { - const restoredRegistry = new WorkspaceRegistry(context.config, secondStore); - const restoredCheckout = restoredRegistry.getWorkspace(checkout.workspace.id); - const restoredWorktree = restoredRegistry.getWorkspace(worktree.workspace.id); - - assert.equal(restoredCheckout.root, context.root); - assert.equal(restoredCheckout.mode, "checkout"); - assert.equal(restoredWorktree.root, worktree.workspace.root); - assert.equal(restoredWorktree.mode, "worktree"); - assert.equal(restoredWorktree.sourceRoot, gitRoot); - assert.equal(restoredWorktree.worktree?.managed, true); + 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 { - secondStore.close(); + await rm(projectAgentsDir, { force: true }); + await rename(projectAgentsBackup, projectAgentsDir); } -}); -test("workspace paths outside the allowed roots are rejected", async (t) => { - const context = await fixture(t); + 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); - await assert.rejects( - () => context.registry.openWorkspace(context.outsideRoot), - /outside allowed roots/, + 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 missingTarget = join(root, "missing-canonical-target", "project"); + const missingTargetWorkspace = await persistentRegistry.openWorkspace(missingTarget, { + conversationScopeId: "chat-missing-canonical", + }); + assert.equal( + firstStore.getConversationBinding( + "chat-missing-canonical", + JSON.stringify(["checkout", await realpath(missingTarget), null]), + )?.workspaceSessionId, + missingTargetWorkspace.workspace.id, ); -}); - -test("a symlinked allowed root preserves checkout and worktree path behavior", { skip: platform() === "win32" }, async (t) => { - const context = await fixture(t); - const aliasRoot = join(context.root, "alias-root"); - await symlink(context.root, aliasRoot, "dir"); - await createGitProject(context.root); - - const aliasConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: aliasRoot, - DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), - DEVSPACE_AGENT_DIR: context.agentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", + const missingTargetAgain = await persistentRegistry.openWorkspace(missingTarget, { + conversationScopeId: "chat-missing-canonical", }); - const aliasRegistry = new WorkspaceRegistry(aliasConfig); + assert.equal(missingTargetAgain.workspace.id, missingTargetWorkspace.workspace.id); + assert.equal(missingTargetAgain.workspaceReused, true); - const worktree = await aliasRegistry.openWorkspace({ - path: join(aliasRoot, "git-project"), - mode: "worktree", + const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeId: "chat-project-modes", }); - const checkout = await aliasRegistry.openWorkspace(aliasRoot); - - assert.equal(worktree.workspace.sourceRoot, join(aliasRoot, "git-project")); + 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", + }); + 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( - checkout.agentsFiles.map((file) => file.content), - ["global instructions\n", "root instructions\n"], + concurrentWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), ); -}); - -interface WorkspaceFixture { - root: string; - outsideRoot: string; - agentDir: string; - config: ServerConfig; - registry: WorkspaceRegistry; -} - -async function fixture(t: TestContext): Promise { - const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); - const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); - const agentDir = join(root, ".pi", "agent"); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles.map((file) => file.path.replace(concurrentWorktree.workspace.root, "")), + persistentWorktree.availableAgentsFiles.map((file) => file.path.replace(persistentWorktree.workspace.root, "")), + ); + firstStore.close(); - if (platform() === "win32") { - await mkdir(agentDir, { recursive: true }); - await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); - } else { - await mkdir(join(agentDir, "skills"), { recursive: true }); - await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); - await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); - } + const secondStore = new SqliteWorkspaceStore(stateDir); + const restoredRegistry = new WorkspaceRegistry(config, secondStore); + const restoredWorkspace = restoredRegistry.getWorkspace(persistentWorkspace.workspace.id); + assert.equal(restoredWorkspace.root, root); + assert.equal(restoredWorkspace.mode, "checkout"); - await writeFile(join(root, "AGENTS.md"), "root instructions\n"); - await mkdir(join(root, ".devspace", "agents"), { recursive: true }); - await writeFile( - join(root, ".devspace", "agents", "reviewer.md"), - [ - "---", - "name: reviewer", - "description: Read-only project reviewer.", - "provider: codex", - "---", - "", - "Review only.", - "", - ].join("\n"), + 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), ); - await mkdir(join(root, "nested")); - await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); - await writeFile(join(root, "nested", "file.txt"), "hello\n"); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); + 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); - t.after(async () => { - await rm(root, { recursive: true, force: true }); - await rm(outsideRoot, { recursive: true, force: 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(); - return { - root, - outsideRoot, - agentDir, - config, - registry: new WorkspaceRegistry(config), - }; -} + 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, + ); -async function createGitProject(parent: string): Promise { - const gitRoot = join(parent, "git-project"); - await mkdir(gitRoot); - await writeFile(join(gitRoot, "AGENTS.md"), "git root instructions\n"); - await writeFile(join(gitRoot, "README.md"), "hello\n"); - await git(gitRoot, ["init"]); - await git(gitRoot, ["config", "user.email", "devspace@example.com"]); - await git(gitRoot, ["config", "user.name", "DevSpace Test"]); - await git(gitRoot, ["add", "."]); - await git(gitRoot, ["commit", "-m", "Initial commit"]); - return gitRoot; + 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"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const aliasWorkspace = await new WorkspaceRegistry(aliasConfig).openWorkspace({ + path: join(aliasRoot, "git-project"), + mode: "worktree", + }); + assert.equal(aliasWorkspace.workspace.sourceRoot, join(aliasRoot, "git-project")); + + const aliasCheckout = await new WorkspaceRegistry(aliasConfig).openWorkspace(aliasRoot); + assert.deepEqual( + aliasCheckout.agentsFiles.map((file) => file.content), + ["global instructions\n", "root instructions\n"], + ); + } +} finally { + await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { From 0249608492b6b3331f4b14b258811228aa90ac56 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:50:15 +0530 Subject: [PATCH 39/59] test(workspace): isolate conversation lifecycle coverage --- package.json | 2 +- src/workspace-conversation.test.ts | 504 +++++++++++++++++++++++++++ src/workspaces.test.ts | 537 ++++++++++------------------- 3 files changed, 682 insertions(+), 361 deletions(-) create mode 100644 src/workspace-conversation.test.ts 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/workspace-conversation.test.ts b/src/workspace-conversation.test.ts new file mode 100644 index 00000000..91a8c027 --- /dev/null +++ b/src/workspace-conversation.test.ts @@ -0,0 +1,504 @@ +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 { openDatabase } from "./db/client.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(first.workspaceReused, false); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.workspaceReused, true); + assert.equal(second.includeBootstrapContext, false); + assert.equal(second.workspace.id, first.workspace.id); + assert.deepEqual(second.agentsFiles, first.agentsFiles); + assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); + assert.deepEqual(second.workspace.skills, first.workspace.skills); + assert.deepEqual(second.workspace.skillDiagnostics, first.workspace.skillDiagnostics); + assert.deepEqual(second.workspace.agentProfiles, first.workspace.agentProfiles); +}); + +test("different conversations receive separate 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); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); +}); + +test("a conversation can bootstrap each canonical project once", async (t) => { + const { root, project, registry } = await fixture(t); + const otherProject = join(root, "other-project"); + await mkdir(otherProject); + await writeFile(join(otherProject, "AGENTS.md"), "other project instructions\n"); + + const firstProjectOpen = await registry.openWorkspace(project, { + conversationScopeId: "chat-1", + }); + const otherProjectOpen = await registry.openWorkspace(otherProject, { + conversationScopeId: "chat-1", + }); + const repeatedProjectOpen = await registry.openWorkspace(project, { + conversationScopeId: "chat-1", + }); + const repeatedOtherProjectOpen = await registry.openWorkspace(otherProject, { + conversationScopeId: "chat-1", + }); + + assert.equal(firstProjectOpen.includeBootstrapContext, true); + assert.equal(otherProjectOpen.includeBootstrapContext, true); + assert.equal(repeatedProjectOpen.includeBootstrapContext, false); + assert.equal(repeatedOtherProjectOpen.includeBootstrapContext, false); + assert.equal(repeatedProjectOpen.workspace.id, firstProjectOpen.workspace.id); + assert.equal(repeatedOtherProjectOpen.workspace.id, otherProjectOpen.workspace.id); + assert.notEqual(otherProjectOpen.workspace.id, firstProjectOpen.workspace.id); +}); + +test("concurrent checkout opens reuse one workspace and claim bootstrap once", async (t) => { + const { project, registry } = await fixture(t); + + const opens = await Promise.all([ + registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + ]); + + assert.equal(new Set(opens.map((open) => open.workspace.id)).size, 1); + assert.equal(opens.filter((open) => open.workspaceReused).length, 1); + assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); + assert.deepEqual(opens[0].agentsFiles, opens[1].agentsFiles); + assert.deepEqual(opens[0].availableAgentsFiles, opens[1].availableAgentsFiles); +}); + +test("a checkout without a conversation scope does not use conversation reuse", async (t) => { + const { project, registry } = await fixture(t); + + const first = await registry.openWorkspace(project); + const second = await registry.openWorkspace(project); + + assert.notEqual(second.workspace.id, first.workspace.id); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, true); +}); + +test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const firstWorktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const secondWorktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(checkout.includeBootstrapContext, true); + assert.equal(firstWorktree.includeBootstrapContext, false); + assert.equal(secondWorktree.includeBootstrapContext, false); + assert.equal(firstWorktree.workspaceReused, false); + assert.equal(secondWorktree.workspaceReused, false); + assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); + assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); + assert.equal(checkoutAgain.workspaceReused, true); + assert.equal(checkoutAgain.includeBootstrapContext, false); +}); + +test("a worktree-first conversation creates and then reuses its checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const worktree = await registry.openWorkspace(worktreeInput, { + conversationScopeId: "chat-1", + }); + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(worktree.includeBootstrapContext, true); + assert.equal(worktree.workspaceReused, false); + assert.equal(checkout.includeBootstrapContext, false); + assert.equal(checkout.workspaceReused, false); + assert.equal(checkout.workspace.mode, "checkout"); + assert.notEqual(checkout.workspace.id, worktree.workspace.id); + assert.equal(checkoutAgain.includeBootstrapContext, false); + assert.equal(checkoutAgain.workspaceReused, true); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); +}); + +test("concurrent worktree opens claim bootstrap exactly once and return complete context", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const worktreeInput = { path: project, mode: "worktree" as const }; + + const [first, second] = await Promise.all([ + registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), + registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), + ]); + + assert.equal([first, second].filter((open) => open.includeBootstrapContext).length, 1); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, false); + assert.notEqual(first.workspace.id, second.workspace.id); + assert.notEqual(first.workspace.root, second.workspace.root); + assert.deepEqual( + first.agentsFiles.map((file) => file.content), + second.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + first.availableAgentsFiles.map((file) => file.path.replace(first.workspace.root, "")), + second.availableAgentsFiles.map((file) => file.path.replace(second.workspace.root, "")), + ); +}); + +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("a failed first context load does not consume bootstrap", 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); + try { + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + } finally { + await restoreAgentsDirectory(agentsDir, backupDir); + } + + const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(successfulOpen.includeBootstrapContext, true); + assert.equal(successfulOpen.workspaceReused, false); +}); + +test("a context-loading failure preserves a valid checkout binding", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const agentsDir = join(project, ".devspace", "agents"); + const backupDir = join(project, ".devspace", "agents-backup"); + + await breakAgentsDirectory(agentsDir, backupDir); + try { + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + } finally { + 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 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 remains stable when the requested target starts missing", async (t) => { + const { project, registry } = await fixture(t); + const missingTarget = join(project, "generated", "checkout"); + + const first = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); + + assert.equal(first.workspace.root, missingTarget); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.workspace.id, first.workspace.id); + assert.equal(second.workspaceReused, true); + assert.equal(second.includeBootstrapContext, false); +}); + +test("canonical checkout identity survives symlink aliases", { skip: platform() === "win32" }, async (t) => { + const { root, project, registry } = await fixture(t); + const alias = join(root, "project-alias"); + await symlink(project, alias, "dir"); + + const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const aliased = await registry.openWorkspace(alias, { conversationScopeId: "chat-1" }); + + assert.equal(aliased.workspace.id, direct.workspace.id); + assert.equal(aliased.workspaceReused, true); + assert.equal(aliased.includeBootstrapContext, false); +}); + +test("canonical checkout identity survives macOS var path aliases", { skip: platform() !== "darwin" }, async (t) => { + const context = await fixture(t); + const macAlias = context.root.startsWith("/private/var/") + ? `/var/${context.root.slice("/private/var/".length)}` + : context.root.startsWith("/var/") + ? `/private/var/${context.root.slice("/var/".length)}` + : undefined; + if (!macAlias) { + t.skip("temporary directory is not under /var"); + return; + } + + const aliasConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(context.root, ".alias-config"), + DEVSPACE_ALLOWED_ROOTS: `${context.root},${macAlias}`, + DEVSPACE_WORKTREE_ROOT: join(context.root, ".worktrees"), + DEVSPACE_AGENT_DIR: join(context.root, "agent"), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); + + const direct = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + const aliased = await aliasRegistry.openWorkspace( + `${macAlias}/${context.project.slice(context.root.length + 1)}`, + { conversationScopeId: "chat-1" }, + ); + + assert.equal(aliased.workspace.id, direct.workspace.id); + assert.equal(aliased.workspaceReused, true); + assert.equal(aliased.includeBootstrapContext, false); +}); + +test("canonical checkout identity survives equivalent path spellings", async (t) => { + const { project, registry } = await fixture(t); + const equivalentPath = join(project, "..", "project"); + + const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const equivalent = await registry.openWorkspace(equivalentPath, { + conversationScopeId: "chat-1", + }); + + assert.equal(equivalent.workspace.id, direct.workspace.id); + assert.equal(equivalent.workspaceReused, true); + assert.equal(equivalent.includeBootstrapContext, false); +}); + +test("an invalid persisted checkout binding is not reused", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set mode = 'worktree' where id = ?") + .run(first.workspace.id); + } finally { + database.close(); + } + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const replacement = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); +}); + +test("an inactive persisted checkout binding is not reused", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set status = 'inactive' where id = ?") + .run(first.workspace.id); + } finally { + database.close(); + } + + const restoredRegistry = new WorkspaceRegistry(context.config, context.openStore()); + const replacement = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); +}); + +test("a project outside the allowed roots is rejected", async (t) => { + const { outsideRoot, registry } = await fixture(t); + + await assert.rejects( + () => registry.openWorkspace(outsideRoot, { conversationScopeId: "chat-1" }), + /outside allowed roots/, + ); +}); + +test("a checkout replaced by a file reports the filesystem error", async (t) => { + const context = await fixture(t); + const target = join(context.root, "file-target"); + await context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }); + await rm(target, { recursive: true, force: true }); + await writeFile(target, "not a directory\n"); + + await assert.rejects( + () => context.registry.openWorkspace(target, { conversationScopeId: "chat-1" }), + /Workspace root must be a directory/, + ); +}); + +test("unexpected storage errors are not mistaken for stale bindings", async (t) => { + const context = await fixture(t); + await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); + context.closeStore(context.store); + + await assert.rejects( + () => context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), + (error: unknown) => error instanceof Error && /database connection is not open/i.test(error.message), + ); +}); + +interface WorkspaceFixture { + root: string; + outsideRoot: 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-workspace-conversation-test-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-outside-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 }); + await rm(outsideRoot, { recursive: true, force: true }); + }); + + return { + root, + outsideRoot, + 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 c0300018..fac8fd81 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,429 +1,246 @@ +import assert from "node:assert/strict"; 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 test, { type TestContext } from "node:test"; import { promisify } from "node:util"; -import assert from "node:assert/strict"; -import { loadConfig } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -import { ensureCheckoutWorkspaceRoot, WorkspaceRegistry } from "./workspaces.js"; +import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); -const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); -const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); -try { - const agentDir = join(root, ".pi", "agent"); - await mkdir(agentDir, { recursive: true }); - if (platform() === "win32") { - await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); - } else { - await mkdir(join(agentDir, "skills"), { recursive: true }); - await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); - await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); - } - await writeFile(join(root, "AGENTS.md"), "root instructions\n"); - await mkdir(join(root, ".devspace", "agents"), { recursive: true }); - await writeFile( - join(root, ".devspace", "agents", "reviewer.md"), - [ - "---", - "name: reviewer", - "description: Read-only project reviewer.", - "provider: codex", - "---", - "", - "Review only.", - "", - ].join("\n"), - ); - await mkdir(join(root, "nested")); - await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); - await writeFile(join(root, "nested", "file.txt"), "hello\n"); +test("a checkout exposes initial and nested instruction context while filtering outside symlinks", async (t) => { + const context = await fixture(t); + const opened = await context.registry.openWorkspace(context.root); - const config = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); - const registry = new WorkspaceRegistry(config); - const { workspace, agentsFiles, availableAgentsFiles } = await registry.openWorkspace(root); - - assert.equal(workspace.mode, "checkout"); + assert.equal(opened.workspace.mode, "checkout"); assert.deepEqual( - agentsFiles.map((file) => file.content), + opened.agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); - assert.deepEqual( - availableAgentsFiles.map((file) => file.path), - [join(root, "nested", "AGENTS.md")], + opened.availableAgentsFiles.map((file) => file.path), + [join(context.root, "nested", "AGENTS.md")], ); assert.deepEqual( - workspace.agentProfiles.map((profile) => ({ + opened.workspace.agentProfiles.map((profile) => ({ name: profile.name, description: profile.description, provider: profile.provider, body: profile.body, })), - [ - { - name: "reviewer", - description: "Read-only project reviewer.", - provider: "codex", - body: "Review only.", - }, - ], + [{ + name: "reviewer", + description: "Read-only project reviewer.", + provider: "codex", + body: "Review only.", + }], ); if (platform() !== "win32") { - const unsafeAgentDir = join(root, ".pi", "unsafe-agent"); + const unsafeAgentDir = join(context.root, ".pi", "unsafe-agent"); await mkdir(unsafeAgentDir, { recursive: true }); - await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n"); - await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); + await writeFile(join(context.outsideRoot, "secret.txt"), "outside secret\n"); + await symlink(join(context.outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); + const unsafeConfig = loadConfig({ - DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"), - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"), + DEVSPACE_CONFIG_DIR: join(context.root, ".devspace-unsafe-home"), + DEVSPACE_ALLOWED_ROOTS: context.root, + DEVSPACE_WORKTREE_ROOT: join(context.root, ".devspace", "unsafe-worktrees"), DEVSPACE_AGENT_DIR: unsafeAgentDir, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); - const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root); + const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(context.root); + assert.deepEqual( unsafeWorkspace.agentsFiles.map((file) => file.content), ["root instructions\n"], ); } +}); - const missingWorkspaceRoot = join(root, "missing", "workspace"); - const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); - assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); - assert.equal(missingWorkspace.workspace.mode, "checkout"); - assert.equal((await stat(missingWorkspaceRoot)).isDirectory(), true); - - { - let mkdirCalls = 0; - const existingStats = await ensureCheckoutWorkspaceRoot(root, { - stat: async (path) => { - assert.equal(path, root); - return await stat(path); - }, - mkdir: async () => { - mkdirCalls += 1; - }, - }); - assert.equal(existingStats.isDirectory(), true); - assert.equal(mkdirCalls, 0); - } +test("opening a missing checkout creates its workspace root", async (t) => { + const context = await fixture(t); + const missingRoot = join(context.root, "missing", "workspace"); + + const opened = await context.registry.openWorkspace(missingRoot); + assert.equal(opened.workspace.root, missingRoot); + assert.equal((await stat(missingRoot)).isDirectory(), true); +}); + +test("worktree opens require Git and create an isolated managed workspace", async (t) => { + const context = await fixture(t); await assert.rejects( - () => registry.openWorkspace({ path: root, mode: "worktree" }), + () => context.registry.openWorkspace({ path: context.root, mode: "worktree" }), (error: unknown) => error instanceof GitWorktreeError && error.code === "GIT_REPOSITORY_NOT_FOUND", ); - const gitRoot = join(root, "git-project"); - await mkdir(gitRoot); - await writeFile(join(gitRoot, "AGENTS.md"), "git root instructions\n"); - await writeFile(join(gitRoot, "README.md"), "hello\n"); - await git(gitRoot, ["init"]); - await git(gitRoot, ["config", "user.email", "devspace@example.com"]); - await git(gitRoot, ["config", "user.name", "DevSpace Test"]); - await git(gitRoot, ["add", "."]); - await git(gitRoot, ["commit", "-m", "Initial commit"]); + const gitRoot = await createGitProject(context.root); await writeFile(join(gitRoot, "dirty.txt"), "not copied\n"); - const worktreeWorkspace = await registry.openWorkspace({ - path: gitRoot, - mode: "worktree", - }); - assert.equal(worktreeWorkspace.workspace.mode, "worktree"); - assert.notEqual(worktreeWorkspace.workspace.root, gitRoot); - assert.match(worktreeWorkspace.workspace.root, /git-project-[a-f0-9]{8}$/); - assert.equal(worktreeWorkspace.workspace.sourceRoot, gitRoot); - assert.equal(worktreeWorkspace.workspace.worktree?.baseRef, "HEAD"); - assert.equal(worktreeWorkspace.workspace.worktree?.dirtySource, true); - assert.equal(worktreeWorkspace.workspace.worktree?.managed, true); - assert.equal((await stat(worktreeWorkspace.workspace.root)).isDirectory(), true); - assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); - assert.match(worktreeWorkspace.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); - - const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md"); - assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true); - - const stateDir = join(root, ".state"); + const opened = await context.registry.openWorkspace({ path: gitRoot, mode: "worktree" }); + + assert.equal(opened.workspace.mode, "worktree"); + assert.notEqual(opened.workspace.root, gitRoot); + assert.equal(opened.workspace.sourceRoot, gitRoot); + assert.equal(opened.workspace.worktree?.baseRef, "HEAD"); + assert.equal(opened.workspace.worktree?.dirtySource, true); + assert.equal(opened.workspace.worktree?.managed, true); + assert.equal((await stat(opened.workspace.root)).isDirectory(), true); + assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /global instructions/); + assert.match(opened.agentsFiles.map((file) => file.content).join("\n"), /git root instructions/); + + const resolvedReadme = context.registry.resolvePath(opened.workspace, "README.md"); + assert.equal(resolvedReadme.startsWith(opened.workspace.root), true); +}); + +test("persisted checkout and worktree sessions restore after recreating the registry", async (t) => { + const context = await fixture(t); + const gitRoot = await createGitProject(context.root); + const stateDir = join(context.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 firstRegistry = new WorkspaceRegistry(context.config, firstStore); - const checkoutTargetKey = JSON.stringify(["checkout", await realpath(root), null]); - firstStore.setConversationBinding({ - conversationScopeId: "chat-context-failure", - targetKey: checkoutTargetKey, - workspaceSessionId: persistentWorkspace.workspace.id, - }); + const checkout = await firstRegistry.openWorkspace(context.root); + const worktree = await firstRegistry.openWorkspace({ path: gitRoot, mode: "worktree" }); + firstStore.close(); - const projectAgentsDir = join(root, ".devspace", "agents"); - const projectAgentsBackup = join(root, ".devspace", "agents-backup"); - await rename(projectAgentsDir, projectAgentsBackup); - await writeFile(projectAgentsDir, "not a directory\n"); + const secondStore = new SqliteWorkspaceStore(stateDir); 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, - ); + const restoredRegistry = new WorkspaceRegistry(context.config, secondStore); + const restoredCheckout = restoredRegistry.getWorkspace(checkout.workspace.id); + const restoredWorktree = restoredRegistry.getWorkspace(worktree.workspace.id); + + assert.equal(restoredCheckout.root, context.root); + assert.equal(restoredCheckout.mode, "checkout"); + assert.equal(restoredWorktree.root, worktree.workspace.root); + assert.equal(restoredWorktree.mode, "worktree"); + assert.equal(restoredWorktree.sourceRoot, gitRoot); + assert.equal(restoredWorktree.worktree?.managed, true); } finally { - await rm(projectAgentsDir, { force: true }); - await rename(projectAgentsBackup, projectAgentsDir); + secondStore.close(); } +}); - 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); +test("workspace paths outside the allowed roots are rejected", async (t) => { + const context = await fixture(t); - 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 missingTarget = join(root, "missing-canonical-target", "project"); - const missingTargetWorkspace = await persistentRegistry.openWorkspace(missingTarget, { - conversationScopeId: "chat-missing-canonical", - }); - assert.equal( - firstStore.getConversationBinding( - "chat-missing-canonical", - JSON.stringify(["checkout", await realpath(missingTarget), null]), - )?.workspaceSessionId, - missingTargetWorkspace.workspace.id, + await assert.rejects( + () => context.registry.openWorkspace(context.outsideRoot), + /outside allowed roots/, ); - const missingTargetAgain = await persistentRegistry.openWorkspace(missingTarget, { - conversationScopeId: "chat-missing-canonical", +}); + +test("a symlinked allowed root preserves checkout and worktree path behavior", { skip: platform() === "win32" }, async (t) => { + const context = await fixture(t); + const aliasRoot = join(context.root, "alias-root"); + await symlink(context.root, aliasRoot, "dir"); + await createGitProject(context.root); + + const aliasConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: aliasRoot, + DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), + DEVSPACE_AGENT_DIR: context.agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", }); - assert.equal(missingTargetAgain.workspace.id, missingTargetWorkspace.workspace.id); - assert.equal(missingTargetAgain.workspaceReused, true); + const aliasRegistry = new WorkspaceRegistry(aliasConfig); - 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 worktree = await aliasRegistry.openWorkspace({ + path: join(aliasRoot, "git-project"), + 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 checkout = await aliasRegistry.openWorkspace(aliasRoot); - const secondStore = new SqliteWorkspaceStore(stateDir); - const restoredRegistry = new WorkspaceRegistry(config, secondStore); - const restoredWorkspace = restoredRegistry.getWorkspace(persistentWorkspace.workspace.id); - 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.equal(worktree.workspace.sourceRoot, join(aliasRoot, "git-project")); assert.deepEqual( - reboundWorkspace.workspace.agentProfiles.map((profile) => profile.name), - persistentWorkspace.workspace.agentProfiles.map((profile) => profile.name), + checkout.agentsFiles.map((file) => file.content), + ["global instructions\n", "root instructions\n"], ); +}); + +interface WorkspaceFixture { + root: string; + outsideRoot: string; + agentDir: string; + config: ServerConfig; + registry: WorkspaceRegistry; +} - 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); +async function fixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); + const agentDir = join(root, ".pi", "agent"); - 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), + if (platform() === "win32") { + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + } else { + await mkdir(join(agentDir, "skills"), { recursive: true }); + await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); + await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); + } + + await writeFile(join(root, "AGENTS.md"), "root instructions\n"); + await mkdir(join(root, ".devspace", "agents"), { recursive: true }); + await writeFile( + join(root, ".devspace", "agents", "reviewer.md"), + [ + "---", + "name: reviewer", + "description: Read-only project reviewer.", + "provider: codex", + "---", + "", + "Review only.", + "", + ].join("\n"), ); - secondStore.close(); + await mkdir(join(root, "nested")); + await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); + await writeFile(join(root, "nested", "file.txt"), "hello\n"); - 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 config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); - 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(); + t.after(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); + }); - const aliasConfig = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: aliasRoot, - DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), - DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", - }); - const aliasWorkspace = await new WorkspaceRegistry(aliasConfig).openWorkspace({ - path: join(aliasRoot, "git-project"), - mode: "worktree", - }); - assert.equal(aliasWorkspace.workspace.sourceRoot, join(aliasRoot, "git-project")); + return { + root, + outsideRoot, + agentDir, + config, + registry: new WorkspaceRegistry(config), + }; +} - const aliasCheckout = await new WorkspaceRegistry(aliasConfig).openWorkspace(aliasRoot); - assert.deepEqual( - aliasCheckout.agentsFiles.map((file) => file.content), - ["global instructions\n", "root instructions\n"], - ); - } -} finally { - await rm(root, { recursive: true, force: true }); - await rm(outsideRoot, { recursive: true, force: true }); +async function createGitProject(parent: string): Promise { + const gitRoot = join(parent, "git-project"); + await mkdir(gitRoot); + await writeFile(join(gitRoot, "AGENTS.md"), "git root instructions\n"); + await writeFile(join(gitRoot, "README.md"), "hello\n"); + await git(gitRoot, ["init"]); + await git(gitRoot, ["config", "user.email", "devspace@example.com"]); + await git(gitRoot, ["config", "user.name", "DevSpace Test"]); + await git(gitRoot, ["add", "."]); + await git(gitRoot, ["commit", "-m", "Initial commit"]); + return gitRoot; } async function git(cwd: string, args: string[]): Promise { From 5ea08d3da4368ab726a09b2946c34478ca111190 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:50:21 +0530 Subject: [PATCH 40/59] test(review): name isolated behavior scenarios --- src/request-meta.test.ts | 62 ++++++-- src/review-checkpoints.test.ts | 277 +++++++++++++++++++++------------ src/ui/card-types.test.ts | 82 ++++++---- 3 files changed, 276 insertions(+), 145 deletions(-) diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index b5949a2c..1c78224d 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -1,20 +1,48 @@ 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": 42 }), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); -assert.equal(openAiConversationScopeId(null), undefined); -assert.equal(openAiConversationScopeId(42), undefined); -assert.equal(openAiConversationScopeId({ "openai/session": "chat-1" }), "chat-1"); - -assert.equal( - openAiConversationScopeId({ - "openai/session": "chat-1", - "openai/subject": "user-1", - "openai/organization": "org-1", - }), - "chat-1", -); +test("undefined request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(undefined), undefined); +}); + +test("null request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(null), undefined); +}); + +test("missing session metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId({}), undefined); +}); + +test("an empty session string has no conversation scope", () => { + assert.equal(openAiConversationScopeId({ "openai/session": "" }), undefined); +}); + +test("a non-string session value has no conversation scope", () => { + assert.equal(openAiConversationScopeId({ "openai/session": 42 }), undefined); + assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); +}); + +test("primitive request metadata has no conversation scope", () => { + assert.equal(openAiConversationScopeId(42), undefined); + assert.equal(openAiConversationScopeId("metadata"), undefined); + assert.equal(openAiConversationScopeId(true), undefined); +}); + +test("valid OpenAI session metadata returns the raw opaque session value", () => { + assert.equal( + openAiConversationScopeId({ "openai/session": "chat-session-opaque-value" }), + "chat-session-opaque-value", + ); +}); + +test("unrelated metadata fields do not alter the selected conversation scope", () => { + assert.equal( + openAiConversationScopeId({ + "openai/session": "chat-session-opaque-value", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), + "chat-session-opaque-value", + ); +}); diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index ecbd835c..904ab201 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,148 +1,183 @@ 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("a clean workspace reports no changes", async (t) => { + const root = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + 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.match(clean.result, /No changes since last shown changes/); +}); + +test("show_changes reports changes from the last-shown checkpoint", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); - const firstReview = await manager.reviewChanges({ + const unreviewed = 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(unreviewed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); + assert.equal(unreviewed.summary.additions, 2); + assert.equal(unreviewed.summary.removals, 0); + assert.match(unreviewed.patch, /world/); +}); + +test("marking changes reviewed advances the last-shown checkpoint", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + + await writeFile(join(root, "README.md"), "hello\nworld\n"); + + const markedReviewed = await manager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: true, + }); + assert.equal(markedReviewed.summary.files, 1); + + const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); + assert.equal(afterReviewed.summary.files, 0); + assert.equal(afterReviewed.patch, ""); +}); + +test("review checkpoints survive a manager restart", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); + await writeFile(join(root, "README.md"), "hello\nworld\n"); + await manager.reviewChanges({ workspaceId: "ws_restart", root, markReviewed: true }); + await writeFile(join(root, "later.txt"), "after restart\n"); const restartedManager = createReviewCheckpointManager(); - await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await restartedManager.initializeWorkspace({ workspaceId: "ws_restart", root }); + const afterRestart = await restartedManager.reviewChanges({ - workspaceId: "ws_review", + workspaceId: "ws_restart", root, markReviewed: false, }); - assert.equal(afterRestart.summary.files, 2); - assert.match(afterRestart.patch, /world/); + assert.equal(afterRestart.summary.files, 1); + assert.match(afterRestart.patch, /after restart/); - const sinceOpenAfterRestart = await restartedManager.reviewChanges({ - workspaceId: "ws_review", + const sinceWorkspaceOpen = await restartedManager.reviewChanges({ + workspaceId: "ws_restart", root, since: "workspace_open", markReviewed: false, }); - assert.equal(sinceOpenAfterRestart.summary.files, 2); - assert.match(sinceOpenAfterRestart.patch, /world/); + assert.equal(sinceWorkspaceOpen.summary.files, 2); + assert.match(sinceWorkspaceOpen.patch, /world/); + assert.match(sinceWorkspaceOpen.patch, /after restart/); +}); - const stillUnreviewed = await manager.reviewChanges({ - workspaceId: "ws_review", +test("concurrent initialization produces a usable shared checkpoint state", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + + const [, concurrentReview] = await Promise.all([ + manager.initializeWorkspace({ workspaceId: "ws_concurrent", root }), + manager.reviewChanges({ workspaceId: "ws_concurrent", root, markReviewed: false }), + ]); + assert.equal(concurrentReview.summary.files, 0); + + await writeFile(join(root, "later.txt"), "visible after initialization\n"); + const afterInitialization = await manager.reviewChanges({ + workspaceId: "ws_concurrent", root, - markReviewed: true, + markReviewed: false, }); - assert.equal(stillUnreviewed.summary.files, 2); + assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); +}); - 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"); +test("a missing last-shown checkpoint falls back to workspace open and re-establishes its baseline", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); + await writeFile(join(root, "README.md"), "hello\nchanged\n"); + await deleteReviewRef(root, "ws_missing_baseline", "baseline"); - 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/); + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); - 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", + const fallback = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", 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/); + assert.match(fallback.patch, /changed/); - const reestablishedBaseline = await partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", + const reestablished = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", root, markReviewed: true, }); - assert.equal(reestablishedBaseline.summary.files, 2); - assert.match(reestablishedBaseline.result, /baseline was re-established/); - const afterBaselineReestablished = await partiallyRestoredManager.reviewChanges({ - workspaceId: "ws_review", + assert.equal(reestablished.summary.files, 1); + assert.match(reestablished.result, /baseline was re-established/); + + const afterReestablished = await restartedManager.reviewChanges({ + workspaceId: "ws_missing_baseline", root, markReviewed: false, }); - assert.equal(afterBaselineReestablished.summary.files, 0); - - const inProcessPartialManager = createReviewCheckpointManager(); - await inProcessPartialManager.initializeWorkspace({ workspaceId: "ws_in_process_partial", root }); - await writeFile(join(root, "in-process-partial.txt"), "visible after ref loss\n"); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_in_process_partial/baseline"]); - const inProcessPartialReview = await inProcessPartialManager.reviewChanges({ - workspaceId: "ws_in_process_partial", + assert.equal(afterReestablished.summary.files, 0); +}); + +test("baseline loss during a running manager falls back to workspace open", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_in_process", root }); + await writeFile(join(root, "visible.txt"), "visible after ref loss\n"); + await deleteReviewRef(root, "ws_in_process", "baseline"); + + const review = await manager.reviewChanges({ + workspaceId: "ws_in_process", root, markReviewed: false, }); - assert.equal(inProcessPartialReview.summary.files, 1); - assert.match(inProcessPartialReview.result, /compared from workspace open/); + assert.deepEqual(review.files.map((file) => file.path), ["visible.txt"]); + assert.match(review.result, /compared from workspace open/); +}); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/open"]); - await git(root, ["update-ref", "-d", "refs/devspace/review/ws_review/baseline"]); - const bothMissingManager = createReviewCheckpointManager(); - await bothMissingManager.initializeWorkspace({ workspaceId: "ws_review", root }); - await assert.rejects( - () => bothMissingManager.reviewChanges({ workspaceId: "ws_review", root }), - /Review checkpoints are missing|cannot reconstruct that history safely/, - ); +test("a missing workspace-open checkpoint preserves incremental review but rejects explicit workspace-open comparison", async (t) => { + const root = await committedRepository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); + await writeFile(join(root, "baseline.txt"), "still visible from baseline\n"); + await deleteReviewRef(root, "ws_open_missing", "open"); - 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 manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - const openMissingManager = createReviewCheckpointManager(); - await openMissingManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - const afterOpenRefLoss = await openMissingManager.reviewChanges({ + const incremental = await manager.reviewChanges({ workspaceId: "ws_open_missing", root, markReviewed: false, }); - assert.equal(afterOpenRefLoss.summary.files, 1); - assert.match(afterOpenRefLoss.patch, /still visible from baseline/); + assert.equal(incremental.summary.files, 1); + assert.match(incremental.patch, /still visible from baseline/); + await assert.rejects( - () => openMissingManager.reviewChanges({ + () => manager.reviewChanges({ workspaceId: "ws_open_missing", root, since: "workspace_open", @@ -150,32 +185,74 @@ try { }), /workspace-open review checkpoint is missing/, ); +}); + +test("missing historical checkpoints do not silently fabricate review history", async (t) => { + const root = await committedRepository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); + await deleteReviewRef(root, "ws_history_missing", "open"); + await deleteReviewRef(root, "ws_history_missing", "baseline"); - await git(unbornRoot, ["init"]); - await git(unbornRoot, ["config", "user.email", "devspace@example.com"]); - await git(unbornRoot, ["config", "user.name", "DevSpace Test"]); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); - 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, + () => manager.reviewChanges({ workspaceId: "ws_history_missing", root }), + /Review checkpoints are missing; show_changes cannot reconstruct that history safely/, ); +}); - await writeFile(join(unbornRoot, "README.md"), "first commit\n"); - await git(unbornRoot, ["add", "README.md"]); - await git(unbornRoot, ["commit", "-m", "Initial commit"]); +test("an unborn repository becomes reviewable after its first commit", async (t) => { + const root = await unbornRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); - const afterFirstCommit = await unbornManager.reviewChanges({ + await assert.rejects( + () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), + /repository has no HEAD commit/, + ); + + 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 committedRepository(t: TestContext): 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"]); + 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 unbornRepository(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-review-unborn-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"]); + return root; +} + +async function deleteReviewRef( + 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..eb16ad87 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { isEditTool, isExpandableCard, @@ -7,34 +8,59 @@ import { isToolName, } from "./card-types.js"; -for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { - assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); -} +test("the supported coding tools are recognized as card tools", () => { + for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { + assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); + } +}); -assert.equal(isPatchTool("apply_patch"), true); -assert.equal(isEditTool("apply_patch"), false); -assert.equal(isShellTool("exec_command"), true); -assert.equal(isShellTool("write_stdin"), true); -assert.equal(isEditTool("exec_command"), false); -assert.equal(isShellTool("apply_patch"), false); +test("tool classification distinguishes patch, edit, and shell operations", () => { + assert.equal(isPatchTool("apply_patch"), true); + assert.equal(isEditTool("apply_patch"), false); + assert.equal(isShellTool("apply_patch"), false); + assert.equal(isShellTool("exec_command"), true); + assert.equal(isShellTool("write_stdin"), true); + assert.equal(isEditTool("exec_command"), false); +}); -assert.equal( - isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), - true, -); -assert.equal(isExpandableCard({ tool: "apply_patch" }), false); +test("a patch card expands only when it contains patch content", () => { + assert.equal( + isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), + 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, -); +test("a workspace card expands when it contains provider metadata", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + agentProviders: [{ name: "codex", available: true }], + }), + true, + ); +}); + +test("a workspace card expands when it contains agent metadata", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + agents: [{ name: "reviewer", provider: "codex" }], + }), + true, + ); +}); + +test("a workspace card expands when it contains available instruction files", () => { + assert.equal( + isExpandableCard({ + tool: "open_workspace", + availableAgentsFiles: [{ path: "nested/AGENTS.md" }], + }), + true, + ); +}); + +test("an empty workspace card stays collapsed", () => { + assert.equal(isExpandableCard({ tool: "open_workspace" }), false); +}); From ce302bafaceb3fb56df46b2e495be06f80d30236 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 17:50:36 +0530 Subject: [PATCH 41/59] test(db): isolate workspace migration coverage --- package.json | 2 +- src/oauth-store.test.ts | 58 ------------------------ src/workspace-store.test.ts | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 59 deletions(-) create mode 100644 src/workspace-store.test.ts diff --git a/package.json b/package.json index f42be15d..d2812a84 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/workspace-conversation.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/workspace-store.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 f1317656..fe69797f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -21,7 +21,6 @@ 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")); @@ -30,63 +29,6 @@ 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_id, 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-04T00:00:00.000Z", - ); - initial.sqlite.prepare(` - insert into workspace_conversation_bindings ( - conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at - ) values (?, ?, ?, ?, ?) - `).run( - "chat-existing", - JSON.stringify(["checkout", "/tmp/project", null]), - "ws_existing", - "2026-01-01T00:00:00.000Z", - "2026-01-03T00: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_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-03T00:00:00.000Z", - }], - ); - } finally { - migrated.close(); - } -} - async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { diff --git a/src/workspace-store.test.ts b/src/workspace-store.test.ts new file mode 100644 index 00000000..c79c8c0f --- /dev/null +++ b/src/workspace-store.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { openDatabase } from "./db/client.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +test("migrated bootstrap history suppresses repeats without blocking another project", async (t) => { + const stateDir = await createLegacyBindingState(t); + const store = new SqliteWorkspaceStore(stateDir); + + try { + assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/project"), false); + assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/other-project"), true); + } finally { + store.close(); + } +}); + +test("migration preserves its deterministic timestamp choice for duplicate historical targets", async (t) => { + const stateDir = await createLegacyBindingState(t); + const migrated = openDatabase(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-03T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +}); + +async function createLegacyBindingState(t: TestContext): Promise { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-workspace-store-test-")); + t.after(() => rm(stateDir, { recursive: true, force: true })); + + 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_id, 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-04T00:00:00.000Z", + ); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["checkout", "/tmp/project", null]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-03T00:00:00.000Z", + ); + initial.sqlite.exec(` + drop table workspace_conversation_bootstraps; + delete from devspace_schema_migrations where version = 5; + `); + } finally { + initial.close(); + } + + return stateDir; +} From bf752dab9d6a55101f4edcb207794b3d3e35de85 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 20:26:41 +0530 Subject: [PATCH 42/59] test(review): prove last-shown persistence after restart --- src/review-checkpoints.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 904ab201..173c8b6e 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -64,10 +64,10 @@ test("review checkpoints survive a manager restart", async (t) => { await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); await writeFile(join(root, "README.md"), "hello\nworld\n"); await manager.reviewChanges({ workspaceId: "ws_restart", root, markReviewed: true }); - await writeFile(join(root, "later.txt"), "after restart\n"); const restartedManager = createReviewCheckpointManager(); await restartedManager.initializeWorkspace({ workspaceId: "ws_restart", root }); + await writeFile(join(root, "later.txt"), "after restart\n"); const afterRestart = await restartedManager.reviewChanges({ workspaceId: "ws_restart", From aa9ee28bc7653b5d6fb68f68db2ba6294157024f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 20:48:16 +0530 Subject: [PATCH 43/59] refactor(review): keep checkpoint scope focused --- src/review-checkpoints.test.ts | 124 +++++++++++++-------------------- src/review-checkpoints.ts | 58 +++++---------- src/server.ts | 9 +-- 3 files changed, 66 insertions(+), 125 deletions(-) diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 173c8b6e..0c2aeb7b 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,5 +1,5 @@ -import { execFile } from "node:child_process"; import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,51 +9,43 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; const execFileAsync = promisify(execFile); -test("a clean workspace reports no changes", async (t) => { +test("a clean workspace reports no changes from the last-shown checkpoint", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); - const clean = await manager.reviewChanges({ workspaceId: "ws_review", root }); + await manager.initializeWorkspace({ workspaceId: "ws_clean", root }); + const clean = await manager.reviewChanges({ workspaceId: "ws_clean", root }); + assert.equal(clean.summary.files, 0); assert.equal(clean.patch, ""); assert.match(clean.result, /No changes since last shown changes/); }); -test("show_changes reports changes from the last-shown checkpoint", async (t) => { +test("show_changes reports and advances the last-shown checkpoint", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + await manager.initializeWorkspace({ workspaceId: "ws_incremental", root }); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); const unreviewed = await manager.reviewChanges({ - workspaceId: "ws_review", + workspaceId: "ws_incremental", root, markReviewed: false, }); assert.deepEqual(unreviewed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); assert.equal(unreviewed.summary.additions, 2); - assert.equal(unreviewed.summary.removals, 0); assert.match(unreviewed.patch, /world/); -}); - -test("marking changes reviewed advances the last-shown checkpoint", async (t) => { - const root = await committedRepository(t); - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_review", root }); - - await writeFile(join(root, "README.md"), "hello\nworld\n"); const markedReviewed = await manager.reviewChanges({ - workspaceId: "ws_review", + workspaceId: "ws_incremental", root, markReviewed: true, }); - assert.equal(markedReviewed.summary.files, 1); + assert.equal(markedReviewed.summary.files, 2); - const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); + const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_incremental", root }); assert.equal(afterReviewed.summary.files, 0); assert.equal(afterReviewed.patch, ""); }); @@ -62,6 +54,7 @@ test("review checkpoints survive a manager restart", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); await manager.initializeWorkspace({ workspaceId: "ws_restart", root }); + await writeFile(join(root, "README.md"), "hello\nworld\n"); await manager.reviewChanges({ workspaceId: "ws_restart", root, markReviewed: true }); @@ -74,21 +67,12 @@ test("review checkpoints survive a manager restart", async (t) => { root, markReviewed: false, }); - assert.equal(afterRestart.summary.files, 1); + assert.deepEqual(afterRestart.files.map((file) => file.path), ["later.txt"]); assert.match(afterRestart.patch, /after restart/); - - const sinceWorkspaceOpen = await restartedManager.reviewChanges({ - workspaceId: "ws_restart", - root, - since: "workspace_open", - markReviewed: false, - }); - assert.equal(sinceWorkspaceOpen.summary.files, 2); - assert.match(sinceWorkspaceOpen.patch, /world/); - assert.match(sinceWorkspaceOpen.patch, /after restart/); + assert.doesNotMatch(afterRestart.patch, /world/); }); -test("concurrent initialization produces a usable shared checkpoint state", async (t) => { +test("concurrent initialization produces one usable checkpoint state", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); @@ -107,10 +91,11 @@ test("concurrent initialization produces a usable shared checkpoint state", asyn assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); }); -test("a missing last-shown checkpoint falls back to workspace open and re-establishes its baseline", async (t) => { +test("a missing last-shown checkpoint falls back after restart and can be re-established", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); await manager.initializeWorkspace({ workspaceId: "ws_missing_baseline", root }); + await writeFile(join(root, "README.md"), "hello\nchanged\n"); await deleteReviewRef(root, "ws_missing_baseline", "baseline"); @@ -142,72 +127,57 @@ test("a missing last-shown checkpoint falls back to workspace open and re-establ assert.equal(afterReestablished.summary.files, 0); }); -test("baseline loss during a running manager falls back to workspace open", async (t) => { +test("a checkpoint workspace rejects a different root without changing its state", async (t) => { const root = await committedRepository(t); + const otherRoot = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_in_process", root }); - await writeFile(join(root, "visible.txt"), "visible after ref loss\n"); - await deleteReviewRef(root, "ws_in_process", "baseline"); + await manager.initializeWorkspace({ workspaceId: "ws_root_mismatch", root }); + + await assert.rejects( + () => manager.reviewChanges({ + workspaceId: "ws_root_mismatch", + root: otherRoot, + markReviewed: false, + }), + /workspace root mismatch/, + ); + + await writeFile(join(root, "only-first-root.txt"), "first root\n"); const review = await manager.reviewChanges({ - workspaceId: "ws_in_process", + workspaceId: "ws_root_mismatch", root, markReviewed: false, }); - assert.deepEqual(review.files.map((file) => file.path), ["visible.txt"]); - assert.match(review.result, /compared from workspace open/); + assert.deepEqual(review.files.map((file) => file.path), ["only-first-root.txt"]); }); -test("a missing workspace-open checkpoint preserves incremental review but rejects explicit workspace-open comparison", async (t) => { +test("a concurrent review rejects a different root after initialization", async (t) => { const root = await committedRepository(t); - const setupManager = createReviewCheckpointManager(); - await setupManager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - await writeFile(join(root, "baseline.txt"), "still visible from baseline\n"); - await deleteReviewRef(root, "ws_open_missing", "open"); - + const otherRoot = await committedRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_open_missing", root }); - - const incremental = await manager.reviewChanges({ - workspaceId: "ws_open_missing", - root, - markReviewed: false, - }); - assert.equal(incremental.summary.files, 1); - assert.match(incremental.patch, /still visible from baseline/); - await assert.rejects( - () => manager.reviewChanges({ - workspaceId: "ws_open_missing", - root, - since: "workspace_open", + const [initialization, review] = await Promise.allSettled([ + manager.initializeWorkspace({ workspaceId: "ws_concurrent_root_mismatch", root }), + manager.reviewChanges({ + workspaceId: "ws_concurrent_root_mismatch", + root: otherRoot, markReviewed: false, }), - /workspace-open review checkpoint is missing/, - ); -}); - -test("missing historical checkpoints do not silently fabricate review history", async (t) => { - const root = await committedRepository(t); - const setupManager = createReviewCheckpointManager(); - await setupManager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); - await deleteReviewRef(root, "ws_history_missing", "open"); - await deleteReviewRef(root, "ws_history_missing", "baseline"); - - const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_history_missing", root }); + ]); - await assert.rejects( - () => manager.reviewChanges({ workspaceId: "ws_history_missing", root }), - /Review checkpoints are missing; show_changes cannot reconstruct that history safely/, - ); + assert.equal(initialization.status, "fulfilled"); + assert.equal(review.status, "rejected"); + if (review.status === "rejected") { + assert.match(String(review.reason), /workspace root mismatch/); + } }); test("an unborn repository becomes reviewable after its first commit", async (t) => { const root = await unbornRepository(t); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); + await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); await assert.rejects( () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), /repository has no HEAD commit/, diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index b6527ff4..0fd8bf36 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -31,10 +31,8 @@ interface WorkspaceReviewState { gitRoot?: string; openRef: string; baselineRef: string; - historyRef: string; openRefAvailable: boolean; baselineRefAvailable: boolean; - historyEstablished: boolean; diagnostic?: string; } @@ -57,6 +55,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { return { async initializeWorkspace({ workspaceId, root }) { const existingState = states.get(workspaceId); + assertWorkspaceRoot(existingState, workspaceId, root); if (existingState?.root === root && existingState.gitRoot !== undefined) { return; } @@ -64,6 +63,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const pending = initializations.get(workspaceId); if (pending) { await pending; + assertWorkspaceRoot(states.get(workspaceId), workspaceId, root); return; } @@ -80,17 +80,17 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); + assertWorkspaceRoot(state, workspaceId, root); if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } + assertWorkspaceRoot(state, workspaceId, root); if (!state?.gitRoot) { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); } - await refreshCheckpointAvailability(state); - let effectiveSince = since; let usedWorkspaceOpenFallback = false; if (since === "last_shown" && !state.baselineRefAvailable) { @@ -101,7 +101,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { usedWorkspaceOpenFallback = true; } else if (since === "workspace_open" && !state.openRefAvailable) { throw new Error( - "The workspace-open review checkpoint is missing; show_changes cannot reconstruct that history safely. Use since=\"last_shown\" if that checkpoint is available.", + "The workspace-open review checkpoint is missing; show_changes cannot reconstruct that history safely.", ); } @@ -139,6 +139,16 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +function assertWorkspaceRoot( + state: WorkspaceReviewState | undefined, + workspaceId: string, + root: string, +): void { + if (state && state.root !== root) { + throw new Error(`Review checkpoint workspace root mismatch for ${workspaceId}.`); + } +} + async function initializeWorkspaceState( states: Map, workspaceId: string, @@ -150,7 +160,6 @@ async function initializeWorkspaceState( ...refs, openRefAvailable: false, baselineRefAvailable: false, - historyEstablished: false, }; try { @@ -160,37 +169,20 @@ async function initializeWorkspaceState( return; } - const [openCommit, baselineCommit, historyCommit] = await Promise.all([ + const [openCommit, baselineCommit] = await Promise.all([ commitForRef(eligibility.gitRoot, state.openRef), commitForRef(eligibility.gitRoot, state.baselineRef), - commitForRef(eligibility.gitRoot, state.historyRef), ]); if (!openCommit && !baselineCommit) { - if (historyCommit) { - state.gitRoot = eligibility.gitRoot; - state.historyEstablished = true; - state.diagnostic = "Review checkpoints are missing; show_changes cannot reconstruct that history safely."; - return; - } - const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot); await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); - await git(eligibility.gitRoot, ["update-ref", state.historyRef, initialCommit]); state.openRefAvailable = true; state.baselineRefAvailable = true; - state.historyEstablished = true; } else { state.openRefAvailable = openCommit !== undefined; state.baselineRefAvailable = baselineCommit !== undefined; - state.historyEstablished = true; - if (!historyCommit) { - const historyCommit = openCommit ?? baselineCommit; - if (historyCommit) { - await git(eligibility.gitRoot, ["update-ref", state.historyRef, historyCommit]); - } - } } state.gitRoot = eligibility.gitRoot; @@ -213,29 +205,13 @@ async function commitForRef(gitRoot: string, ref: string): Promise { - const gitRoot = state.gitRoot; - if (!gitRoot) return; - - const [openCommit, baselineCommit] = await Promise.all([ - commitForRef(gitRoot, state.openRef), - commitForRef(gitRoot, state.baselineRef), - ]); - state.openRefAvailable = openCommit !== undefined; - state.baselineRefAvailable = baselineCommit !== undefined; - state.diagnostic = state.historyEstablished && !openCommit && !baselineCommit - ? "Review checkpoints are missing; show_changes cannot reconstruct that history safely." - : undefined; -} - function reviewRefs( workspaceId: string, -): Pick { +): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { openRef: `${REVIEW_REF_PREFIX}/${segment}/open`, baselineRef: `${REVIEW_REF_PREFIX}/${segment}/baseline`, - historyRef: `${REVIEW_REF_PREFIX}/${segment}/history`, }; } diff --git a/src/server.ts b/src/server.ts index 8a683bf8..c2962c94 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1294,27 +1294,22 @@ function createMcpServer( { title: "Show changes", description: - "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs. By default, compare from the last shown checkpoint; pass since=\"workspace_open\" only when an explicit comparison from workspace open is required.", + "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs. DevSpace automatically compares against the last shown checkpoint.", inputSchema: { workspaceId: z .string() .describe("Workspace identifier returned by open_workspace."), - since: z - .enum(["last_shown", "workspace_open"]) - .optional() - .describe("Checkpoint to compare from. Defaults to last_shown."), }, outputSchema: resultOutputSchema(), ...toolWidgetDescriptorMeta(config, "show_changes"), annotations: { readOnlyHint: true }, }, - async ({ workspaceId, since }) => { + async ({ workspaceId }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const review = await reviewCheckpoints.reviewChanges({ workspaceId, root: workspace.root, - since, markReviewed: true, }); From 38dc6c01a3896bd8f8167123528ad44e4b5e6d33 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 20:49:28 +0530 Subject: [PATCH 44/59] fix(workspace): propagate unexpected binding errors --- src/workspace-conversation.test.ts | 42 ++++++++++++++++++++++++++++++ src/workspaces.ts | 24 ++++++++++++----- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 91a8c027..53dd74b0 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -404,6 +404,44 @@ test("unexpected storage errors are not mistaken for stale bindings", async (t) ); }); +test("unexpected filesystem errors are propagated without replacing the binding", { + skip: platform() === "win32", +}, async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); + const targetKey = checkoutTargetKey(context.project); + const loopA = join(context.root, "loop-a"); + const loopB = join(context.root, "loop-b"); + + await symlink(loopB, loopA, "dir"); + await symlink(loopA, loopB, "dir"); + context.closeStore(context.store); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare("update workspace_sessions set root = ? where id = ?") + .run(loopA, first.workspace.id); + } finally { + database.close(); + } + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + await assert.rejects( + () => restoredRegistry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), + (error: unknown) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ELOOP", + ); + + const binding = restoredStore.getConversationBinding("chat-1", targetKey); + assert.equal(binding?.workspaceSessionId, first.workspace.id); + assert.equal(restoredStore.getSession(first.workspace.id)?.root, loopA); +}); + interface WorkspaceFixture { root: string; outsideRoot: string; @@ -502,3 +540,7 @@ async function initializeGitRepository(root: string): Promise { async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } + +function checkoutTargetKey(project: string): string { + return JSON.stringify(["checkout", project, null]); +} diff --git a/src/workspaces.ts b/src/workspaces.ts index d2e7842e..a6d0a15d 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -10,7 +10,12 @@ 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"; -import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; +import { + AccessDeniedError, + assertAllowedPath, + isPathInsideRoot, + resolveAllowedPath, +} from "./roots.js"; import { loadWorkspaceSkills, markSkillActivated, @@ -165,8 +170,8 @@ export class WorkspaceRegistry { const reusableWorkspace = await this.findReusableCheckoutWorkspace(binding); if (reusableWorkspace) { - this.store?.touchConversationBinding(conversationScopeId, targetKey); const context = await this.reusedWorkspaceContext(reusableWorkspace); + this.store?.touchConversationBinding(conversationScopeId, targetKey); return { ...context, includeBootstrapContext: @@ -204,10 +209,15 @@ export class WorkspaceRegistry { root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); const rootStats = await stat(root); if (!rootStats.isDirectory()) return undefined; - } catch { - // Path containment and filesystem checks are binding validation. Context - // discovery happens below, outside this recovery boundary. - return undefined; + } catch (error) { + if ( + error instanceof AccessDeniedError || + (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) + ) { + return undefined; + } + + throw error; } const workspace = this.getWorkspace(binding.workspaceSessionId); @@ -461,7 +471,7 @@ async function canonicalPath(path: string): Promise { while (true) { try { - return resolve(await realpath(candidate), ...missingSegments.reverse()); + return resolve(await realpath(candidate), ...missingSegments.slice().reverse()); } catch (error) { if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) { throw error; From 20e83486907fe6edf496a16c07f04d763647429c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 20:51:13 +0530 Subject: [PATCH 45/59] docs(workspace): clarify optional conversation reuse --- docs/chatgpt-coding-workflow.md | 15 +++++++++++---- docs/gotchas.md | 20 ++++++++++++++++---- src/request-meta.test.ts | 10 ---------- src/server.ts | 4 ++-- src/workspace-conversation.test.ts | 30 +++++++++++++----------------- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index e228a814..f4161bb6 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,10 +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 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`. +ChatGPT may provide an opaque conversation identifier in +`_meta["openai/session"]`. This is an optional OpenAI-host adapter detail, not a +standard MCP conversation field. DevSpace stores the 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`. Hosts without a supported conversation +identifier receive a normal new workspace from `open_workspace` and continue by +reusing the returned `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. @@ -185,6 +190,8 @@ When `show_changes` is exposed, models should call it exactly once after the final file modification in any turn that changes files. The tool only requires the `workspaceId`; DevSpace automatically compares against the last shown checkpoint and advances that checkpoint after rendering the aggregate diff. +Conversation-scoped workspace reuse does not add a selectable review baseline or +otherwise change this behavior. ## Shell Use diff --git a/docs/gotchas.md b/docs/gotchas.md index cff72411..fe8484bb 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -136,10 +136,22 @@ npx @waishnav/devspace init --force client receives an unknown workspace error, call `open_workspace` again for that project. -Workspace session metadata is persisted. In a ChatGPT conversation, calling -`open_workspace` again for the same checkout project can return the existing -conversation-scoped workspace; worktree mode always creates a new isolated -workspace. +Workspace session metadata is persisted. When ChatGPT supplies its optional +`openai/session` adapter metadata, calling `open_workspace` again for the same +checkout project can return the existing conversation-scoped workspace; +worktree mode always creates a new isolated workspace. Hosts without supported +conversation metadata receive a normal new workspace and should continue +reusing the returned `workspaceId`. + +Conversation-scoped workspace reuse does not change `show_changes`: it still +compares from and advances the last-shown checkpoint automatically. + +## Persistence Retention Follow-up + +DevSpace currently does not prune workspace sessions, conversation bindings, +conversation bootstrap records, or review refs. A future product retention +policy should define safe cleanup for these records; this feature does not +invent expiration or orphan cleanup rules. ## Workspace Path Rejected diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts index 1c78224d..effd3dd0 100644 --- a/src/request-meta.test.ts +++ b/src/request-meta.test.ts @@ -6,10 +6,6 @@ test("undefined request metadata has no conversation scope", () => { assert.equal(openAiConversationScopeId(undefined), undefined); }); -test("null request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(null), undefined); -}); - test("missing session metadata has no conversation scope", () => { assert.equal(openAiConversationScopeId({}), undefined); }); @@ -23,12 +19,6 @@ test("a non-string session value has no conversation scope", () => { assert.equal(openAiConversationScopeId({ "openai/session": {} }), undefined); }); -test("primitive request metadata has no conversation scope", () => { - assert.equal(openAiConversationScopeId(42), undefined); - assert.equal(openAiConversationScopeId("metadata"), undefined); - assert.equal(openAiConversationScopeId(true), undefined); -}); - test("valid OpenAI session metadata returns the raw opaque session value", () => { assert.equal( openAiConversationScopeId({ "openai/session": "chat-session-opaque-value" }), diff --git a/src/server.ts b/src/server.ts index c2962c94..010feae5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -197,7 +197,7 @@ function serverInstructions(config: ServerConfig): string { : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Checkout mode can reuse the conversation-scoped workspace; each worktree-mode open creates a new isolated worktree. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Checkout mode can reuse the conversation-scoped workspace when the host provides that optional context; otherwise continue using the returned workspaceId. Each worktree-mode open creates a new isolated worktree. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -752,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "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.", + "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. When ChatGPT supplies optional conversation context, checkout mode reuses the existing checkout workspace for the same project and conversation; hosts without supported context receive a normal new workspace and continue with the returned workspaceId. 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() diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 53dd74b0..955f100a 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -256,12 +256,22 @@ test("canonical checkout identity remains stable when the requested target start assert.equal(second.includeBootstrapContext, false); }); -test("canonical checkout identity survives symlink aliases", { skip: platform() === "win32" }, async (t) => { +test("canonical checkout identity survives equivalent path and symlink aliases", async (t) => { const { root, project, registry } = await fixture(t); - const alias = join(root, "project-alias"); - await symlink(project, alias, "dir"); const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const equivalent = await registry.openWorkspace(join(project, "..", "project"), { + conversationScopeId: "chat-1", + }); + + assert.equal(equivalent.workspace.id, direct.workspace.id); + assert.equal(equivalent.workspaceReused, true); + assert.equal(equivalent.includeBootstrapContext, false); + + if (platform() === "win32") return; + + const alias = join(root, "project-alias"); + await symlink(project, alias, "dir"); const aliased = await registry.openWorkspace(alias, { conversationScopeId: "chat-1" }); assert.equal(aliased.workspace.id, direct.workspace.id); @@ -304,20 +314,6 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat assert.equal(aliased.includeBootstrapContext, false); }); -test("canonical checkout identity survives equivalent path spellings", async (t) => { - const { project, registry } = await fixture(t); - const equivalentPath = join(project, "..", "project"); - - const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - const equivalent = await registry.openWorkspace(equivalentPath, { - conversationScopeId: "chat-1", - }); - - assert.equal(equivalent.workspace.id, direct.workspace.id); - assert.equal(equivalent.workspaceReused, true); - assert.equal(equivalent.includeBootstrapContext, false); -}); - test("an invalid persisted checkout binding is not reused", async (t) => { const context = await fixture(t); const first = await context.registry.openWorkspace(context.project, { From c3a642b628c64923c2d65c34816daf6d2dc070fc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 20:56:34 +0530 Subject: [PATCH 46/59] test(workspace): canonicalize macOS binding fixture --- src/workspace-conversation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 955f100a..3a639136 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -1,6 +1,6 @@ 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 { mkdtemp, mkdir, realpath, 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"; @@ -405,7 +405,7 @@ test("unexpected filesystem errors are propagated without replacing the binding" }, async (t) => { const context = await fixture(t); const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); - const targetKey = checkoutTargetKey(context.project); + const targetKey = checkoutTargetKey(await realpath(context.project)); const loopA = join(context.root, "loop-a"); const loopB = join(context.root, "loop-b"); From e1e20196944961a56183c47ad38f200bcc124cd7 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:01:16 +0530 Subject: [PATCH 47/59] test(workspace): remove duplicate root coverage --- src/workspace-conversation.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 3a639136..f4501038 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -367,15 +367,6 @@ test("an inactive persisted checkout binding is not reused", async (t) => { assert.equal(replacement.includeBootstrapContext, false); }); -test("a project outside the allowed roots is rejected", async (t) => { - const { outsideRoot, registry } = await fixture(t); - - await assert.rejects( - () => registry.openWorkspace(outsideRoot, { conversationScopeId: "chat-1" }), - /outside allowed roots/, - ); -}); - test("a checkout replaced by a file reports the filesystem error", async (t) => { const context = await fixture(t); const target = join(context.root, "file-target"); @@ -440,7 +431,6 @@ test("unexpected filesystem errors are propagated without replacing the binding" interface WorkspaceFixture { root: string; - outsideRoot: string; project: string; stateDir: string; config: ServerConfig; @@ -455,7 +445,6 @@ async function fixture( options: { git?: boolean } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-test-")); - const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-conversation-outside-test-")); const project = join(root, "project"); const agentDir = join(root, "agent"); const stateDir = join(root, ".state"); @@ -498,12 +487,10 @@ async function fixture( t.after(async () => { for (const openStore of stores) openStore.close(); await rm(root, { recursive: true, force: true }); - await rm(outsideRoot, { recursive: true, force: true }); }); return { root, - outsideRoot, project, stateDir, config, From 45e60e4c2e0cc495e871a4b1a18d8d34b9593f78 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:16:41 +0530 Subject: [PATCH 48/59] refactor(server): make workspace guidance actionable --- src/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index 010feae5..6ffaf196 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 before reading, editing, searching, writing, showing changes, or running commands, then reuse the returned workspaceId. When ChatGPT supplies optional conversation context, checkout mode reuses the existing checkout workspace for the same project and conversation; hosts without supported context receive a normal new workspace and continue with the returned workspaceId. 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.", + "Open a local project directory as a coding workspace. Call this once before working in a project or worktree, then reuse the returned workspaceId for later file, search, edit, show-changes, and shell calls. By default this opens the actual checkout; set mode=\"worktree\" when you need isolated or parallel work. Open another workspace when changing projects, switching modes, or starting another isolated worktree.", 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. Each worktree-mode call creates a new isolated managed Git worktree and workspace for parallel work.", + "Defaults to checkout, which works in the actual directory. Use worktree for isolated or parallel Git work.", ), baseRef: z .string() @@ -1294,7 +1294,7 @@ function createMcpServer( { title: "Show changes", description: - "Show aggregate file changes for an open workspace. If the current turn successfully modified files, call this exactly once after the final related file change and before your final response so the user can inspect the combined diff for the turn. Do not call it after every individual file change, and do not skip it because prior file-change tools already displayed per-tool diffs. DevSpace automatically compares against the last shown checkpoint.", + "Show the changes made in this turn for an open workspace. Call this once after the final related file change and before your final response so the user can review the combined diff. Do not call it after each individual file change.", inputSchema: { workspaceId: z .string() From 61b29b21df4f8c450d2111fe39c4ad97284288b4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:17:17 +0530 Subject: [PATCH 49/59] docs(workspace): describe resume behavior for users --- docs/chatgpt-coding-workflow.md | 39 +++++++++++++-------------------- docs/gotchas.md | 30 ++++++++++++------------- 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index f4161bb6..54a5c473 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,28 +17,21 @@ 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 may provide an opaque conversation identifier in -`_meta["openai/session"]`. This is an optional OpenAI-host adapter detail, not a -standard MCP conversation field. DevSpace stores the 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`. Hosts without a supported conversation -identifier receive a normal new workspace from `open_workspace` and continue by -reusing the returned `workspaceId`. +When ChatGPT opens the same checkout project again in the same conversation, +DevSpace can continue in the existing checkout workspace. This is a convenience +for continuing work; the portable workflow remains the same: keep using the +`workspaceId` returned by `open_workspace` for later operations. Hosts that do +not provide conversation context continue with that explicit `workspaceId` +workflow. 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 -successful open, after project context discovery completes, for a canonical -project path in a ChatGPT conversation returns project instructions and -diagnostics, plus skills when `DEVSPACE_SKILLS` is enabled and subagent metadata -when `DEVSPACE_SUBAGENTS=1`. 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. +The first successful open of a project in a conversation provides its initial +instructions and coding context. Later opens of that project avoid repeating the +same setup guidance, including when switching between checkout and worktree +mode. The workspace UI continues to show the full details for the current +workspace. Do not call `open_workspace` again for the same checkout folder unless: @@ -186,12 +179,10 @@ and shell tools. The aggregate `show_changes` tool is not exposed by default. Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` to expose the aggregate show-changes flow. -When `show_changes` is exposed, models should call it exactly once after the -final file modification in any turn that changes files. The tool only requires -the `workspaceId`; DevSpace automatically compares against the last shown -checkpoint and advances that checkpoint after rendering the aggregate diff. -Conversation-scoped workspace reuse does not add a selectable review baseline or -otherwise change this behavior. +When `show_changes` is exposed, call it exactly once after the final file +modification in any turn that changes files. It shows the combined changes for +that turn and advances the review point automatically. Reusing a workspace does +not change this workflow. ## Shell Use diff --git a/docs/gotchas.md b/docs/gotchas.md index fe8484bb..a3303791 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -136,22 +136,20 @@ npx @waishnav/devspace init --force client receives an unknown workspace error, call `open_workspace` again for that project. -Workspace session metadata is persisted. When ChatGPT supplies its optional -`openai/session` adapter metadata, calling `open_workspace` again for the same -checkout project can return the existing conversation-scoped workspace; -worktree mode always creates a new isolated workspace. Hosts without supported -conversation metadata receive a normal new workspace and should continue -reusing the returned `workspaceId`. - -Conversation-scoped workspace reuse does not change `show_changes`: it still -compares from and advances the last-shown checkpoint automatically. - -## Persistence Retention Follow-up - -DevSpace currently does not prune workspace sessions, conversation bindings, -conversation bootstrap records, or review refs. A future product retention -policy should define safe cleanup for these records; this feature does not -invent expiration or orphan cleanup rules. +Workspace session metadata is persisted. In ChatGPT, opening the same checkout +project again in the same conversation can resume the existing workspace; +worktree mode always creates a new isolated workspace. In all cases, continue +passing the `workspaceId` returned by `open_workspace` to later tools. Other MCP +hosts use this explicit workspace workflow as well. + +To review work, call `show_changes` once after the final related file change. It +shows the combined changes and advances the review point automatically. + +## Data Retention + +DevSpace does not currently expire old workspace, conversation-resume, or review +history data automatically. A future retention policy will define cleanup; no +automatic deletion is performed today. ## Workspace Path Rejected From 6541ce2ecaab0e33c39031b8ff8ef1c4b4707e20 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:35:43 +0530 Subject: [PATCH 50/59] test(workspace): avoid dependency error text assertions --- src/workspace-conversation.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index f4501038..02c876c8 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -382,12 +382,18 @@ test("a checkout replaced by a file reports the filesystem error", async (t) => test("unexpected storage errors are not mistaken for stale bindings", async (t) => { const context = await fixture(t); - await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); + const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }); + const targetKey = checkoutTargetKey(await realpath(context.project)); context.closeStore(context.store); await assert.rejects( () => context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), - (error: unknown) => error instanceof Error && /database connection is not open/i.test(error.message), + ); + + const restoredStore = context.openStore(); + assert.equal( + restoredStore.getConversationBinding("chat-1", targetKey)?.workspaceSessionId, + first.workspace.id, ); }); From 8e6e7cc495258c4d11e68ea4edc072a7306679e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:51:22 +0530 Subject: [PATCH 51/59] fix(workspace): scope bootstrap suppression to reused checkouts --- src/workspace-conversation.test.ts | 65 +++--------------------------- src/workspaces.ts | 15 ++++--- 2 files changed, 12 insertions(+), 68 deletions(-) diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 02c876c8..5af9f991 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -12,16 +12,12 @@ import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); -test("a conversation reuses its checkout and receives bootstrap once", async (t) => { +test("a conversation reuses its checkout context", 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(first.workspaceReused, false); - assert.equal(first.includeBootstrapContext, true); - assert.equal(second.workspaceReused, true); - assert.equal(second.includeBootstrapContext, false); assert.equal(second.workspace.id, first.workspace.id); assert.deepEqual(second.agentsFiles, first.agentsFiles); assert.deepEqual(second.availableAgentsFiles, first.availableAgentsFiles); @@ -37,13 +33,9 @@ test("different conversations receive separate checkout workspaces", async (t) = 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); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); }); -test("a conversation can bootstrap each canonical project once", async (t) => { +test("conversation bindings distinguish canonical projects", async (t) => { const { root, project, registry } = await fixture(t); const otherProject = join(root, "other-project"); await mkdir(otherProject); @@ -62,16 +54,12 @@ test("a conversation can bootstrap each canonical project once", async (t) => { conversationScopeId: "chat-1", }); - assert.equal(firstProjectOpen.includeBootstrapContext, true); - assert.equal(otherProjectOpen.includeBootstrapContext, true); - assert.equal(repeatedProjectOpen.includeBootstrapContext, false); - assert.equal(repeatedOtherProjectOpen.includeBootstrapContext, false); assert.equal(repeatedProjectOpen.workspace.id, firstProjectOpen.workspace.id); assert.equal(repeatedOtherProjectOpen.workspace.id, otherProjectOpen.workspace.id); assert.notEqual(otherProjectOpen.workspace.id, firstProjectOpen.workspace.id); }); -test("concurrent checkout opens reuse one workspace and claim bootstrap once", async (t) => { +test("concurrent checkout opens reuse one workspace and return matching context", async (t) => { const { project, registry } = await fixture(t); const opens = await Promise.all([ @@ -80,8 +68,6 @@ test("concurrent checkout opens reuse one workspace and claim bootstrap once", a ]); assert.equal(new Set(opens.map((open) => open.workspace.id)).size, 1); - assert.equal(opens.filter((open) => open.workspaceReused).length, 1); - assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); assert.deepEqual(opens[0].agentsFiles, opens[1].agentsFiles); assert.deepEqual(opens[0].availableAgentsFiles, opens[1].availableAgentsFiles); }); @@ -93,10 +79,6 @@ test("a checkout without a conversation scope does not use conversation reuse", const second = await registry.openWorkspace(project); assert.notEqual(second.workspace.id, first.workspace.id); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); - assert.equal(first.includeBootstrapContext, true); - assert.equal(second.includeBootstrapContext, true); }); test("worktree requests remain fresh without replacing the reusable checkout", async (t) => { @@ -112,16 +94,9 @@ test("worktree requests remain fresh without replacing the reusable checkout", a }); const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.equal(checkout.includeBootstrapContext, true); - assert.equal(firstWorktree.includeBootstrapContext, false); - assert.equal(secondWorktree.includeBootstrapContext, false); - assert.equal(firstWorktree.workspaceReused, false); - assert.equal(secondWorktree.workspaceReused, false); assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); - assert.equal(checkoutAgain.workspaceReused, true); - assert.equal(checkoutAgain.includeBootstrapContext, false); }); test("a worktree-first conversation creates and then reuses its checkout", async (t) => { @@ -134,18 +109,12 @@ test("a worktree-first conversation creates and then reuses its checkout", async const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.equal(worktree.includeBootstrapContext, true); - assert.equal(worktree.workspaceReused, false); - assert.equal(checkout.includeBootstrapContext, false); - assert.equal(checkout.workspaceReused, false); assert.equal(checkout.workspace.mode, "checkout"); assert.notEqual(checkout.workspace.id, worktree.workspace.id); - assert.equal(checkoutAgain.includeBootstrapContext, false); - assert.equal(checkoutAgain.workspaceReused, true); assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); }); -test("concurrent worktree opens claim bootstrap exactly once and return complete context", async (t) => { +test("concurrent worktree opens remain fresh and return complete context", async (t) => { const { project, registry } = await fixture(t, { git: true }); const worktreeInput = { path: project, mode: "worktree" as const }; @@ -154,9 +123,6 @@ test("concurrent worktree opens claim bootstrap exactly once and return complete registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1" }), ]); - assert.equal([first, second].filter((open) => open.includeBootstrapContext).length, 1); - assert.equal(first.workspaceReused, false); - assert.equal(second.workspaceReused, false); assert.notEqual(first.workspace.id, second.workspace.id); assert.notEqual(first.workspace.root, second.workspace.root); assert.deepEqual( @@ -183,8 +149,6 @@ test("checkout reuse survives a registry restart", async (t) => { }); assert.equal(restored.workspace.id, first.workspace.id); - assert.equal(restored.workspaceReused, true); - assert.equal(restored.includeBootstrapContext, false); }); test("a failed first context load does not consume bootstrap", async (t) => { @@ -203,8 +167,6 @@ test("a failed first context load does not consume bootstrap", async (t) => { } const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); - assert.equal(successfulOpen.includeBootstrapContext, true); - assert.equal(successfulOpen.workspaceReused, false); }); test("a context-loading failure preserves a valid checkout binding", async (t) => { @@ -225,11 +187,9 @@ test("a context-loading failure preserves a valid checkout binding", async (t) = 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 bootstrap", async (t) => { +test("a deleted checkout is replaced with a new workspace", async (t) => { const { project, registry } = await fixture(t); const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); @@ -237,8 +197,6 @@ test("a deleted checkout is replaced without repeating bootstrap", async (t) => 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); }); @@ -250,10 +208,7 @@ test("canonical checkout identity remains stable when the requested target start const second = await registry.openWorkspace(missingTarget, { conversationScopeId: "chat-1" }); assert.equal(first.workspace.root, missingTarget); - assert.equal(first.includeBootstrapContext, true); assert.equal(second.workspace.id, first.workspace.id); - assert.equal(second.workspaceReused, true); - assert.equal(second.includeBootstrapContext, false); }); test("canonical checkout identity survives equivalent path and symlink aliases", async (t) => { @@ -265,8 +220,6 @@ test("canonical checkout identity survives equivalent path and symlink aliases", }); assert.equal(equivalent.workspace.id, direct.workspace.id); - assert.equal(equivalent.workspaceReused, true); - assert.equal(equivalent.includeBootstrapContext, false); if (platform() === "win32") return; @@ -275,8 +228,6 @@ test("canonical checkout identity survives equivalent path and symlink aliases", const aliased = await registry.openWorkspace(alias, { conversationScopeId: "chat-1" }); assert.equal(aliased.workspace.id, direct.workspace.id); - assert.equal(aliased.workspaceReused, true); - assert.equal(aliased.includeBootstrapContext, false); }); test("canonical checkout identity survives macOS var path aliases", { skip: platform() !== "darwin" }, async (t) => { @@ -310,8 +261,6 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat ); assert.equal(aliased.workspace.id, direct.workspace.id); - assert.equal(aliased.workspaceReused, true); - assert.equal(aliased.includeBootstrapContext, false); }); test("an invalid persisted checkout binding is not reused", async (t) => { @@ -337,8 +286,6 @@ test("an invalid persisted checkout binding is not reused", async (t) => { }); assert.notEqual(replacement.workspace.id, first.workspace.id); - assert.equal(replacement.workspaceReused, false); - assert.equal(replacement.includeBootstrapContext, false); }); test("an inactive persisted checkout binding is not reused", async (t) => { @@ -363,8 +310,6 @@ test("an inactive persisted checkout binding is not reused", async (t) => { }); assert.notEqual(replacement.workspace.id, first.workspace.id); - assert.equal(replacement.workspaceReused, false); - assert.equal(replacement.includeBootstrapContext, false); }); test("a checkout replaced by a file reports the filesystem error", async (t) => { diff --git a/src/workspaces.ts b/src/workspaces.ts index a6d0a15d..4909e752 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -113,10 +113,8 @@ export class WorkspaceRegistry { const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); return { ...context, - includeBootstrapContext: this.store.claimConversationBootstrap( - conversationScopeId, - projectKey, - ), + // A new worktree always has its own workspace-specific context. + includeBootstrapContext: true, }; } @@ -174,8 +172,7 @@ export class WorkspaceRegistry { this.store?.touchConversationBinding(conversationScopeId, targetKey); return { ...context, - includeBootstrapContext: - this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + includeBootstrapContext: false, }; } @@ -189,10 +186,12 @@ export class WorkspaceRegistry { targetKey, workspaceSessionId: context.workspace.id, }); + // Keep the durable project-level delivery record for migration and + // bookkeeping, but derive response suppression from actual reuse. + this.store?.claimConversationBootstrap(conversationScopeId, projectKey); return { ...context, - includeBootstrapContext: - this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + includeBootstrapContext: true, }; } From 44fb31e6f732d834a7ed63fba8d00710c6a552a6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:51:27 +0530 Subject: [PATCH 52/59] refactor(server): hide workspace lifecycle flags from models --- package.json | 2 +- src/server.test.ts | 233 ++++++++++++++++++++++++++++++++++++ src/server.ts | 32 ++--- src/ui/tool-display.test.ts | 2 + src/ui/tool-display.ts | 7 +- 5 files changed, 257 insertions(+), 19 deletions(-) create mode 100644 src/server.test.ts diff --git a/package.json b/package.json index d2812a84..42960567 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/workspace-conversation.test.ts && tsx src/workspace-store.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/workspace-store.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 00000000..4b625e67 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, 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 { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { loadConfig } from "./config.js"; +import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { ProcessSessionManager } from "./process-sessions.js"; +import { createMcpServer } from "./server.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +const execFileAsync = promisify(execFile); + +test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { + const context = await fixture(t); + const first = await callOpen(context.client, context.project, "chat-1"); + const repeated = await callOpen(context.client, context.project, "chat-1"); + + const tools = await context.client.listTools(); + const openTool = tools.tools.find((tool) => tool.name === "open_workspace"); + const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; + assert.equal(outputProperties && "workspaceReused" in outputProperties, false); + assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); + + const firstStructured = structuredContent(first); + assert.equal(firstStructured.workspaceId, structuredContent(repeated).workspaceId); + assert.ok(Array.isArray(firstStructured.agentsFiles)); + assert.ok(Array.isArray(firstStructured.availableAgentsFiles)); + assert.ok(Array.isArray(firstStructured.skills)); + assert.ok(Array.isArray(firstStructured.agentProviders)); + assert.ok(Array.isArray(firstStructured.agents)); + assert.ok(Array.isArray(firstStructured.skillDiagnostics)); + assert.equal("workspaceReused" in firstStructured, false); + assert.equal("includeBootstrapContext" in firstStructured, false); + + const repeatedStructured = structuredContent(repeated); + assert.equal(repeatedStructured.agentsFiles, undefined); + assert.equal(repeatedStructured.availableAgentsFiles, undefined); + assert.equal(repeatedStructured.skills, undefined); + assert.equal(repeatedStructured.agentProviders, undefined); + assert.equal(repeatedStructured.agents, undefined); + assert.equal(repeatedStructured.skillDiagnostics, undefined); + assert.equal("workspaceReused" in repeatedStructured, false); + assert.equal("includeBootstrapContext" in repeatedStructured, false); + + const repeatedText = responseText(repeated); + assert.match(repeatedText, /Workspace already open as/); + assert.match(repeatedText, /same checkout previously opened/); + assert.match(repeatedText, /Reuse this workspaceId for subsequent tool calls/); + assert.match(repeatedText, /previously provided for this workspace/); + assert.match(repeatedText, /not repeated here/); + + const card = responseCard(repeated); + assert.equal(card.workspaceReused, true); + assert.equal(card.includeBootstrapContext, false); + assert.ok(Array.isArray(card.agentsFiles)); + assert.ok(Array.isArray(card.availableAgentsFiles)); + assert.ok(Array.isArray(card.skills)); + assert.ok(Array.isArray(card.agentProviders)); + assert.ok(Array.isArray(card.agents)); + assert.ok(Array.isArray(card.skillDiagnostics)); +}); + +test("new worktrees always receive a fresh workspace and complete worktree context", async (t) => { + const context = await fixture(t, { git: true }); + const checkout = await callOpen(context.client, context.project, "chat-1"); + const firstWorktree = await callOpen(context.client, context.project, "chat-1", "worktree"); + const secondWorktree = await callOpen(context.client, context.project, "chat-1", "worktree"); + const checkoutAgain = await callOpen(context.client, context.project, "chat-1"); + + assert.notEqual(structuredContent(firstWorktree).workspaceId, structuredContent(secondWorktree).workspaceId); + assert.equal(structuredContent(checkoutAgain).workspaceId, structuredContent(checkout).workspaceId); + for (const result of [firstWorktree, secondWorktree]) { + const structured = structuredContent(result); + assert.equal(structured.mode, "worktree"); + assert.ok(Array.isArray(structured.agentsFiles)); + assert.ok(Array.isArray(structured.availableAgentsFiles)); + assert.ok(Array.isArray(structured.skills)); + assert.ok(Array.isArray(structured.agentProviders)); + assert.ok(Array.isArray(structured.agents)); + assert.ok(Array.isArray(structured.skillDiagnostics)); + assert.match(responseText(result), /Opened isolated worktree workspace/); + } + assert.equal(structuredContent(checkoutAgain).agentsFiles, undefined); + assert.match(responseText(checkoutAgain), /same checkout previously opened/); +}); + +test("checkout opened after a worktree receives its own complete context", async (t) => { + const context = await fixture(t, { git: true }); + const worktree = await callOpen(context.client, context.project, "chat-1", "worktree"); + const checkout = await callOpen(context.client, context.project, "chat-1"); + const checkoutAgain = await callOpen(context.client, context.project, "chat-1"); + + assert.equal(structuredContent(worktree).mode, "worktree"); + assert.ok(Array.isArray(structuredContent(worktree).agentsFiles)); + assert.equal(structuredContent(checkout).mode, "checkout"); + assert.ok(Array.isArray(structuredContent(checkout).agentsFiles)); + assert.equal(structuredContent(checkoutAgain).workspaceId, structuredContent(checkout).workspaceId); + assert.equal(structuredContent(checkoutAgain).agentsFiles, undefined); + assert.match(responseText(checkoutAgain), /same checkout previously opened/); +}); + +test("a host without conversation metadata receives normal explicit-workspace behavior", async (t) => { + const context = await fixture(t); + const first = await callOpen(context.client, context.project); + const second = await callOpen(context.client, context.project); + + assert.notEqual(structuredContent(first).workspaceId, structuredContent(second).workspaceId); + assert.ok(Array.isArray(structuredContent(first).agentsFiles)); + assert.ok(Array.isArray(structuredContent(second).agentsFiles)); + assert.doesNotMatch(responseText(first), /conversation metadata/i); + assert.doesNotMatch(responseText(second), /conversation metadata/i); +}); + +interface ServerFixture { + client: Client; + project: string; +} + +async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); + const project = join(root, "project"); + const agentDir = join(root, "agent"); + const stateDir = join(root, ".state"); + + 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 writeFile(join(project, "README.md"), "hello\n"); + await git(project, ["init"]); + await git(project, ["config", "user.email", "devspace@example.com"]); + await git(project, ["config", "user.name", "DevSpace Test"]); + await git(project, ["add", "."]); + await git(project, ["commit", "-m", "Initial commit"]); + } + + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_WIDGETS: "full", + DEVSPACE_TOOL_MODE: "full", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const store = new SqliteWorkspaceStore(stateDir); + const workspaces = new WorkspaceRegistry(config, store); + const server = createMcpServer( + config, + workspaces, + createReviewCheckpointManager(), + new ProcessSessionManager(), + [], + [], + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + t.after(async () => { + await client.close(); + await server.close(); + store.close(); + await rm(root, { recursive: true, force: true }); + }); + + return { client, project }; +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} + +async function callOpen( + client: Client, + path: string, + conversationScopeId?: string, + mode?: "checkout" | "worktree", +): Promise>> { + const params = { + name: "open_workspace", + arguments: { + path, + ...(mode ? { mode } : {}), + }, + ...(conversationScopeId + ? { _meta: { "openai/session": conversationScopeId } } + : {}), + } as Parameters[0]; + return client.callTool(params); +} + +function structuredContent(result: Awaited>): Record { + assert.ok(result.structuredContent); + return result.structuredContent as Record; +} + +function responseText(result: Awaited>): string { + const content = (result as { content?: unknown }).content; + assert.ok(Array.isArray(content)); + const first = content[0] as { type?: unknown; text?: unknown } | undefined; + assert.equal(first?.type, "text"); + assert.equal(typeof first?.text, "string"); + return first?.text as string; +} + +function responseCard(result: Awaited>): Record { + const metadata = result._meta; + assert.ok(metadata && typeof metadata === "object"); + const card = (metadata as Record).card; + assert.ok(card && typeof card === "object"); + return card as Record; +} diff --git a/src/server.ts b/src/server.ts index 6ffaf196..bfb8fb4f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -197,7 +197,7 @@ function serverInstructions(config: ServerConfig): string { : ""; if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Checkout mode can reuse the conversation-scoped workspace when the host provides that optional context; otherwise continue using the returned workspaceId. Each worktree-mode open creates a new isolated worktree. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Open it again when the workspaceId is invalid, the project changes, checkout/worktree mode changes, or another isolated worktree is needed. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -694,7 +694,7 @@ function registerCodexProcessTools( ); } -function createMcpServer( +export function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, @@ -774,8 +774,6 @@ function createMcpServer( workspaceId: z.string(), root: z.string(), mode: z.enum(["checkout", "worktree"]), - workspaceReused: z.boolean(), - includeBootstrapContext: z.boolean(), sourceRoot: z.string().optional(), worktree: z .object({ @@ -810,7 +808,6 @@ function createMcpServer( { path, mode, baseRef }, { conversationScopeId: openAiConversationScopeId(_meta) }, ); - const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, @@ -849,21 +846,26 @@ 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, 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 - ? "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 instruction = workspaceReused + ? [ + `Workspace already open as ${workspace.id}.`, + "Reuse this workspaceId for subsequent tool calls. This is the same checkout previously opened for this project in this conversation.", + "Continue following the project instructions, nested instruction files, skills, agent profiles, and diagnostics previously provided for this workspace. They remain the active workspace context and are not repeated here.", + ].join("\n\n") + : workspace.mode === "worktree" + ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree." + : cardInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `${workspaceReused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, + workspaceReused + ? `Workspace already open as ${workspace.id}.` + : workspace.mode === "worktree" + ? `Opened isolated worktree workspace ${workspace.id}.` + : `Opened workspace ${workspace.id}.`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, - 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(", ")}` : undefined, @@ -929,8 +931,6 @@ function createMcpServer( workspaceId: workspace.id, root: workspace.root, mode: workspace.mode, - workspaceReused, - includeBootstrapContext, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, ...(includeBootstrapContext diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index b9977ac8..86b67271 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -5,6 +5,8 @@ import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened worktree", tone: "workspace" }], [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 7a847631..f9706690 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -27,7 +27,11 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "open_workspace": return { icon: toolIcons.folderOpen, - title: "Opened workspace", + title: card.workspaceReused + ? "Reused workspace" + : card.mode === "worktree" + ? "Opened worktree" + : "Opened workspace", label: card.root ?? card.path, tone: "workspace", }; @@ -197,4 +201,3 @@ function durationLabel(durationMs: number | undefined): string | undefined { if (durationMs < 1_000) return `${Math.round(durationMs)}ms`; return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)}s`; } - From b964909479280acddf83ff355bde59543f29f006 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:51:36 +0530 Subject: [PATCH 53/59] docs(workspace): explain context-aware workspace recovery --- docs/chatgpt-coding-workflow.md | 26 +++++++++++++++----------- docs/gotchas.md | 13 ++++++++----- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 54a5c473..d1a51385 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,21 +17,25 @@ 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`. -When ChatGPT opens the same checkout project again in the same conversation, -DevSpace can continue in the existing checkout workspace. This is a convenience -for continuing work; the portable workflow remains the same: keep using the -`workspaceId` returned by `open_workspace` for later operations. Hosts that do -not provide conversation context continue with that explicit `workspaceId` -workflow. +ChatGPT may support automatic checkout recovery through optional host +conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP +conversation field. When that optional context is available, opening the same +checkout project again in the same conversation can continue in the existing +workspace, and the context already provided for that workspace is not repeated. +The portable workflow remains the same: keep using the `workspaceId` returned by +`open_workspace` for later operations. Hosts without supported conversation +context receive a normal new workspace and continue with that explicit +`workspaceId` workflow. 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. -The first successful open of a project in a conversation provides its initial -instructions and coding context. Later opens of that project avoid repeating the -same setup guidance, including when switching between checkout and worktree -mode. The workspace UI continues to show the full details for the current -workspace. +The first open of a checkout provides its complete instructions and coding +context. A repeated open that reuses that same checkout workspace does not repeat +the model-visible context, but the workspace UI continues to show the complete +details. Every new worktree establishes and returns its own complete context, +even when the same project was already opened in checkout or another worktree. +Opening checkout after a worktree therefore provides the checkout's own context. Do not call `open_workspace` again for the same checkout folder unless: diff --git a/docs/gotchas.md b/docs/gotchas.md index a3303791..59968fe6 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -136,11 +136,14 @@ npx @waishnav/devspace init --force client receives an unknown workspace error, call `open_workspace` again for that project. -Workspace session metadata is persisted. In ChatGPT, opening the same checkout -project again in the same conversation can resume the existing workspace; -worktree mode always creates a new isolated workspace. In all cases, continue -passing the `workspaceId` returned by `open_workspace` to later tools. Other MCP -hosts use this explicit workspace workflow as well. +Workspace session metadata is persisted. ChatGPT may provide optional +conversation metadata that lets DevSpace resume the same checkout workspace for +the same project in that conversation; repeated opens reuse the `workspaceId` +and do not repeat context already provided for that workspace. Worktree mode +always creates a new isolated workspace with its own context. Hosts without +supported conversation metadata receive a normal new workspace. In all cases, +continue passing the `workspaceId` returned by `open_workspace` to later tools. +Other MCP hosts use this explicit workspace workflow as well. To review work, call `show_changes` once after the final related file change. It shows the combined changes and advances the review point automatically. From d80699452e1357483cca85a4835207892e64989b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:52:55 +0530 Subject: [PATCH 54/59] test(server): cover concurrent workspace responses --- src/server.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/server.test.ts b/src/server.test.ts index 4b625e67..eb0fe731 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -66,6 +66,24 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.skillDiagnostics)); }); +test("concurrent checkout opens return one full context and one reuse instruction", async (t) => { + const context = await fixture(t); + const [first, second] = await Promise.all([ + callOpen(context.client, context.project, "chat-1"), + callOpen(context.client, context.project, "chat-1"), + ]); + + assert.equal(structuredContent(first).workspaceId, structuredContent(second).workspaceId); + assert.equal( + [first, second].filter((result) => Array.isArray(structuredContent(result).agentsFiles)).length, + 1, + ); + assert.equal( + [first, second].filter((result) => responseText(result).includes("Workspace already open as")).length, + 1, + ); +}); + test("new worktrees always receive a fresh workspace and complete worktree context", async (t) => { const context = await fixture(t, { git: true }); const checkout = await callOpen(context.client, context.project, "chat-1"); From 6a326342872e2524b41c2e3b17bcb74154de10ed Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:52:55 +0530 Subject: [PATCH 55/59] docs(workspace): name retained conversation records --- docs/gotchas.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/gotchas.md b/docs/gotchas.md index 59968fe6..9ba9393f 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -150,9 +150,10 @@ shows the combined changes and advances the review point automatically. ## Data Retention -DevSpace does not currently expire old workspace, conversation-resume, or review -history data automatically. A future retention policy will define cleanup; no -automatic deletion is performed today. +DevSpace does not currently prune workspace sessions, conversation bindings, +conversation bootstrap records, or review refs. A future product retention +policy will define safe cleanup for these records; no automatic deletion is +performed today. ## Workspace Path Rejected From e32504c9489dcd8ef3c70680a44f6de859ea9088 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:53:30 +0530 Subject: [PATCH 56/59] docs(workspace): keep reuse bookkeeping out of workflow guidance --- docs/chatgpt-coding-workflow.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index d1a51385..23b629c3 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -26,6 +26,8 @@ The portable workflow remains the same: keep using the `workspaceId` returned by `open_workspace` for later operations. Hosts without supported conversation context receive a normal new workspace and continue with that explicit `workspaceId` workflow. +The model receives actionable workspace instructions; automatic-reuse bookkeeping +is not a model-facing choice. 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. From 72aa34e5c88c154b62852d7336ada063724a785f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 21:55:45 +0530 Subject: [PATCH 57/59] test(server): verify reuse after restart --- src/server.test.ts | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index eb0fe731..5a205023 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -7,7 +7,7 @@ import test, { type TestContext } from "node:test"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { loadConfig } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; @@ -135,9 +135,43 @@ test("a host without conversation metadata receives normal explicit-workspace be assert.doesNotMatch(responseText(second), /conversation metadata/i); }); +test("checkout reuse and context suppression survive a registry restart", async (t) => { + const context = await fixture(t); + const first = await callOpen(context.client, context.project, "chat-1"); + const firstWorkspaceId = structuredContent(first).workspaceId; + + const restoredStore = new SqliteWorkspaceStore(context.stateDir); + const restoredServer = createMcpServer( + context.config, + new WorkspaceRegistry(context.config, restoredStore), + createReviewCheckpointManager(), + new ProcessSessionManager(), + [], + [], + ); + const [restoredClientTransport, restoredServerTransport] = InMemoryTransport.createLinkedPair(); + const restoredClient = new Client({ name: "devspace-restored-test-client", version: "1.0.0" }); + await Promise.all([ + restoredClient.connect(restoredClientTransport), + restoredServer.connect(restoredServerTransport), + ]); + t.after(async () => { + await restoredClient.close(); + await restoredServer.close(); + restoredStore.close(); + }); + + const restored = await callOpen(restoredClient, context.project, "chat-1"); + assert.equal(structuredContent(restored).workspaceId, firstWorkspaceId); + assert.equal(structuredContent(restored).agentsFiles, undefined); + assert.match(responseText(restored), /same checkout previously opened/); +}); + interface ServerFixture { client: Client; project: string; + config: ServerConfig; + stateDir: string; } async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { @@ -202,7 +236,7 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise await rm(root, { recursive: true, force: true }); }); - return { client, project }; + return { client, project, config, stateDir }; } async function git(cwd: string, args: string[]): Promise { From 4a8e15fc50a6f9e2b34daf39067bd8d353ca24e6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 22:01:03 +0530 Subject: [PATCH 58/59] test(server): close restart fixtures before cleanup --- src/server.test.ts | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 5a205023..33f2a871 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -140,6 +140,8 @@ test("checkout reuse and context suppression survive a registry restart", async const first = await callOpen(context.client, context.project, "chat-1"); const firstWorkspaceId = structuredContent(first).workspaceId; + await context.close(); + const restoredStore = new SqliteWorkspaceStore(context.stateDir); const restoredServer = createMcpServer( context.config, @@ -151,20 +153,29 @@ test("checkout reuse and context suppression survive a registry restart", async ); const [restoredClientTransport, restoredServerTransport] = InMemoryTransport.createLinkedPair(); const restoredClient = new Client({ name: "devspace-restored-test-client", version: "1.0.0" }); - await Promise.all([ - restoredClient.connect(restoredClientTransport), - restoredServer.connect(restoredServerTransport), - ]); - t.after(async () => { + let restoredClosed = false; + const closeRestored = async () => { + if (restoredClosed) return; + restoredClosed = true; await restoredClient.close(); await restoredServer.close(); restoredStore.close(); - }); + }; + t.after(closeRestored); - const restored = await callOpen(restoredClient, context.project, "chat-1"); - assert.equal(structuredContent(restored).workspaceId, firstWorkspaceId); - assert.equal(structuredContent(restored).agentsFiles, undefined); - assert.match(responseText(restored), /same checkout previously opened/); + try { + await Promise.all([ + restoredClient.connect(restoredClientTransport), + restoredServer.connect(restoredServerTransport), + ]); + + const restored = await callOpen(restoredClient, context.project, "chat-1"); + assert.equal(structuredContent(restored).workspaceId, firstWorkspaceId); + assert.equal(structuredContent(restored).agentsFiles, undefined); + assert.match(responseText(restored), /same checkout previously opened/); + } finally { + await closeRestored(); + } }); interface ServerFixture { @@ -172,6 +183,7 @@ interface ServerFixture { project: string; config: ServerConfig; stateDir: string; + close: () => Promise; } async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { @@ -229,14 +241,21 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise server.connect(serverTransport), ]); - t.after(async () => { + let closed = false; + const close = async () => { + if (closed) return; + closed = true; await client.close(); await server.close(); store.close(); + }; + + t.after(async () => { + await close(); await rm(root, { recursive: true, force: true }); }); - return { client, project, config, stateDir }; + return { client, project, config, stateDir, close }; } async function git(cwd: string, args: string[]): Promise { From a13a24efa8b65a3071248339d208836649551308 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 4 Aug 2026 22:18:43 +0530 Subject: [PATCH 59/59] refactor(workspace): remove unused bootstrap ledger --- docs/chatgpt-coding-workflow.md | 32 ++++++------ docs/gotchas.md | 15 +++--- package.json | 2 +- src/db/migrations.ts | 59 --------------------- src/db/schema.ts | 15 ------ src/oauth-store.test.ts | 1 - src/workspace-store.test.ts | 90 --------------------------------- src/workspace-store.ts | 33 ------------ src/workspaces.ts | 5 -- 9 files changed, 25 insertions(+), 227 deletions(-) delete mode 100644 src/workspace-store.test.ts diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 23b629c3..ac2bdc7c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -21,23 +21,25 @@ ChatGPT may support automatic checkout recovery through optional host conversation metadata. This is an OpenAI-host adapter detail, not a standard MCP conversation field. When that optional context is available, opening the same checkout project again in the same conversation can continue in the existing -workspace, and the context already provided for that workspace is not repeated. -The portable workflow remains the same: keep using the `workspaceId` returned by -`open_workspace` for later operations. Hosts without supported conversation -context receive a normal new workspace and continue with that explicit -`workspaceId` workflow. -The model receives actionable workspace instructions; automatic-reuse bookkeeping -is not a model-facing choice. +workspace, and the context already provided for that reused checkout is not +repeated. The portable workflow remains the same: keep using the `workspaceId` +returned by `open_workspace` for later operations. Hosts without supported +conversation context receive a normal new workspace and continue with that +explicit `workspaceId` workflow. +The model receives actionable workspace instructions; automatic-reuse +bookkeeping is not a model-facing choice. 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. - -The first open of a checkout provides its complete instructions and coding -context. A repeated open that reuses that same checkout workspace does not repeat -the model-visible context, but the workspace UI continues to show the complete -details. Every new worktree establishes and returns its own complete context, -even when the same project was already opened in checkout or another worktree. -Opening checkout after a worktree therefore provides the checkout's own context. +worktree and a new workspace session with complete context, even for the same +path and base ref. + +The first successful open of a checkout provides complete instructions and +coding context. A repeated open that reuses the same checkout workspace does +not repeat the model-visible context, but the workspace UI continues to show the +complete details. Every new worktree establishes and returns its own complete +context, even when the same project was already opened in checkout or another +worktree. Opening checkout after a worktree therefore provides the checkout's +own context. Do not call `open_workspace` again for the same checkout folder unless: diff --git a/docs/gotchas.md b/docs/gotchas.md index 9ba9393f..639f54a9 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -139,11 +139,11 @@ project. Workspace session metadata is persisted. ChatGPT may provide optional conversation metadata that lets DevSpace resume the same checkout workspace for the same project in that conversation; repeated opens reuse the `workspaceId` -and do not repeat context already provided for that workspace. Worktree mode -always creates a new isolated workspace with its own context. Hosts without -supported conversation metadata receive a normal new workspace. In all cases, -continue passing the `workspaceId` returned by `open_workspace` to later tools. -Other MCP hosts use this explicit workspace workflow as well. +and do not repeat context already provided for that reused checkout. Worktree +mode always creates a new isolated workspace with its own complete context. +Hosts without supported conversation metadata receive a normal new workspace. +In all cases, continue passing the `workspaceId` returned by `open_workspace` to +later tools. Other MCP hosts use this explicit workspace workflow as well. To review work, call `show_changes` once after the final related file change. It shows the combined changes and advances the review point automatically. @@ -151,9 +151,8 @@ shows the combined changes and advances the review point automatically. ## Data Retention DevSpace does not currently prune workspace sessions, conversation bindings, -conversation bootstrap records, or review refs. A future product retention -policy will define safe cleanup for these records; no automatic deletion is -performed today. +or review refs. A future product retention policy will define safe cleanup for +these records; no automatic deletion is performed today. ## Workspace Path Rejected diff --git a/package.json b/package.json index 42960567..59e23330 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/workspace-conversation.test.ts && tsx src/workspace-store.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.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/server.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 058b71d5..1c5c3298 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,11 +27,6 @@ const migrations: Migration[] = [ name: "workspace-conversation-bindings", up: migrateWorkspaceConversationBindings, }, - { - version: 5, - name: "workspace-conversation-bootstraps", - up: migrateWorkspaceConversationBootstraps, - }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -203,60 +198,6 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { `); } -function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void { - sqlite.exec(` - create table if not exists workspace_conversation_bootstraps ( - 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_id, project_key) - ); - `); - - 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; - created_at: string; - last_used_at: string; - }>; - const insertBootstrap = sqlite.prepare(` - insert or ignore into workspace_conversation_bootstraps ( - conversation_scope_id, - 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_id, - projectKey, - binding.created_at, - binding.last_used_at, - ); - } -} - -// 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; - 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 dd87ab5b..215c6c1a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,19 +55,6 @@ export const workspaceConversationBindings = sqliteTable( ], ); -export const workspaceConversationBootstraps = sqliteTable( - "workspace_conversation_bootstraps", - { - 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.conversationScopeId, table.projectKey] }), - ], -); - export const oauthClients = sqliteTable( "oauth_clients", { @@ -133,7 +120,5 @@ 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 fe69797f..e47f8121 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,7 +45,6 @@ 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.test.ts b/src/workspace-store.test.ts deleted file mode 100644 index c79c8c0f..00000000 --- a/src/workspace-store.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test, { type TestContext } from "node:test"; -import { openDatabase } from "./db/client.js"; -import { SqliteWorkspaceStore } from "./workspace-store.js"; - -test("migrated bootstrap history suppresses repeats without blocking another project", async (t) => { - const stateDir = await createLegacyBindingState(t); - const store = new SqliteWorkspaceStore(stateDir); - - try { - assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/project"), false); - assert.equal(store.claimConversationBootstrap("chat-existing", "/tmp/other-project"), true); - } finally { - store.close(); - } -}); - -test("migration preserves its deterministic timestamp choice for duplicate historical targets", async (t) => { - const stateDir = await createLegacyBindingState(t); - const migrated = openDatabase(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-03T00:00:00.000Z", - }], - ); - } finally { - migrated.close(); - } -}); - -async function createLegacyBindingState(t: TestContext): Promise { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-workspace-store-test-")); - t.after(() => rm(stateDir, { recursive: true, force: true })); - - 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_id, 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-04T00:00:00.000Z", - ); - initial.sqlite.prepare(` - insert into workspace_conversation_bindings ( - conversation_scope_id, target_key, workspace_session_id, created_at, last_used_at - ) values (?, ?, ?, ?, ?) - `).run( - "chat-existing", - JSON.stringify(["checkout", "/tmp/project", null]), - "ws_existing", - "2026-01-01T00:00:00.000Z", - "2026-01-03T00:00:00.000Z", - ); - initial.sqlite.exec(` - drop table workspace_conversation_bootstraps; - delete from devspace_schema_migrations where version = 5; - `); - } finally { - initial.close(); - } - - return stateDir; -} diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 6fa773ea..88a70e2e 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,7 +1,6 @@ import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { - workspaceConversationBootstraps, workspaceConversationBindings, workspaceSessions, type WorkspaceConversationBindingRow, @@ -54,7 +53,6 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeId: string, targetKey: string): void; deleteConversationBinding(conversationScopeId: string, targetKey: string): void; - claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean; close?(): void; } @@ -203,37 +201,6 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } - claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean { - const now = new Date().toISOString(); - return this.database.db.transaction((transaction) => { - const [inserted] = transaction - .insert(workspaceConversationBootstraps) - .values({ - conversationScopeId, - projectKey, - createdAt: now, - lastUsedAt: now, - }) - .onConflictDoNothing() - .returning() - .all(); - - if (inserted) return true; - - transaction - .update(workspaceConversationBootstraps) - .set({ lastUsedAt: now }) - .where( - and( - eq(workspaceConversationBootstraps.conversationScopeId, conversationScopeId), - eq(workspaceConversationBootstraps.projectKey, projectKey), - ), - ) - .run(); - return false; - }); - } - close(): void { this.database.close(); } diff --git a/src/workspaces.ts b/src/workspaces.ts index 4909e752..fa8374a0 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -134,7 +134,6 @@ export class WorkspaceRegistry { workspaceInput, conversationScopeId, targetKey, - projectKey, ); this.pendingCheckoutOpens.set(operationKey, open); @@ -161,7 +160,6 @@ export class WorkspaceRegistry { input: OpenWorkspaceInput, conversationScopeId: string, targetKey: string, - projectKey: string, ): Promise { const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); if (binding) { @@ -186,9 +184,6 @@ export class WorkspaceRegistry { targetKey, workspaceSessionId: context.workspace.id, }); - // Keep the durable project-level delivery record for migration and - // bookkeeping, but derive response suppression from actual reuse. - this.store?.claimConversationBootstrap(conversationScopeId, projectKey); return { ...context, includeBootstrapContext: true,