diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf3d7ea..ac9f895b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: pull_request: jobs: - smoke: - name: Smoke (${{ matrix.os }}) + verify: + name: Verify (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..0279a083 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,12 +17,28 @@ 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`. -Do not reopen the same folder unless: +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 +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 call `open_workspace` again for 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 +- work moves to a different project folder +- work switches between checkout and worktree mode +- the user asks for a new isolated worktree ## Checkout Mode @@ -56,6 +72,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/package.json b/package.json index 4489360e..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/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/db/migrations.ts b/src/db/migrations.ts index 2bda20c2..058b71d5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,16 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "workspace-conversation-bindings", + up: migrateWorkspaceConversationBindings, + }, + { + version: 5, + name: "workspace-conversation-bootstraps", + up: migrateWorkspaceConversationBootstraps, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,6 +184,79 @@ 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_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_id, 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 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 01d13fac..dd87ab5b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,36 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceConversationBindings = sqliteTable( + "workspace_conversation_bindings", + { + conversationScopeId: text("conversation_scope_id").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.conversationScopeId, table.targetKey] }), + index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId), + ], +); + +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", { @@ -101,5 +131,9 @@ 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 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 e1c00338..26ee3608 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -7,6 +7,7 @@ import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/ import { databasePath, openDatabase } from "./db/client.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); const oauthConfig = { @@ -21,6 +22,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 +31,56 @@ 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-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 = new SqliteWorkspaceStore(stateDir); + try { + assert.deepEqual( + { + existingProjectAlreadyClaimed: migrated.claimConversationBootstrap( + "chat-existing", + "/tmp/project", + ), + newProjectCanClaim: migrated.claimConversationBootstrap( + "chat-existing", + "/tmp/other-project", + ), + }, + { + existingProjectAlreadyClaimed: false, + newProjectCanClaim: true, + }, + ); + } finally { + migrated.close(); + } +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { @@ -44,6 +96,8 @@ 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" }, + { version: 5, name: "workspace-conversation-bootstraps" }, ]); } finally { database.close(); diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts new file mode 100644 index 00000000..9f454129 --- /dev/null +++ b/src/request-meta.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { openAiConversationScopeId } from "./request-meta.js"; + +test("OpenAI session metadata supplies the opaque conversation scope", () => { + assert.equal( + openAiConversationScopeId({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), + "chat-1", + ); +}); + +test("missing or empty OpenAI session metadata has no conversation scope", () => { + assert.deepEqual( + [ + openAiConversationScopeId(undefined), + openAiConversationScopeId({}), + openAiConversationScopeId({ "openai/session": "" }), + ], + [undefined, undefined, undefined], + ); +}); diff --git a/src/request-meta.ts b/src/request-meta.ts new file mode 100644 index 00000000..40662373 --- /dev/null +++ b/src/request-meta.ts @@ -0,0 +1,13 @@ +function metadataString( + meta: Record | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function openAiConversationScopeId( + meta: Record | undefined, +): string | undefined { + return metadataString(meta, "openai/session"); +} diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 3ec4676a..a9b699c0 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -1,56 +1,172 @@ import { execFile } from "node:child_process"; +import assert from "node:assert/strict"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import test, { type TestContext } from "node:test"; import { promisify } from "node:util"; -import assert from "node:assert/strict"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; const execFileAsync = promisify(execFile); -const root = await mkdtemp(join(tmpdir(), "devspace-review-checkpoints-test-")); - -try { - await git(root, ["init"]); - await git(root, ["config", "user.email", "devspace@example.com"]); - await git(root, ["config", "user.name", "DevSpace Test"]); - await writeFile(join(root, "README.md"), "hello\n"); - await git(root, ["add", "README.md"]); - await git(root, ["commit", "-m", "Initial commit"]); +test("review changes reports edits and advances the last-shown checkpoint", async (t) => { + const root = await repository(t); const manager = createReviewCheckpointManager(); await manager.initializeWorkspace({ workspaceId: "ws_review", root }); - - const clean = await manager.reviewChanges({ workspaceId: "ws_review", root }); - assert.equal(clean.summary.files, 0); - assert.equal(clean.patch, ""); - assert.match(clean.result, /No changes/); + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root })).summary.files, 0); await writeFile(join(root, "README.md"), "hello\nworld\n"); await writeFile(join(root, "new.txt"), "new\n"); + const changed = await manager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.deepEqual(changed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); + assert.equal(changed.summary.additions, 2); + + assert.equal((await manager.reviewChanges({ workspaceId: "ws_review", root })).summary.files, 2); + assert.equal((await manager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + })).summary.files, 0); +}); + +test("review checkpoints survive a manager restart", async (t) => { + const root = await repository(t); + const firstManager = createReviewCheckpointManager(); + await firstManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nworld\n"); + + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const sinceLastShown = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + const sinceWorkspaceOpen = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + since: "workspace_open", + markReviewed: false, + }); + + assert.equal(sinceLastShown.summary.files, 1); + assert.equal(sinceWorkspaceOpen.summary.files, 1); +}); - const firstReview = await manager.reviewChanges({ +test("review waits for concurrent checkpoint initialization", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nlater\n"); + + const manager = createReviewCheckpointManager(); + const [, review] = await Promise.all([ + manager.initializeWorkspace({ workspaceId: "ws_review", root }), + manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false }), + ]); + + assert.equal(review.summary.files, 1); + assert.match(review.patch, /later/); +}); + +test("a missing last-shown checkpoint falls back and is re-established", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "README.md"), "hello\nlater\n"); + await deleteCheckpointRef(root, "ws_review", "baseline"); + + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + const fallback = await manager.reviewChanges({ workspaceId: "ws_review", root, markReviewed: false, }); - assert.equal(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 stillUnreviewed = await manager.reviewChanges({ + assert.equal(fallback.summary.files, 1); + assert.match(fallback.result, /compared from workspace open/); + + const reestablished = await manager.reviewChanges({ workspaceId: "ws_review", root }); + assert.match(reestablished.result, /baseline was re-established/); + assert.equal((await manager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + })).summary.files, 0); +}); + +test("a missing workspace-open checkpoint preserves last-shown behavior", async (t) => { + const root = await repository(t); + const setupManager = createReviewCheckpointManager(); + await setupManager.initializeWorkspace({ workspaceId: "ws_review", root }); + await writeFile(join(root, "new.txt"), "still visible from baseline\n"); + await deleteCheckpointRef(root, "ws_review", "open"); + + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_review", root }); + const sinceLastShown = await manager.reviewChanges({ workspaceId: "ws_review", root, - markReviewed: true, + markReviewed: false, + }); + assert.equal(sinceLastShown.summary.files, 1); + assert.match(sinceLastShown.patch, /still visible from baseline/); + await assert.rejects( + () => manager.reviewChanges({ + workspaceId: "ws_review", + root, + since: "workspace_open", + markReviewed: false, + }), + /workspace-open review checkpoint is missing/, + ); +}); + +test("an unborn repository becomes reviewable after its first commit", async (t) => { + const root = await repository(t, false); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); + await assert.rejects( + () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), + /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"]); + const afterFirstCommit = await manager.reviewChanges({ + workspaceId: "ws_unborn", + root, + markReviewed: false, }); - assert.equal(stillUnreviewed.summary.files, 2); + assert.equal(afterFirstCommit.summary.files, 0); + assert.equal(afterFirstCommit.patch, ""); +}); + +async function repository(t: TestContext, initialCommit = true): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-review-checkpoints-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + if (initialCommit) { + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["add", "README.md"]); + await git(root, ["commit", "-m", "Initial commit"]); + } + return root; +} - const afterReviewed = await manager.reviewChanges({ workspaceId: "ws_review", root }); - assert.equal(afterReviewed.summary.files, 0); -} finally { - await rm(root, { recursive: true, force: true }); +async function deleteCheckpointRef( + root: string, + workspaceId: string, + checkpoint: "open" | "baseline", +): Promise { + await git(root, ["update-ref", "-d", `refs/devspace/review/${workspaceId}/${checkpoint}`]); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index eaa04dfa..f718d96a 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; } @@ -48,32 +50,35 @@ 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) { + 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); } }, async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); - if (!state) { + if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } @@ -82,7 +87,21 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { throw new Error(state?.diagnostic ?? "show_changes requires a Git workspace in this version."); } - const baselineRef = since === "workspace_open" ? state.openRef : state.baselineRef; + 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( + "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 = 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], { @@ -96,13 +115,18 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { if (markReviewed) { await git(state.gitRoot, ["update-ref", state.baselineRef, current]); + 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, @@ -111,6 +135,62 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +async function initializeWorkspaceState( + states: Map, + workspaceId: string, + root: string, +): Promise { + const refs = reviewRefs(workspaceId); + const state: WorkspaceReviewState = { + root, + ...refs, + openRefAvailable: false, + baselineRefAvailable: false, + }; + + 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; + } + + const [openCommit, baselineCommit] = await Promise.all([ + commitForRef(eligibility.gitRoot, state.openRef), + commitForRef(eligibility.gitRoot, state.baselineRef), + ]); + + 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]); + state.openRefAvailable = true; + state.baselineRefAvailable = true; + } else { + state.openRefAvailable = openCommit !== undefined; + state.baselineRefAvailable = baselineCommit !== undefined; + } + + state.gitRoot = eligibility.gitRoot; + } catch (error) { + state.diagnostic = error instanceof Error ? error.message : String(error); + } finally { + states.set(workspaceId, state); + } +} + +function isReadyState(state: WorkspaceReviewState | undefined): boolean { + return state?.gitRoot !== undefined; +} + +async function commitForRef(gitRoot: string, ref: string): Promise { + try { + return (await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`])).stdout.trim(); + } catch { + return undefined; + } +} + function reviewRefs(workspaceId: string): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { diff --git a/src/server.ts b/src/server.ts index 91f5df61..3fb6e9bd 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 { openAiConversationScopeId } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -209,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: { @@ -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 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() @@ -762,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() @@ -784,60 +785,83 @@ 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 }); + const { + workspace, + agentsFiles, + availableAgentsFiles, + workspaceReused, + includeBootstrapContext, + } = await workspaces.openWorkspace( + { path, mode, baseRef }, + { conversationScopeId: openAiConversationScopeId(_meta) }, + ); + const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { - void reviewCheckpoints.initializeWorkspace({ + await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); } - const visibleSkills = workspace.skills + const cardSkills = 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 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 = agentsFiles.map((file) => ({ + const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, })); - const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ + const cardAvailableAgentsFiles = 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 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, 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 resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `Opened workspace ${workspace.id}`, + `${workspaceReused ? "Workspace already open as" : "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, @@ -876,13 +900,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, - 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, }, }, @@ -893,12 +927,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/card-types.ts b/src/ui/card-types.ts index 1e3c9409..cb3ab0fa 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,16 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; summary?: Record; files?: Array<{ @@ -46,6 +56,20 @@ export interface ToolResultCard { description?: string; path?: string; }>; + 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; } @@ -137,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..da32cd32 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) ?? String(diagnostic); + } catch { + return String(diagnostic); + } +} + function formatAgentsFilesForPayload( agentsFiles: NonNullable, ): string { diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts new file mode 100644 index 00000000..3e42171c --- /dev/null +++ b/src/workspace-conversation.test.ts @@ -0,0 +1,244 @@ +import { execFile } from "node:child_process"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { promisify } from "node:util"; +import { loadConfig, type ServerConfig } from "./config.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceRegistry } from "./workspaces.js"; + +const execFileAsync = promisify(execFile); + +test("a conversation reuses its checkout and receives bootstrap once", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.equal(second.workspace.id, first.workspace.id); + assert.equal(first.workspaceReused, false); + assert.equal(second.workspaceReused, true); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, false); +}); + +test("different conversations receive different checkout workspaces", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const second = await registry.openWorkspace(project, { conversationScopeId: "chat-2" }); + + assert.notEqual(second.workspace.id, first.workspace.id); + assert.equal(first.includeBootstrapContext, true); + assert.equal(second.includeBootstrapContext, true); +}); + +test("worktree requests stay fresh without replacing the reusable checkout", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const firstWorktree = await registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + const secondWorktree = await registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); + assert.notEqual(firstWorktree.workspace.root, secondWorktree.workspace.root); + assert.equal(firstWorktree.workspaceReused, false); + assert.equal(secondWorktree.workspaceReused, false); + assert.equal(firstWorktree.includeBootstrapContext, false); + assert.equal(secondWorktree.includeBootstrapContext, false); + assert.equal(checkoutAgain.workspace.id, checkout.workspace.id); +}); + +test("concurrent worktree opens deliver project bootstrap once", async (t) => { + const { project, registry } = await fixture(t, { git: true }); + const opens = await Promise.all([ + registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + registry.openWorkspace( + { path: project, mode: "worktree" }, + { conversationScopeId: "chat-1" }, + ), + ]); + + assert.notEqual(opens[0]?.workspace.id, opens[1]?.workspace.id); + assert.equal(opens.filter((open) => open.includeBootstrapContext).length, 1); +}); + +test("checkout reuse survives a registry restart", async (t) => { + const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + context.closeStore(context.store); + + const restoredStore = context.openStore(); + const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const restored = await restoredRegistry.openWorkspace(context.project, { + conversationScopeId: "chat-1", + }); + + assert.equal(restored.workspace.id, first.workspace.id); + assert.equal(restored.workspaceReused, true); + assert.equal(restored.includeBootstrapContext, false); +}); + +test("context failures neither consume bootstrap nor discard a checkout binding", async (t) => { + const { project, registry } = await fixture(t); + const agentsDir = join(project, ".devspace", "agents"); + const backupDir = join(project, ".devspace", "agents-backup"); + + await breakAgentsDirectory(agentsDir, backupDir); + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + await restoreAgentsDirectory(agentsDir, backupDir); + + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(first.includeBootstrapContext, true); + + await breakAgentsDirectory(agentsDir, backupDir); + await assert.rejects( + () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), + /directory|ENOTDIR/i, + ); + await restoreAgentsDirectory(agentsDir, backupDir); + + const recovered = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + assert.equal(recovered.workspace.id, first.workspace.id); + assert.equal(recovered.workspaceReused, true); + assert.equal(recovered.includeBootstrapContext, false); +}); + +test("a deleted checkout is replaced without repeating project bootstrap", async (t) => { + const { project, registry } = await fixture(t); + const first = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + await rm(project, { recursive: true, force: true }); + const replacement = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + + assert.notEqual(replacement.workspace.id, first.workspace.id); + assert.equal(replacement.workspaceReused, false); + assert.equal(replacement.includeBootstrapContext, false); + assert.equal((await stat(project)).isDirectory(), true); +}); + +test("canonical checkout identity survives symlink aliases and a missing target", { skip: platform() === "win32" }, async (t) => { + const { root, project, registry } = await fixture(t); + const alias = join(root, "project-alias"); + const target = join(project, "temporary-checkout"); + const aliasedTarget = join(alias, "temporary-checkout"); + await symlink(project, alias, "dir"); + await mkdir(target); + + const direct = await registry.openWorkspace(target, { conversationScopeId: "chat-1" }); + const aliased = await registry.openWorkspace(aliasedTarget, { conversationScopeId: "chat-1" }); + assert.equal(aliased.workspace.id, direct.workspace.id); + + await rm(target, { recursive: true, force: true }); + const replacement = await registry.openWorkspace(aliasedTarget, { + conversationScopeId: "chat-1", + }); + assert.notEqual(replacement.workspace.id, direct.workspace.id); + assert.equal(replacement.includeBootstrapContext, false); +}); + +interface WorkspaceFixture { + root: string; + project: string; + stateDir: string; + config: ServerConfig; + store: SqliteWorkspaceStore; + registry: WorkspaceRegistry; + openStore: () => SqliteWorkspaceStore; + closeStore: (store: SqliteWorkspaceStore) => void; +} + +async function fixture( + t: TestContext, + options: { git?: boolean } = {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-conversation-test-")); + const project = join(root, "project"); + const agentDir = join(root, "agent"); + const stateDir = join(root, ".state"); + const stores = new Set(); + await mkdir(join(project, ".devspace", "agents"), { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + await writeFile(join(project, "AGENTS.md"), "project instructions\n"); + await writeFile(join(project, ".devspace", "agents", "reviewer.md"), [ + "---", + "name: reviewer", + "description: Reviews project changes.", + "provider: codex", + "---", + "Review changes.", + ].join("\n")); + + if (options.git) await initializeGitRepository(project); + + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const openStore = () => { + const store = new SqliteWorkspaceStore(stateDir); + stores.add(store); + return store; + }; + const closeStore = (store: SqliteWorkspaceStore) => { + if (stores.delete(store)) store.close(); + }; + const store = openStore(); + t.after(async () => { + for (const openStore of stores) openStore.close(); + await rm(root, { recursive: true, force: true }); + }); + + return { + root, + project, + stateDir, + config, + store, + registry: new WorkspaceRegistry(config, store), + openStore, + closeStore, + }; +} + +async function breakAgentsDirectory(agentsDir: string, backupDir: string): Promise { + await rename(agentsDir, backupDir); + await writeFile(agentsDir, "not a directory\n"); +} + +async function restoreAgentsDirectory(agentsDir: string, backupDir: string): Promise { + await rm(agentsDir, { force: true }); + await rename(backupDir, agentsDir); +} + +async function initializeGitRepository(root: string): Promise { + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "Initial commit"]); +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 39c2ed09..f7375701 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,7 +1,10 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBootstraps, + workspaceConversationBindings, workspaceSessions, + type WorkspaceConversationBindingRow, type WorkspaceSessionRow, } from "./db/schema.js"; @@ -20,6 +23,14 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspaceConversationBinding { + conversationScopeId: string; + targetKey: string; + workspaceSessionId: string; + createdAt: string; + lastUsedAt: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +43,18 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getConversationBinding( + conversationScopeId: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined; + setConversationBinding(input: { + conversationScopeId: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding; + touchConversationBinding(conversationScopeId: string, targetKey: string): void; + deleteConversationBinding(conversationScopeId: string, targetKey: string): void; + claimConversationBootstrap(conversationScopeId: string, projectKey: string): boolean; close?(): void; } @@ -102,6 +125,113 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getConversationBinding( + conversationScopeId: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined { + const row = this.database.db + .select() + .from(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .get(); + + return row ? rowToWorkspaceConversationBinding(row) : undefined; + } + + setConversationBinding(input: { + conversationScopeId: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding { + const now = new Date().toISOString(); + const row = this.database.db + .insert(workspaceConversationBindings) + .values({ + conversationScopeId: input.conversationScopeId, + targetKey: input.targetKey, + workspaceSessionId: input.workspaceSessionId, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoUpdate({ + target: [ + workspaceConversationBindings.conversationScopeId, + workspaceConversationBindings.targetKey, + ], + set: { + workspaceSessionId: input.workspaceSessionId, + lastUsedAt: now, + }, + }) + .returning() + .get(); + + if (!row) { + throw new Error("Conversation workspace binding upsert returned no row."); + } + + return rowToWorkspaceConversationBinding(row); + } + + touchConversationBinding(conversationScopeId: string, targetKey: string): void { + this.database.db + .update(workspaceConversationBindings) + .set({ lastUsedAt: new Date().toISOString() }) + .where( + and( + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + deleteConversationBinding(conversationScopeId: string, targetKey: string): void { + this.database.db + .delete(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + 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(); + + 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; + } + close(): void { this.database.close(); } @@ -126,3 +256,15 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspaceConversationBinding( + row: WorkspaceConversationBindingRow, +): WorkspaceConversationBinding { + return { + conversationScopeId: row.conversationScopeId, + 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..52e655b6 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -182,6 +182,7 @@ try { 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"), diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..939fbf0c 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"; @@ -53,6 +53,8 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + workspaceReused: boolean; + includeBootstrapContext: boolean; } export interface WorkspaceReadPath { @@ -67,6 +69,10 @@ export interface OpenWorkspaceInput { baseRef?: string; } +export interface OpenWorkspaceOptions { + conversationScopeId?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -75,14 +81,66 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); + private readonly pendingCheckoutOpens = 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 conversationScopeId = openOptions.conversationScopeId; + if (!conversationScopeId || !this.store) { + return this.openNewWorkspace(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( + conversationScopeId, + projectKey, + ), + }; + } + + const targetKey = this.conversationCheckoutTargetKey(projectKey); + const operationKey = JSON.stringify([conversationScopeId, targetKey]); + const pending = this.pendingCheckoutOpens.get(operationKey); + if (pending) { + const context = await pending; + return { + ...context, + workspaceReused: true, + includeBootstrapContext: false, + }; + } + + const open = this.openConversationCheckout( + workspaceInput, + conversationScopeId, + targetKey, + projectKey, + ); + this.pendingCheckoutOpens.set(operationKey, open); + + try { + return await open; + } finally { + if (this.pendingCheckoutOpens.get(operationKey) === open) { + this.pendingCheckoutOpens.delete(operationKey); + } + } + } + + private async openNewWorkspace(options: OpenWorkspaceInput): Promise { const mode = options.mode ?? "checkout"; if (mode === "worktree") { @@ -92,6 +150,75 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } + private async openConversationCheckout( + input: OpenWorkspaceInput, + conversationScopeId: string, + targetKey: string, + projectKey: string, + ): 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. + } + + if (reusableWorkspace) { + this.store?.touchConversationBinding(conversationScopeId, targetKey); + const context = await this.reusedWorkspaceContext(reusableWorkspace); + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + }; + } + + this.workspaces.delete(binding.workspaceSessionId); + this.store?.deleteConversationBinding(conversationScopeId, targetKey); + } + + const context = await this.openCheckoutWorkspace(input.path); + this.store?.setConversationBinding({ + conversationScopeId, + targetKey, + workspaceSessionId: context.workspace.id, + }); + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeId, projectKey) ?? true, + }; + } + + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { + const path = assertAllowedPath(input.path, this.config.allowedRoots); + return canonicalPath(path); + } + + private conversationCheckoutTargetKey(projectKey: string): string { + return JSON.stringify(["checkout", projectKey, null]); + } + + 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, + workspaceReused: true, + includeBootstrapContext: true, + }; + } + getWorkspace(workspaceId: string): Workspace { const workspace = this.workspaces.get(workspaceId); if (workspace) { @@ -228,7 +355,13 @@ 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, + workspaceReused: false, + includeBootstrapContext: true, + }; } private loadSkillsForWorkspace(root: string): Pick { @@ -303,6 +436,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 },