Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
866d49e
feat(workspace): reuse opens within ChatGPT sessions
Waishnav Jul 31, 2026
ff9c337
fix(workspace): keep reused cards visually stable
Waishnav Jul 31, 2026
8ea171f
fix(review): restore checkpoints after restart
Waishnav Jul 31, 2026
e2121b4
fix(ui): expose workspace card metadata
Waishnav Jul 31, 2026
44c0969
test(workspace): make reuse assertions deterministic
Waishnav Jul 31, 2026
f7fc11b
test(ui): remove duplicate workspace assertions
Waishnav Jul 31, 2026
fef5250
feat(workspace): track project bootstrap delivery
Waishnav Jul 31, 2026
540d51c
fix(workspace): always create requested worktrees
Waishnav Jul 31, 2026
f5ab680
fix(server): separate workspace reuse from bootstrap
Waishnav Jul 31, 2026
b38e46c
refactor(workspace): store conversation scope ids directly
Waishnav Aug 4, 2026
a8701f4
fix(review): preserve partial checkpoints safely
Waishnav Aug 4, 2026
e13fd52
fix(workspace): preserve valid bindings on context errors
Waishnav Aug 4, 2026
190b1dc
fix(ui): stringify empty diagnostics safely
Waishnav Aug 4, 2026
35049a8
docs(workspace): remove unsupported reopen guidance
Waishnav Aug 4, 2026
62dc7c1
fix(db): make bootstrap backfill deterministic
Waishnav Aug 4, 2026
ae54601
fix(workspace): canonicalize missing checkout targets
Waishnav Aug 4, 2026
3597e2e
Merge origin/main into feat/reuse-chatgpt-workspaces
Waishnav Aug 4, 2026
7942f2b
test(workspace): use canonical binding keys
Waishnav Aug 4, 2026
7105d1d
fix(workspace): claim bootstrap after context loads
Waishnav Aug 4, 2026
95b08f2
fix(review): preserve checkpoint meanings after ref loss
Waishnav Aug 4, 2026
13e9fef
docs(workspace): include mode switches in reopen guidance
Waishnav Aug 4, 2026
ffb83de
fix(review): activate recreated baselines
Waishnav Aug 4, 2026
598c737
fix(review): retry eligibility after repository changes
Waishnav Aug 4, 2026
13f1464
fix(review): fall back when last-shown checkpoint is missing
Waishnav Aug 4, 2026
076c7fb
test(workspace): align regressions with public seams
Waishnav Aug 4, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Checkout Mode

Expand Down Expand Up @@ -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.
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/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": [],
Expand Down
83 changes: 83 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,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",
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",
{
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",
{
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;
54 changes: 54 additions & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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"));
Expand All @@ -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<void> {
const database = openDatabase(stateDir);
try {
Expand All @@ -44,6 +96,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
25 changes: 25 additions & 0 deletions src/request-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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],
);
});
13 changes: 13 additions & 0 deletions src/request-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 openAiConversationScopeId(
meta: Record<string, unknown> | undefined,
): string | undefined {
return metadataString(meta, "openai/session");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading