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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,27 @@ 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 anonymized conversation identifier in
`_meta["openai/session"]`. DevSpace uses that value 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 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
Comment on lines +36 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the reopening conditions with open_workspace.

src/server.ts allows reopening when the user asks to reopen and when the model switches to a different folder or worktree. This document omits both cases. Restore them so the workflow does not reject valid user requests.

Proposed fix
 - the `workspaceId` is rejected as unknown
-- the user switches to another folder
+- the user switches to another folder or worktree
+- the user asks to reopen the checkout
 - the user asks for a new isolated worktree
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
Do not reopen the same checkout folder unless:
- the `workspaceId` is rejected as unknown
- the user switches to another folder or worktree
- the user asks to reopen the checkout
- the user asks for a new isolated worktree
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/chatgpt-coding-workflow.md` around lines 36 - 40, Update the reopening
conditions in the workflow documentation to include an explicit user request to
reopen and a model switch to a different folder or worktree, while retaining the
existing unknown workspaceId condition and new isolated worktree request.


## Checkout Mode

Expand Down Expand Up @@ -56,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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
80 changes: 80 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -174,6 +184,76 @@ 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 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",
Expand Down
34 changes: 34 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ 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 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",
{
Expand Down Expand Up @@ -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;
49 changes: 49 additions & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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<void> {
const database = openDatabase(stateDir);
try {
Expand All @@ -44,6 +91,8 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ 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();
Expand Down
26 changes: 26 additions & 0 deletions src/request-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
20 changes: 20 additions & 0 deletions src/request-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";

function metadataString(
meta: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = meta?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}

export function openAiConversationScopeHash(
meta: Record<string, unknown> | undefined,
): string | undefined {
const session = metadataString(meta, "openai/session");
if (!session) return undefined;

return createHash("sha256")
.update(JSON.stringify(["openai", session]))
.digest("hex");
}
10 changes: 10 additions & 0 deletions src/review-checkpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading