diff --git a/apps/daemon/drizzle/0007_add_worktree_workspaces.sql b/apps/daemon/drizzle/0007_add_worktree_workspaces.sql new file mode 100644 index 000000000..07904f9b0 --- /dev/null +++ b/apps/daemon/drizzle/0007_add_worktree_workspaces.sql @@ -0,0 +1,12 @@ +CREATE TABLE `worktrees` ( + `worktree_path` text PRIMARY KEY NOT NULL, + `repo_root` text NOT NULL, + `branch` text NOT NULL, + `session_id` text NOT NULL, + `created_at` integer NOT NULL, + `state` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `worktrees_repo_root_branch_unique` ON `worktrees` (`repo_root`,`branch`);--> statement-breakpoint +CREATE UNIQUE INDEX `worktrees_session_id_unique` ON `worktrees` (`session_id`);--> statement-breakpoint +ALTER TABLE `workspaces` ADD `parent_workspace_id` text; diff --git a/apps/daemon/drizzle/meta/0007_snapshot.json b/apps/daemon/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..b84434214 --- /dev/null +++ b/apps/daemon/drizzle/meta/0007_snapshot.json @@ -0,0 +1,735 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "09729734-3d60-48c3-a391-a5e31703b4fb", + "prevId": "73dc0d74-adb6-434a-82e8-d904567e8b74", + "tables": { + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/_journal.json b/apps/daemon/drizzle/meta/_journal.json index 6f16e5c9d..ed6ed2584 100644 --- a/apps/daemon/drizzle/meta/_journal.json +++ b/apps/daemon/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1784175697180, "tag": "0006_free_secret_warriors", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1785411858802, + "tag": "0007_add_worktree_workspaces", + "breakpoints": true } ] } diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 3416d379b..ea3681f6b 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -54,6 +54,11 @@ export function databasePath(): string { return join(daemonStateDir(), 'daemon.db'); } +/** Daemon-owned root for managed git worktrees. */ +export function worktreeRoot(): string { + return join(daemonStateDir(), 'worktrees'); +} + /** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */ export function runtimeFilePath(): string { return daemonRuntimeFilePath(daemonChannel(), daemonProfile()); diff --git a/apps/daemon/src/db/schema.ts b/apps/daemon/src/db/schema.ts index fbc680911..731b6011c 100644 --- a/apps/daemon/src/db/schema.ts +++ b/apps/daemon/src/db/schema.ts @@ -1,4 +1,11 @@ -import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { + index, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; /** * Session registry tables. These mirror `SessionRecord` from `@linkcode/schema` — the zod schema @@ -143,11 +150,30 @@ export const workspaces = sqliteTable( workspaceId: text('workspace_id').primaryKey(), cwd: text('cwd').notNull().unique(), name: text('name'), - kind: text('kind', { enum: ['project', 'chat'] }) + kind: text('kind', { enum: ['project', 'chat', 'worktree'] }) .notNull() .default('project'), + parentWorkspaceId: text('parent_workspace_id'), createdAt: integer('created_at').notNull(), lastUsedAt: integer('last_used_at').notNull(), }, (table) => [index('workspaces_last_used_at_idx').on(table.lastUsedAt)], ); + +/** Managed git worktrees. Session ids intentionally have no FK: rows survive session deletion until + * the dedicated cleanup lifecycle owns removal. */ +export const worktrees = sqliteTable( + 'worktrees', + { + worktreePath: text('worktree_path').primaryKey(), + repoRoot: text('repo_root').notNull(), + branch: text('branch').notNull(), + sessionId: text('session_id').notNull(), + createdAt: integer('created_at').notNull(), + state: text('state', { enum: ['active', 'orphaned'] }).notNull(), + }, + (table) => [ + uniqueIndex('worktrees_repo_root_branch_unique').on(table.repoRoot, table.branch), + uniqueIndex('worktrees_session_id_unique').on(table.sessionId), + ], +); diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index b2f34701d..f4a4470ee 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -36,6 +36,7 @@ import { databasePath, loadConfig, saveSimulatorConsent, + worktreeRoot, } from './config'; import { runLoginCommand, runLogoutCommand } from './hq/login'; import { startHqUplink } from './hq/uplink'; @@ -56,6 +57,7 @@ import { createSessionStore } from './session-store'; import { resolveSimSidecarPath } from './sim/backend'; import { SimulatorMcpEndpoint } from './sim/mcp-endpoint'; import { createWorkspaceStore } from './workspace-store'; +import { createWorktreeStore } from './worktree-store'; // State is untrustworthy after an uncaught exception — die rather than serve from unknown state. process.on('uncaughtException', (err) => { @@ -240,6 +242,8 @@ async function main(): Promise { scheduleStore: createScheduleStore(databasePath()), loopStore: createLoopStore(databasePath()), workspaceStore: createWorkspaceStore(databasePath()), + worktreeStore: createWorktreeStore(databasePath()), + worktreeRoot: worktreeRoot(), previewRoutes, browserToolsEnabled: process.env.LINKCODE_BROWSER_TOOLS === '1', agentRuntimesReady, diff --git a/apps/daemon/src/workspace-store.ts b/apps/daemon/src/workspace-store.ts index 4699d9309..c564cb4b4 100644 --- a/apps/daemon/src/workspace-store.ts +++ b/apps/daemon/src/workspace-store.ts @@ -51,6 +51,7 @@ function toRow(record: WorkspaceRecord): typeof workspaces.$inferInsert { cwd: record.cwd, name: record.name ?? null, kind: workspaceKind(record), + parentWorkspaceId: record.parentWorkspaceId ?? null, createdAt: record.createdAt, lastUsedAt: record.lastUsedAt, }; @@ -62,6 +63,7 @@ function toRecord(row: WorkspaceRow): WorkspaceRecord { cwd: row.cwd, name: row.name ?? undefined, kind: row.kind, + parentWorkspaceId: row.parentWorkspaceId ?? undefined, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, }); diff --git a/apps/daemon/src/worktree-store.ts b/apps/daemon/src/worktree-store.ts new file mode 100644 index 000000000..5361e4264 --- /dev/null +++ b/apps/daemon/src/worktree-store.ts @@ -0,0 +1,46 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { WorktreeStore } from '@linkcode/engine'; +import type { WorktreeRecord } from '@linkcode/schema'; +import { WorktreeRecordSchema } from '@linkcode/schema'; +import Sqlite from 'better-sqlite3'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { worktrees } from './db/schema'; + +export function createWorktreeStore(dbPath: string): WorktreeStore { + if (dbPath !== ':memory:') mkdirSync(dirname(dbPath), { recursive: true }); + const sqlite = new Sqlite(dbPath); + sqlite.pragma('journal_mode = WAL'); + const db = drizzle(sqlite); + migrate(db, { migrationsFolder: fileURLToPath(new URL('../drizzle', import.meta.url)) }); + + return { + load(): Promise { + return Promise.resolve( + db + .select() + .from(worktrees) + .all() + .map((row) => WorktreeRecordSchema.parse(row)), + ); + }, + save(record: WorktreeRecord): Promise { + try { + db.insert(worktrees) + .values(record) + .onConflictDoUpdate({ target: worktrees.worktreePath, set: record }) + .run(); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error('Failed to save worktree', { cause: error })); + } + }, + delete(worktreePath): Promise { + db.delete(worktrees).where(eq(worktrees.worktreePath, worktreePath)).run(); + return Promise.resolve(); + }, + }; +} diff --git a/apps/daemon/tests/integration/workspace-store.test.ts b/apps/daemon/tests/integration/workspace-store.test.ts index d0c25b084..f12106332 100644 --- a/apps/daemon/tests/integration/workspace-store.test.ts +++ b/apps/daemon/tests/integration/workspace-store.test.ts @@ -34,6 +34,20 @@ describe('daemon sqlite workspace store', () => { expect(await store.load()).toEqual([chat]); }); + it('round-trips a managed worktree and its parent workspace id', async () => { + const store = createWorkspaceStore(':memory:'); + const worktree = makeRecord({ + workspaceId: 'ws-worktree', + cwd: '/worktrees/feature', + kind: 'worktree', + parentWorkspaceId: 'ws-1', + name: 'feature', + }); + await store.save(worktree); + + expect(await store.load()).toEqual([worktree]); + }); + it('saves as a whole-record upsert', async () => { const store = createWorkspaceStore(':memory:'); await store.save(makeRecord({})); diff --git a/apps/daemon/tests/integration/worktree-store.test.ts b/apps/daemon/tests/integration/worktree-store.test.ts new file mode 100644 index 000000000..680e713f1 --- /dev/null +++ b/apps/daemon/tests/integration/worktree-store.test.ts @@ -0,0 +1,57 @@ +import type { WorktreeRecord } from '@linkcode/schema'; +import { SessionIdSchema } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { createWorktreeStore } from '../../src/worktree-store'; + +function record(values: Partial = {}): WorktreeRecord { + return { + worktreePath: '/managed/repo-feature', + repoRoot: '/repo', + branch: 'feature', + sessionId: SessionIdSchema.parse('sess-1'), + createdAt: 123, + state: 'active', + ...values, + }; +} + +describe('daemon sqlite worktree store', () => { + it('round-trips active and orphaned rows without requiring a session row', async () => { + const store = createWorktreeStore(':memory:'); + const rows = [ + record(), + record({ + worktreePath: '/managed/repo-old', + branch: 'old', + sessionId: SessionIdSchema.parse('sess-missing'), + state: 'orphaned', + }), + ]; + await Promise.all(rows.map((row) => store.save(row))); + expect(await store.load()).toEqual(rows); + }); + + it('enforces one row per normalized repository and branch', async () => { + const store = createWorktreeStore(':memory:'); + await store.save(record()); + await expect( + store.save( + record({ + worktreePath: '/managed/duplicate', + sessionId: SessionIdSchema.parse('sess-2'), + }), + ), + ).rejects.toThrow(); + }); + + it('updates and deletes a record by its worktree path', async () => { + const store = createWorktreeStore(':memory:'); + const active = record(); + await store.save(active); + await store.save({ ...active, state: 'orphaned' }); + expect(await store.load()).toEqual([{ ...active, state: 'orphaned' }]); + + await store.delete(active.worktreePath); + expect(await store.load()).toEqual([]); + }); +}); diff --git a/apps/desktop/e2e/new-session-branch.e2e.mts b/apps/desktop/e2e/new-session-branch.e2e.mts new file mode 100644 index 000000000..92aad0ae4 --- /dev/null +++ b/apps/desktop/e2e/new-session-branch.e2e.mts @@ -0,0 +1,188 @@ +/** + * New-session branch-picker E2E (CODE-428): boots an isolated daemon + the built desktop app, + * registers a real Git fixture through the native directory-picker bridge, and verifies the + * desktop shell's direct NewSessionSurface composition can select a local branch. + */ + +import type { ChildProcess } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication, Page } from 'playwright-core'; +import { _electron } from 'playwright-core'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 44000 + (process.pid % 1000); +const FEATURE_BRANCH = 'feature/code-428'; +const RE_FIXTURE_WORKSPACE = /branch-picker-fixture/; +const RE_FEATURE_BRANCH = /feature\/code-428/; +const RECORDING_PAUSE_MS = process.env.LINKCODE_E2E_RECORDING === '1' ? 1500 : 0; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function waitForDaemon(): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + await wait(250); + } + } + fail(`daemon did not come up on port ${PORT}`); +} + +async function pauseForRecording(win: Page): Promise { + if (RECORDING_PAUSE_MS > 0) await win.waitForTimeout(RECORDING_PAUSE_MS); +} + +function makeRepository(path: string): void { + mkdirSync(path, { recursive: true }); + execFileSync('git', ['init', '--initial-branch=main'], { + cwd: path, + stdio: 'ignore', + windowsHide: true, + }); + writeFileSync(join(path, 'README.md'), '# Branch picker fixture\n'); + execFileSync('git', ['add', 'README.md'], { + cwd: path, + stdio: 'ignore', + windowsHide: true, + }); + execFileSync( + 'git', + [ + '-c', + 'user.name=LinkCode E2E', + '-c', + 'user.email=e2e@linkcode.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '-m', + 'fixture', + ], + { cwd: path, stdio: 'ignore', windowsHide: true }, + ); + execFileSync('git', ['branch', FEATURE_BRANCH], { + cwd: path, + stdio: 'ignore', + windowsHide: true, + }); +} + +async function run(app: ElectronApplication, win: Page, repository: string): Promise { + await win.getByRole('combobox').waitFor({ state: 'visible', timeout: 30000 }); + await pauseForRecording(win); + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [directory] }); + }, repository); + + await win.getByRole('button', { name: 'Add project' }).click(); + await pauseForRecording(win); + await win.getByRole('menuitem', { name: 'Use an existing folder' }).click(); + await win.getByText('branch-picker-fixture').first().waitFor({ + state: 'visible', + timeout: 15000, + }); + await pauseForRecording(win); + + const workspacePicker = win.getByRole('button', { name: 'Choose a workspace' }); + await workspacePicker.click(); + await win + .getByRole('menuitemradio', { name: RE_FIXTURE_WORKSPACE }) + .waitFor({ state: 'visible', timeout: 15000 }); + await pauseForRecording(win); + await win.getByRole('menuitemradio', { name: RE_FIXTURE_WORKSPACE }).click(); + + const branchPicker = win.getByRole('button', { name: 'Branch' }); + await branchPicker.waitFor({ state: 'visible', timeout: 15000 }); + await branchPicker.click(); + const feature = win.getByRole('menuitemradio', { name: RE_FEATURE_BRANCH }); + await feature.waitFor({ state: 'visible', timeout: 15000 }); + const itemText = await feature.textContent(); + if (!itemText?.includes('isolated worktree')) { + fail('non-current branch did not explain isolated worktree startup'); + } + await pauseForRecording(win); + await feature.click(); + + if (!(await branchPicker.textContent())?.includes(FEATURE_BRANCH)) { + fail('selected branch was not reflected in the desktop context bar'); + } + await pauseForRecording(win); + console.log('PASS desktop new-session branch picker'); +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-branch-home-')); + const userData = mkdtempSync(join(tmpdir(), 'linkcode-e2e-branch-userdata-')); + const repository = join(home, 'branch-picker-fixture'); + makeRepository(repository); + + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let passed = false; + try { + daemon = spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + // The dist bundle is stamped `release`, while this unpackaged Electron shell resolves as + // `development`; force both onto the same isolated state dir so desktop finds runtime.json. + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_CHANNEL: 'development', + }, + stdio: 'ignore', + }); + await waitForDaemon(); + + app = await _electron.launch({ + executablePath: electronBinary, + args: [desktopDir, `--user-data-dir=${userData}`, '--use-mock-keychain'], + env: { ...process.env, HOME: home }, + }); + const win = await app.firstWindow(); + try { + await run(app, win, repository); + } catch (error) { + const shot = join(tmpdir(), `linkcode-e2e-new-session-branch-${process.pid}.png`); + await win.screenshot({ path: shot }).catch(noop); + console.error(`screenshot: ${shot}`); + throw error; + } + passed = true; + } finally { + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + if (passed) { + rmSync(home, { recursive: true, force: true }); + rmSync(userData, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home} userData=${userData}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 285a65560..144ad5a1f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,6 +19,7 @@ "e2e:packaged": "pnpm run package:devshell && pnpm run e2e:packaged:smoke", "e2e:notifications": "node e2e/notifications.e2e.mts", "e2e:file-tree": "node e2e/file-tree.e2e.mts", + "e2e:new-session-branch": "node e2e/new-session-branch.e2e.mts", "e2e:new-session-policy": "node e2e/new-session-policy.e2e.mts", "e2e:simulator": "node e2e/simulator-panel.e2e.mts", "e2e:thread-menu": "node e2e/thread-menu.e2e.mts", diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 2f55a15a6..816b72e2b 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -75,6 +75,8 @@ export function DesktopShell({ newSessionDefaultModels, newSessionPreferredModels, newSessionPreferredEfforts, + newSessionPreferredBranches, + NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, onLoginAgent, @@ -405,6 +407,8 @@ export function DesktopShell({ defaultModels={newSessionDefaultModels} preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} + preferredBranches={newSessionPreferredBranches} + NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} mentionItems={mentionItems} topContent={} onContinueUnverified={onContinueUnverified} diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 1083d28fb..f53a597bb 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -11,6 +11,7 @@ import type { ContentBlock, EffortLevel, FileSuggestion, + GitBranchList, GitDiff, GitDiffMode, GitPullRequestStatus, @@ -435,6 +436,9 @@ export class LinkCodeClient { case 'git.status.get.result': this.pending.resolve('gitStatus', p.replyTo, p.status); break; + case 'git.branch.list.result': + this.pending.resolve('gitBranchList', p.replyTo, p.branchList); + break; case 'git.pr_status.get.result': this.pending.resolve('gitPrStatus', p.replyTo, p.prStatus); break; @@ -947,6 +951,10 @@ export class LinkCodeClient { return this.control.getGitStatus(cwd); } + listGitBranches(cwd: string): Promise { + return this.control.listGitBranches(cwd); + } + getGitPullRequestStatus(cwd: string): Promise { return this.control.getGitPullRequestStatus(cwd); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 85bbe1bc0..dc2974c7d 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -12,6 +12,7 @@ import type { ContentBlock, EffortLevel, FileSuggestion, + GitBranchList, GitDiff, GitDiffMode, GitPullRequestStatus, @@ -424,6 +425,15 @@ export class ControlChannel { })); } + /** Local branches for a directory, ordered current-first then by descending commit date. */ + listGitBranches(cwd: string): Promise { + return this.sendCorrelated('gitBranchList', (clientReqId) => ({ + kind: 'git.branch.list', + clientReqId, + cwd, + })); + } + /** Hosting-provider PR state for a directory's current branch. */ getGitPullRequestStatus(cwd: string): Promise { return this.sendCorrelated('gitPrStatus', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 75f33fbba..f24adf327 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -5,6 +5,7 @@ import type { AgentRuntimes, AgentStartCatalog, FileSuggestion, + GitBranchList, GitDiff, GitPullRequestStatus, GitStatus, @@ -75,6 +76,7 @@ export interface PendingValueMap { assetList: ManagedAssetStatus[]; assetEnsure: ManagedAssetStatus; gitStatus: GitStatus; + gitBranchList: GitBranchList; gitPrStatus: GitPullRequestStatus; gitDiff: GitDiff; fileRead: WorkspaceFile; @@ -127,6 +129,7 @@ export class PendingRegistry { assetList: new Map(), assetEnsure: new Map(), gitStatus: new Map(), + gitBranchList: new Map(), gitPrStatus: new Map(), gitDiff: new Map(), fileRead: new Map(), diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 7e066e0b0..e14289a60 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -16,6 +16,7 @@ import type { AgentStartCatalog, EffortLevel, FileSuggestion, + GitBranchList, GitDiff, GitDiffMode, GitPullRequestStatus, @@ -253,6 +254,11 @@ export class LinkCodeSdkClient { return toResult(this.raw.getGitStatus(cwd)); } + /** Local branches for a directory, ordered current-first then by descending commit date. */ + listGitBranches(cwd: string): RequestResult { + return toResult(this.raw.listGitBranches(cwd)); + } + /** Hosting-provider PR state for a directory's current branch. */ getGitPullRequestStatus(cwd: string): RequestResult { return toResult(this.raw.getGitPullRequestStatus(cwd)); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index cbdadfd64..35cc4c1ce 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -10,6 +10,7 @@ import type { AgentStartCatalog, EffortLevel, FileSuggestion, + GitBranchList, GitDiff, GitDiffMode, GitPullRequestStatus, @@ -205,6 +206,11 @@ export function getGitStatus(options: Options<{ cwd: string }>): RequestResult): RequestResult { + return resolveClient(options).listGitBranches(options.cwd); +} + /** Hosting-provider PR state for a directory's current branch. */ export function getGitPullRequestStatus( options: Options<{ cwd: string }>, diff --git a/packages/client/workbench/src/git/__tests__/new-session-branch-picker.test.tsx b/packages/client/workbench/src/git/__tests__/new-session-branch-picker.test.tsx new file mode 100644 index 000000000..e122b651b --- /dev/null +++ b/packages/client/workbench/src/git/__tests__/new-session-branch-picker.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RuntimeNewSessionBranchPicker } from '../new-session-branch-picker'; + +const mocks = vi.hoisted(() => ({ + useGitStatus: vi.fn(), + useGitBranches: vi.fn(), +})); + +vi.mock('../hooks', () => mocks); +vi.mock('@linkcode/ui', () => ({ + NewSessionBranchPicker: ({ + branches, + currentBranch, + onOpenChange, + }: { + branches?: Array<{ name: string }>; + currentBranch: string | null; + onOpenChange: (open: boolean) => void; + }) => ( +
+ {currentBranch} + + {branches?.map((branch) => ( + {branch.name} + ))} +
+ ), +})); + +beforeEach(() => { + mocks.useGitStatus.mockReturnValue({ data: undefined }); + mocks.useGitBranches.mockReturnValue({ data: undefined, isLoading: false, error: undefined }); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('RuntimeNewSessionBranchPicker', () => { + it('hides while git status is loading and for non-repositories', () => { + const { rerender } = render( + , + ); + expect(screen.queryByRole('button')).toBeNull(); + + mocks.useGitStatus.mockReturnValue({ data: { isRepo: false } }); + rerender(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('shows repositories, including their current branch', () => { + mocks.useGitStatus.mockReturnValue({ data: { isRepo: true, branch: 'main' } }); + render(); + + expect(screen.getByText('main')).toBeTruthy(); + }); + + it('fetches branches only after the menu opens', () => { + mocks.useGitStatus.mockReturnValue({ data: { isRepo: true, branch: null } }); + mocks.useGitBranches.mockImplementation((cwd: string | undefined) => ({ + data: cwd + ? { isRepo: true, branches: [{ name: 'feature', isCurrent: false, lastCommitAt: 1 }] } + : undefined, + isLoading: false, + error: undefined, + })); + render(); + + expect(mocks.useGitBranches).toHaveBeenLastCalledWith(undefined); + fireEvent.click(screen.getByRole('button', { name: 'Open branches' })); + expect(mocks.useGitBranches).toHaveBeenLastCalledWith('/repo'); + expect(screen.getByText('feature')).toBeTruthy(); + }); +}); diff --git a/packages/client/workbench/src/git/hooks.ts b/packages/client/workbench/src/git/hooks.ts index f07fc6d7d..50b158d21 100644 --- a/packages/client/workbench/src/git/hooks.ts +++ b/packages/client/workbench/src/git/hooks.ts @@ -1,5 +1,5 @@ import type { GitDiffMode } from '@linkcode/schema'; -import { getGitDiff, getGitPullRequestStatus, getGitStatus } from '@linkcode/sdk'; +import { getGitDiff, getGitPullRequestStatus, getGitStatus, listGitBranches } from '@linkcode/sdk'; import { useData } from '../runtime/tayori'; /** @@ -19,6 +19,14 @@ export function useGitStatus(cwd: string | undefined) { }); } +/** Local branches for a directory. Pass undefined to pause. */ +export function useGitBranches(cwd: string | undefined) { + return useData(listGitBranches, cwd === undefined ? null : { cwd }, { + refreshInterval: GIT_STATUS_REFRESH_MS, + keepPreviousData: true, + }); +} + /** A unified-diff patch for a directory. Pass undefined to pause (e.g. no active session). */ export function useGitDiff(cwd: string | undefined, mode: GitDiffMode) { return useData(getGitDiff, cwd === undefined ? null : { cwd, mode }, { diff --git a/packages/client/workbench/src/git/new-session-branch-picker.tsx b/packages/client/workbench/src/git/new-session-branch-picker.tsx new file mode 100644 index 000000000..9bdece3e0 --- /dev/null +++ b/packages/client/workbench/src/git/new-session-branch-picker.tsx @@ -0,0 +1,44 @@ +import type { NewSessionBranchPickerComponentProps } from '@linkcode/ui'; +import { NewSessionBranchPicker } from '@linkcode/ui'; +import { useState } from 'react'; +import { useGitBranches, useGitStatus } from './hooks'; + +/** Keying by cwd prevents keepPreviousData from displaying another workspace's git state. */ +export function RuntimeNewSessionBranchPicker({ + cwd, + ...props +}: NewSessionBranchPickerComponentProps): React.ReactNode { + return ; +} + +function RuntimeNewSessionBranchPickerForWorkspace({ + cwd, + selectedBranch, + disabled, + onSelect, +}: NewSessionBranchPickerComponentProps): React.ReactNode { + const [open, setOpen] = useState(false); + const { data: status } = useGitStatus(cwd); + const { + data: branchList, + isLoading, + error, + } = useGitBranches(open && status?.isRepo ? cwd : undefined); + + // Status is the immediate repository gate. Detached HEAD remains a repository and stays visible. + if (!status?.isRepo) return null; + + return ( + + ); +} diff --git a/packages/client/workbench/src/mock/data/git.ts b/packages/client/workbench/src/mock/data/git.ts index 6e6066a93..33138ed68 100644 --- a/packages/client/workbench/src/mock/data/git.ts +++ b/packages/client/workbench/src/mock/data/git.ts @@ -1,8 +1,9 @@ -import type { GitDiff, GitPullRequestStatus, GitStatus } from '@linkcode/schema'; +import type { GitBranchList, GitDiff, GitPullRequestStatus, GitStatus } from '@linkcode/schema'; import { normalizeCwdKey } from '@linkcode/schema'; export interface GitFixture { status: GitStatus; + branchList: GitBranchList; prStatus: GitPullRequestStatus; diff: GitDiff; } @@ -46,6 +47,13 @@ const FIXTURES = new Map( behind: 2, remote: MOCK_REMOTE, }, + branchList: { + isRepo: true, + branches: [ + { name: 'mock-host', isCurrent: true, lastCommitAt: 1_720_000_000_000 }, + { name: 'main', isCurrent: false, lastCommitAt: 1_710_000_000_000 }, + ], + }, prStatus: { status: 'ok', pullRequest: { @@ -73,11 +81,16 @@ const FIXTURES = new Map( behind: 0, remote: MOCK_REMOTE, }, + branchList: { + isRepo: true, + branches: [{ name: 'main', isCurrent: true, lastCommitAt: 1_720_000_000_000 }], + }, prStatus: { status: 'ok', pullRequest: null }, diff: EMPTY_DIFF, }, '/mock/scratch': { status: { isRepo: false }, + branchList: { isRepo: false }, prStatus: { status: 'unavailable', reason: 'not_git_repo' }, diff: EMPTY_DIFF, }, @@ -97,6 +110,10 @@ export function gitFixtureFor(cwd: string): GitFixture { behind: 0, remote: MOCK_REMOTE, }, + branchList: { + isRepo: true, + branches: [{ name: 'mock-host', isCurrent: true, lastCommitAt: 1_720_000_000_000 }], + }, prStatus: { status: 'ok', pullRequest: null }, diff: RICH_DIFF, } diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 00d4ec17c..da0e1e57b 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -395,6 +395,14 @@ export class DevMockHost { status: gitFixtureFor(p.cwd).status, }); break; + case 'git.branch.list': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'git.branch.list.result', + replyTo: p.clientReqId, + branchList: gitFixtureFor(p.cwd).branchList, + }); + break; case 'git.pr_status.get': await wait(CONTROL_LATENCY_MS); this.send({ diff --git a/packages/client/workbench/src/palette/command-palette.tsx b/packages/client/workbench/src/palette/command-palette.tsx index 15199c212..9fd6a890b 100644 --- a/packages/client/workbench/src/palette/command-palette.tsx +++ b/packages/client/workbench/src/palette/command-palette.tsx @@ -48,8 +48,19 @@ function OpenCommandPalette({ sessions }: WorkbenchCommandPaletteProps): React.R const workspaceByCwd = new Map( (workspaces ?? []).map((workspace) => [normalizeCwdKey(workspace.cwd), workspace]), ); + const workspaceById = new Map( + (workspaces ?? []).map((workspace) => [workspace.workspaceId, workspace]), + ); const candidates: PaletteThreadCandidate[] = sessions.sessions.map((session) => { - const workspace = workspaceByCwd.get(normalizeCwdKey(session.cwd)); + const matched = workspaceByCwd.get(normalizeCwdKey(session.cwd)); + let workspace = matched; + if (matched && workspaceKind(matched) === 'worktree') { + const parent = + matched.parentWorkspaceId === undefined + ? undefined + : workspaceById.get(matched.parentWorkspaceId); + workspace = parent && workspaceKind(parent) === 'project' ? parent : undefined; + } return { session, title: session.title ?? `${AGENT_LABELS[session.kind]} in ${repositoryLabel(session.cwd)}`, @@ -87,7 +98,7 @@ function OpenCommandPalette({ sessions }: WorkbenchCommandPaletteProps): React.R ]; let targetWorkspace: WorkspaceRecord | null = null; for (const workspace of workspaces ?? []) { - if (workspaceKind(workspace) !== 'chat') { + if (workspaceKind(workspace) === 'project') { targetWorkspace = workspace; break; } diff --git a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts index 0ea9e1983..14da7fd08 100644 --- a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts +++ b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts @@ -1,7 +1,7 @@ import { WorkspaceIdSchema } from '@linkcode/schema'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -const STORAGE_KEY = 'linkcode.workbench.new-session-defaults:v3'; +const STORAGE_KEY = 'linkcode.workbench.new-session-defaults:v4'; const WORKSPACE_ID = WorkspaceIdSchema.parse('workspace-1'); const stored = new Map(); const storage = { @@ -63,6 +63,20 @@ describe('new-session defaults', () => { expect(restarted.getState().effortsByProvider).toEqual({ 'grok-build': 'medium' }); }); + it('persists branch choices per workspace and rehydrates them', async () => { + const otherWorkspaceId = WorkspaceIdSchema.parse('workspace-2'); + const first = await loadStore(); + first.getState().remember('codex', WORKSPACE_ID, {}, 'main'); + first.getState().remember('codex', otherWorkspaceId, {}, 'release'); + + const restarted = await loadStore(); + + expect(restarted.getState().branchesByWorkspace).toEqual({ + [WORKSPACE_ID]: 'main', + [otherWorkspaceId]: 'release', + }); + }); + it('discards malformed persisted selections at the schema boundary', async () => { storage.setItem( STORAGE_KEY, @@ -82,5 +96,6 @@ describe('new-session defaults', () => { expect(store.getState().lastProvider).toBeNull(); expect(store.getState().modelsByProvider).toEqual({}); expect(store.getState().effortsByProvider).toEqual({}); + expect(store.getState().branchesByWorkspace).toEqual({}); }); }); diff --git a/packages/client/workbench/src/surface/new-session-defaults-store.ts b/packages/client/workbench/src/surface/new-session-defaults-store.ts index 55c9bb126..86e2c1ab2 100644 --- a/packages/client/workbench/src/surface/new-session-defaults-store.ts +++ b/packages/client/workbench/src/surface/new-session-defaults-store.ts @@ -10,6 +10,7 @@ const PersistedNewSessionDefaultsSchema = z lastWorkspaceId: WorkspaceIdSchema.nullable(), modelsByProvider: z.partialRecord(AgentKindSchema, z.string().min(1)), effortsByProvider: z.partialRecord(AgentKindSchema, EffortLevelSchema), + branchesByWorkspace: z.record(z.string(), z.string().min(1)), }) .partial(); type PersistedNewSessionDefaults = z.infer; @@ -30,7 +31,14 @@ export interface NewSessionDefaultsState { modelsByProvider: Partial>; /** Last effort accepted by LinkCode per provider; absent means defer to the provider default. */ effortsByProvider: Partial>; - remember: (provider: AgentKind, workspaceId: WorkspaceId, selection: NewSessionSelection) => void; + /** Last explicitly selected branch per workspace. */ + branchesByWorkspace: Record; + remember: ( + provider: AgentKind, + workspaceId: WorkspaceId, + selection: NewSessionSelection, + branch?: string, + ) => void; rememberSelection: (provider: AgentKind, selection: NewSessionSelection) => void; } @@ -73,23 +81,29 @@ export const useNewSessionDefaultsStore = create()( lastWorkspaceId: null, modelsByProvider: {}, effortsByProvider: {}, - remember: (provider, workspaceId, selection) => + branchesByWorkspace: {}, + remember: (provider, workspaceId, selection, branch) => set((state) => ({ ...selectionPatch(state, provider, selection), lastProvider: provider, lastWorkspaceId: workspaceId, + branchesByWorkspace: + branch === undefined + ? state.branchesByWorkspace + : { ...state.branchesByWorkspace, [workspaceId]: branch }, })), rememberSelection: (provider, selection) => set((state) => selectionPatch(state, provider, selection)), }), { - name: 'linkcode.workbench.new-session-defaults:v3', + name: 'linkcode.workbench.new-session-defaults:v4', schema: PersistedNewSessionDefaultsSchema, partialize: (state) => ({ lastProvider: state.lastProvider, lastWorkspaceId: state.lastWorkspaceId, modelsByProvider: state.modelsByProvider, effortsByProvider: state.effortsByProvider, + branchesByWorkspace: state.branchesByWorkspace, }), }, ), diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index 14119d913..0500ae319 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -44,6 +44,7 @@ export interface WorkbenchSessions { effort?: EffortLevel; approvalPolicyId?: string; modeId?: SessionModeId; + branch?: { name: string }; }) => Promise; /** Stop the session if live and remove it from the list; re-importable from provider history. */ close: (id: SessionId) => void; @@ -188,6 +189,7 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench effort?: EffortLevel; approvalPolicyId?: string; modeId?: SessionModeId; + branch?: { name: string }; }): Promise { const startedAt = Date.now(); // Captured now: by resolve time the surface still shows the draft, and the recorded diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 274ce1c3f..e2bd353f7 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -49,6 +49,7 @@ import { useTranslations } from 'use-intl'; import { useAgentRuntimeOnboarding } from '../agent-runtime/onboarding'; import { captureProductEvent } from '../analytics/product-analytics'; import { useFileMentionSource } from '../files/mentions'; +import { RuntimeNewSessionBranchPicker } from '../git/new-session-branch-picker'; import { WorkbenchCommandPalette } from '../palette/command-palette'; import { openCommandPalette } from '../palette/store'; import { useWorkbenchSdkClient } from '../runtime/provider'; @@ -265,6 +266,9 @@ function WorkbenchSessionSurface({ const lastWorkspaceId = useNewSessionDefaultsStore((state) => state.lastWorkspaceId); const newSessionPreferredModels = useNewSessionDefaultsStore((state) => state.modelsByProvider); const newSessionPreferredEfforts = useNewSessionDefaultsStore((state) => state.effortsByProvider); + const newSessionPreferredBranches = useNewSessionDefaultsStore( + (state) => state.branchesByWorkspace, + ); const onboarding = useAgentRuntimeOnboarding(); const rememberNewSessionDefaults = useNewSessionDefaultsStore((state) => state.remember); const rememberSelection = useNewSessionDefaultsStore((state) => state.rememberSelection); @@ -351,12 +355,18 @@ function WorkbenchSessionSurface({ effort: submission.effort ?? undefined, approvalPolicyId: submission.approvalPolicyId, modeId: submission.modeId, + branch: submission.branch, }); const startupSelection = reflectedStartupSelection( submission, sdkClient.raw.eventsSnapshot(sessionId), ); - rememberNewSessionDefaults(submission.kind, submission.workspaceId, startupSelection); + rememberNewSessionDefaults( + submission.kind, + submission.workspaceId, + startupSelection, + submission.branch?.name, + ); // The first input rides behind the started session, like any conversation send. void turnInputMutation .trigger({ sessionId, input: submission.input }) @@ -508,16 +518,17 @@ function WorkbenchSessionSurface({ // The chat workspace is a fixed system entry (the sidebar's "Chats" section, not a Projects // group) — split out so the new-session picker offers it as its own "Chat" entry. const allWorkspaces = workspaces ?? []; - const workspacesById = new Map( - allWorkspaces.map((workspace) => [workspace.workspaceId, workspace] as const), - ); - const workspaceIds = new Set(workspacesById.keys()); + const workspacesById = new Map(); let chatWorkspace: WorkspaceRecord | null = null; const projectWorkspaces: WorkspaceRecord[] = []; for (const workspace of allWorkspaces) { - if (workspaceKind(workspace) === 'chat') chatWorkspace ??= workspace; + const kind = workspaceKind(workspace); + if (kind === 'worktree') continue; + workspacesById.set(workspace.workspaceId, workspace); + if (kind === 'chat') chatWorkspace ??= workspace; else projectWorkspaces.push(workspace); } + const workspaceIds = new Set(workspacesById.keys()); // Resolve the draft's initial picks: an explicit preselection (group "+", Chats "+") wins, then // the persisted last-used workspace (if it still exists), then chat, then the first project. @@ -616,6 +627,8 @@ function WorkbenchSessionSurface({ agentCatalogs={agentCatalogs} newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} + newSessionPreferredBranches={newSessionPreferredBranches} + NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} runtimeCues={onboarding.cues} onDownloadAgent={onboarding.download} onContinueUnverified={onboarding.acknowledgeUnverified} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 0bcb159fd..a60368cf0 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -316,6 +316,13 @@ describe('dev mock transport', () => { branch: 'mock-host', dirtyFileCount: 3, }); + await expect(client.listGitBranches('/mock/linkcode')).resolves.toEqual({ + isRepo: true, + branches: [ + { name: 'mock-host', isCurrent: true, lastCommitAt: 1_720_000_000_000 }, + { name: 'main', isCurrent: false, lastCommitAt: 1_710_000_000_000 }, + ], + }); await expect(client.getGitPullRequestStatus('/mock/linkcode')).resolves.toMatchObject({ status: 'ok', pullRequest: { checks: 'failing', reviewDecision: 'changes_requested' }, @@ -333,6 +340,7 @@ describe('dev mock transport', () => { pullRequest: null, }); await expect(client.getGitStatus('/mock/scratch')).resolves.toEqual({ isRepo: false }); + await expect(client.listGitBranches('/mock/scratch')).resolves.toEqual({ isRepo: false }); await expect(client.getGitPullRequestStatus('/mock/scratch')).resolves.toEqual({ status: 'unavailable', reason: 'not_git_repo', diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 21607d939..03f64c306 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -53,6 +53,8 @@ export const StartOptionsSchema = z.object({ kind: AgentKindSchema, /** Working directory (the root of the repository the agent operates on). */ cwd: z.string().min(1), + /** Existing local branch to run in. The host consumes this intent before adapter startup. */ + branch: z.object({ name: z.string().min(1) }).optional(), /** Model id override (vendor-specific). Undefined applies the LinkCode-configured default; * null explicitly defers to the agent/provider's own default. */ model: z.string().nullable().optional(), diff --git a/packages/foundation/schema/src/model/git.ts b/packages/foundation/schema/src/model/git.ts index a18529fe3..b2d0230c4 100644 --- a/packages/foundation/schema/src/model/git.ts +++ b/packages/foundation/schema/src/model/git.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { TimestampSchema } from './primitives'; /** * Git data contracts (data plane). Capability-shaped and provider-neutral: the wire never names @@ -45,6 +46,21 @@ export const GitStatusSchema = z.discriminatedUnion('isRepo', [ ]); export type GitStatus = z.infer; +/** One local branch, with the timestamp of its tip commit. */ +export const GitBranchSchema = z.object({ + name: z.string().min(1), + isCurrent: z.boolean(), + lastCommitAt: TimestampSchema, +}); +export type GitBranch = z.infer; + +/** Local branches for one directory. Non-repositories degrade to a typed empty state. */ +export const GitBranchListSchema = z.discriminatedUnion('isRepo', [ + z.object({ isRepo: z.literal(false) }), + z.object({ isRepo: z.literal(true), branches: z.array(GitBranchSchema) }), +]); +export type GitBranchList = z.infer; + /** Which base a diff is computed against. `base` compares HEAD against the merge-base with the * remote's default branch; `uncommitted` compares the working tree (tracked + untracked) against HEAD. */ export const GitDiffModeSchema = z.enum(['uncommitted', 'base']); diff --git a/packages/foundation/schema/src/model/index.ts b/packages/foundation/schema/src/model/index.ts index 6ad95a602..b461ab34e 100644 --- a/packages/foundation/schema/src/model/index.ts +++ b/packages/foundation/schema/src/model/index.ts @@ -25,3 +25,4 @@ export * from './terminal'; export * from './tool-call'; export * from './usage'; export * from './workspace'; +export * from './worktree'; diff --git a/packages/foundation/schema/src/model/workspace.ts b/packages/foundation/schema/src/model/workspace.ts index e63557c52..d3c331708 100644 --- a/packages/foundation/schema/src/model/workspace.ts +++ b/packages/foundation/schema/src/model/workspace.ts @@ -2,23 +2,41 @@ import { z } from 'zod'; import { TimestampSchema, WorkspaceIdSchema } from './primitives'; /** `project`: a directory the user explicitly registered. `chat`: the single daemon-owned chat - * root (`~/LinkCode`) backing the sidebar's "Chats" section — a fixed system entry the user - * doesn't manage. */ -export const WorkspaceKindSchema = z.enum(['project', 'chat']); + * root (`~/LinkCode`) backing the sidebar's "Chats" section. `worktree`: a daemon-managed + * worktree belonging to a project workspace. */ +export const WorkspaceKindSchema = z.enum(['project', 'chat', 'worktree']); export type WorkspaceKind = z.infer; /** A workspace is a registered directory: the persisted identity behind "recent directories", * independent of any session — a session's `cwd` remains the source of truth for where it runs. */ -export const WorkspaceRecordSchema = z.object({ - workspaceId: WorkspaceIdSchema, - cwd: z.string().min(1), - /** Derived from the path's last segment when not set explicitly. */ - name: z.string().min(1).optional(), - /** Absent on records from before this field existed, or from an older client — read via {@link workspaceKind}. */ - kind: WorkspaceKindSchema.optional(), - createdAt: TimestampSchema, - lastUsedAt: TimestampSchema, -}); +export const WorkspaceRecordSchema = z + .object({ + workspaceId: WorkspaceIdSchema, + cwd: z.string().min(1), + /** Derived from the path's last segment when not set explicitly. */ + name: z.string().min(1).optional(), + /** Absent on records from before this field existed, or from an older client — read via {@link workspaceKind}. */ + kind: WorkspaceKindSchema.optional(), + /** Owning project; present only for daemon-managed worktrees. */ + parentWorkspaceId: WorkspaceIdSchema.optional(), + createdAt: TimestampSchema, + lastUsedAt: TimestampSchema, + }) + .superRefine((record, context) => { + if (record.kind === 'worktree' && record.parentWorkspaceId === undefined) { + context.addIssue({ + code: 'custom', + path: ['parentWorkspaceId'], + message: 'Worktree workspaces require a parent workspace', + }); + } else if (record.kind !== 'worktree' && record.parentWorkspaceId !== undefined) { + context.addIssue({ + code: 'custom', + path: ['parentWorkspaceId'], + message: 'Only worktree workspaces may have a parent workspace', + }); + } + }); export type WorkspaceRecord = z.infer; /** `record.kind`, defaulting to `'project'` when absent (see {@link WorkspaceRecordSchema.kind}). */ diff --git a/packages/foundation/schema/src/model/worktree.ts b/packages/foundation/schema/src/model/worktree.ts new file mode 100644 index 000000000..83ab603c3 --- /dev/null +++ b/packages/foundation/schema/src/model/worktree.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { SessionIdSchema, TimestampSchema } from './primitives'; + +export const WorktreeStateSchema = z.enum(['active', 'orphaned']); +export type WorktreeState = z.infer; + +/** Durable ownership record for a LinkCode-managed git worktree. */ +export const WorktreeRecordSchema = z.object({ + worktreePath: z.string().min(1), + repoRoot: z.string().min(1), + branch: z.string().min(1), + sessionId: SessionIdSchema, + createdAt: TimestampSchema, + state: WorktreeStateSchema, +}); +export type WorktreeRecord = z.infer; diff --git a/packages/foundation/schema/src/wire/git.ts b/packages/foundation/schema/src/wire/git.ts index 88ff8eb7b..bc3057583 100644 --- a/packages/foundation/schema/src/wire/git.ts +++ b/packages/foundation/schema/src/wire/git.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { + GitBranchListSchema, GitDiffModeSchema, GitDiffSchema, GitPullRequestStatusSchema, @@ -19,6 +20,16 @@ export const gitWireVariants = [ replyTo: WireRequestIdSchema, status: GitStatusSchema, }), + z.object({ + kind: z.literal('git.branch.list'), + clientReqId: WireRequestIdSchema, + cwd: z.string().min(1), + }), + z.object({ + kind: z.literal('git.branch.list.result'), + replyTo: WireRequestIdSchema, + branchList: GitBranchListSchema, + }), z.object({ kind: z.literal('git.pr_status.get'), clientReqId: WireRequestIdSchema, diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 901213527..bc78deaed 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WirePayloadSchema } from './payload'; */ // Bump on every wire schema change; mismatched peers silently discard all frames (Invariant 1). -export const WIRE_PROTOCOL_VERSION = 62 as const; +export const WIRE_PROTOCOL_VERSION = 63 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/foundation/schema/tests/contract/workspace.test.ts b/packages/foundation/schema/tests/contract/workspace.test.ts new file mode 100644 index 000000000..b31f6ae75 --- /dev/null +++ b/packages/foundation/schema/tests/contract/workspace.test.ts @@ -0,0 +1,40 @@ +import { WorkspaceRecordSchema } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +const base = { + workspaceId: 'ws-1', + cwd: '/repo', + createdAt: 1, + lastUsedAt: 2, +}; + +describe('WorkspaceRecordSchema', () => { + it('keeps legacy absent-kind project records valid', () => { + expect(WorkspaceRecordSchema.safeParse(base).success).toBe(true); + }); + + it('requires parentWorkspaceId only for worktree records', () => { + expect( + WorkspaceRecordSchema.safeParse({ + ...base, + kind: 'worktree', + parentWorkspaceId: 'ws-parent', + }).success, + ).toBe(true); + expect(WorkspaceRecordSchema.safeParse({ ...base, kind: 'worktree' }).success).toBe(false); + expect( + WorkspaceRecordSchema.safeParse({ + ...base, + kind: 'project', + parentWorkspaceId: 'ws-parent', + }).success, + ).toBe(false); + expect( + WorkspaceRecordSchema.safeParse({ + ...base, + kind: 'chat', + parentWorkspaceId: 'ws-parent', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/host/engine/src/__tests__/fixtures/session-harness.ts b/packages/host/engine/src/__tests__/fixtures/session-harness.ts index dba927ff4..be9127b09 100644 --- a/packages/host/engine/src/__tests__/fixtures/session-harness.ts +++ b/packages/host/engine/src/__tests__/fixtures/session-harness.ts @@ -20,6 +20,7 @@ import { createWireMessage } from '@linkcode/transport'; import { nullthrow } from 'foxts/guard'; import { noop } from 'foxts/noop'; import type { ProviderConfigStore } from '../../agent/provider-config'; +import type { EngineDeps } from '../../deps'; import type { SessionStore } from '../../session/session-store'; import { InMemorySessionStore } from '../../session/session-store'; import type { WorkspaceStore } from '../../workspace/workspace-store'; @@ -107,6 +108,7 @@ export function createSessionHarness( agentRuntimesReady?: Promise, workspaceStore?: WorkspaceStore, providerStore?: ProviderConfigStore, + extraDeps: EngineDeps = {}, ) { const sent: WirePayload[] = []; let handler: ((msg: ValidatedWireMessage) => void) | null = null; @@ -129,6 +131,7 @@ export function createSessionHarness( return adapter; }; const engine = createTestEngine(transport, { + ...extraDeps, factory, sessionStore: store, collectAgentRuntimes, diff --git a/packages/host/engine/src/__tests__/workspace-registry.test.ts b/packages/host/engine/src/__tests__/workspace-registry.test.ts index f5a61e123..138fc306e 100644 --- a/packages/host/engine/src/__tests__/workspace-registry.test.ts +++ b/packages/host/engine/src/__tests__/workspace-registry.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { WorkspaceId, WorkspaceRecord } from '@linkcode/schema'; -import { normalizeCwdKey } from '@linkcode/schema'; +import { normalizeCwdKey, WorkspaceIdSchema } from '@linkcode/schema'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkspaceRegistry } from '../workspace/workspace-registry'; import { InMemoryWorkspaceStore } from '../workspace/workspace-store'; @@ -69,6 +69,61 @@ describe('WorkspaceRegistry', () => { expect(registry.list()).toHaveLength(0); }); + it('register() rejects client-supplied worktree records', async () => { + const registry = new WorkspaceRegistry(); + await expect(registry.register({ cwd: makeTempDir(), kind: 'worktree' })).rejects.toMatchObject( + { code: 'invalid_request' }, + ); + expect(registry.list()).toHaveLength(0); + }); + + it('registerWorktree() requires a project parent and persists trusted metadata', async () => { + const registry = new WorkspaceRegistry(); + const parent = await registry.register({ cwd: makeTempDir() }); + const cwd = makeTempDir(); + + const record = await registry.registerWorktree({ + cwd, + parentWorkspaceId: parent.workspaceId, + branch: 'feature/code-429', + }); + + expect(record).toMatchObject({ + cwd, + name: 'feature/code-429', + kind: 'worktree', + parentWorkspaceId: parent.workspaceId, + }); + await expect( + registry.registerWorktree({ + cwd: makeTempDir(), + parentWorkspaceId: WorkspaceIdSchema.parse('ws-missing'), + branch: 'invalid', + }), + ).rejects.toMatchObject({ code: 'invalid_request' }); + }); + + it('registerWorktree() upgrades an existing cwd atomically and keeps its id', async () => { + const registry = new WorkspaceRegistry(); + const parent = await registry.register({ cwd: makeTempDir() }); + const cwd = makeTempDir(); + const intermediate = await registry.touch(cwd); + + const upgraded = await registry.registerWorktree({ + cwd, + parentWorkspaceId: parent.workspaceId, + branch: 'feature', + }); + + expect(upgraded.workspaceId).toBe(intermediate.workspaceId); + expect(upgraded).toMatchObject({ + kind: 'worktree', + parentWorkspaceId: parent.workspaceId, + name: 'feature', + }); + expect(registry.list()).toHaveLength(2); + }); + it('touch() resolves a relative cwd to the same record as an absolute register() cwd', async () => { const registry = new WorkspaceRegistry(); const root = makeTempDir(); diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 323bb761b..d7dc6f8c7 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -14,6 +14,7 @@ import type { SimulatorService } from './simulator/service'; import type { PtyBackend } from './terminal/pty-backend'; import type { FileSuggestService } from './workspace/file-suggest-service'; import type { WorkspaceStore } from './workspace/workspace-store'; +import type { WorktreeStore } from './worktree/worktree-store'; /** Optional collaborators the daemon injects; each defaults to an in-memory/no-op implementation. */ export interface EngineDeps { @@ -37,6 +38,9 @@ export interface EngineDeps { git?: GitService; fileSuggest?: FileSuggestService; workspaceStore?: WorkspaceStore; + /** Durable managed-worktree registry and daemon-owned root. Both are optional for embedders. */ + worktreeStore?: WorktreeStore; + worktreeRoot?: string; /** Shared with the transport's reverse proxy; scripts need a PTY backend to run. */ previewRoutes?: PreviewRouteRegistry; /** Boot-time probe result (`collectAgentRuntimes()`), served to clients on `agent-runtime.list`. */ diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 92886b58d..003e2383e 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -51,6 +51,8 @@ import { FileSuggestService } from './workspace/file-suggest-service'; import { WorkspaceRequestHandler } from './workspace/request-handler'; import { WorkspaceRegistry } from './workspace/workspace-registry'; import { InMemoryWorkspaceStore } from './workspace/workspace-store'; +import { WorktreeService } from './worktree/worktree-service'; +import { InMemoryWorktreeStore } from './worktree/worktree-store'; /** * The local core engine — the "host" that runs the agents, carrier-agnostic @@ -127,6 +129,11 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const workspaces = new WorkspaceRegistry(deps.workspaceStore ?? new InMemoryWorkspaceStore()); const workspaceRequests = new WorkspaceRequestHandler(transport, workspaces, responder); const git = deps.git ?? (yield* GitService.make()); + const worktrees = new WorktreeService( + deps.worktreeStore ?? new InMemoryWorktreeStore(), + deps.worktreeRoot, + git, + ); const gitRequests = new GitRequestHandler(transport, git, responder); const fileSuggest = deps.fileSuggest ?? (yield* FileSuggestService.make()); const routes = deps.previewRoutes ?? new PreviewRouteRegistry(); @@ -156,6 +163,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( history, startOptions, workspaces, + worktrees, ); const sessionRequests = new SessionRequestHandler( transport, @@ -229,9 +237,24 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( yield* records.start((effect) => { runTask(effect); }); + yield* worktrees.start(new Set(Array.from(records.values(), ({ sessionId }) => sessionId))); yield* tryOperation('store', 'workspaces.load', 'Failed to load workspaces', () => workspaces.start(), ); + for (const workspace of workspaces.list()) { + if (workspace.kind === 'worktree' && !worktrees.hasPath(workspace.cwd)) { + yield* tryOperation( + 'store', + 'workspace.archive-stale', + 'Failed to archive workspace', + () => workspaces.archive(workspace.workspaceId), + ).pipe( + Effect.catch((error) => + Effect.logWarning('Stale worktree workspace reconciliation deferred', error), + ), + ); + } + } // Reconcile imports created before workspace auto-registration. Known workspaces are skipped so // daemon startup never renames or freshens an existing project merely because it has imports. for (const record of records.values()) { diff --git a/packages/host/engine/src/failure.ts b/packages/host/engine/src/failure.ts index c366e9db1..486cf439e 100644 --- a/packages/host/engine/src/failure.ts +++ b/packages/host/engine/src/failure.ts @@ -12,6 +12,7 @@ export type RequestErrorCode = /** The request was understood and refused — the user withheld consent, not a broken call. */ | 'forbidden' | 'unsupported' + | 'worktree_missing' | 'limit_exceeded' | 'cancelled'; diff --git a/packages/host/engine/src/git/branches.ts b/packages/host/engine/src/git/branches.ts new file mode 100644 index 000000000..ea323b6f9 --- /dev/null +++ b/packages/host/engine/src/git/branches.ts @@ -0,0 +1,43 @@ +import type { GitBranch } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { runCommand } from '../process/run-command'; + +const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } as const; +const FIELD_SEPARATOR = '\0'; + +const git = Effect.fn('Git.branchCommand')(function* (cwd: string, ...args: string[]) { + return yield* runCommand('git', args, { cwd, env: GIT_ENV }); +}); + +/** List local branches only, with the current branch first and all others newest-tip first. */ +export const readGitBranches = Effect.fn('Git.readBranches')(function* (cwd: string) { + const repo = yield* git(cwd, 'rev-parse', '--git-dir').pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (repo?.exitCode !== 0) return { isRepo: false } as const; + + const result = yield* git( + cwd, + 'for-each-ref', + '--format=%(refname:short)%00%(HEAD)%00%(committerdate:unix)', + 'refs/heads', + ); + if (result.exitCode !== 0) return { isRepo: false } as const; + + const branches: GitBranch[] = []; + for (const line of result.stdout.split('\n')) { + if (line.length === 0) continue; + const [name, head, committedAt] = line.split(FIELD_SEPARATOR); + if (!name) continue; + branches.push({ + name, + isCurrent: head === '*', + lastCommitAt: Number(committedAt) * 1000, + }); + } + branches.sort((left, right) => { + if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1; + return right.lastCommitAt - left.lastCommitAt; + }); + return { isRepo: true as const, branches }; +}); diff --git a/packages/host/engine/src/git/git-service.ts b/packages/host/engine/src/git/git-service.ts index ae9baeefb..eca435142 100644 --- a/packages/host/engine/src/git/git-service.ts +++ b/packages/host/engine/src/git/git-service.ts @@ -1,7 +1,14 @@ -import type { GitDiff, GitDiffMode, GitPullRequestStatus, GitStatus } from '@linkcode/schema'; +import type { + GitBranchList, + GitDiff, + GitDiffMode, + GitPullRequestStatus, + GitStatus, +} from '@linkcode/schema'; import { Cache, Effect, Exit } from 'effect'; import type { EngineFailure } from '../failure'; import { OperationError, toOperationFailure } from '../failure'; +import { readGitBranches } from './branches'; import { readGitDiff } from './diff'; import { GhCliGitHubClient } from './github'; import type { GitProviderClient } from './provider'; @@ -24,6 +31,7 @@ type GitDiffKey = readonly [cwd: string, mode: GitDiffMode]; export class GitService { private constructor( private readonly statusCache: Cache.Cache, + private readonly branchListCache: Cache.Cache, private readonly prStatusCache: Cache.Cache, private readonly diffCache: Cache.Cache, ) {} @@ -45,6 +53,19 @@ export class GitService { ), STATUS_TTL_MS, ); + const branchListCache = yield* makeCache( + (cwd: string) => + readGitBranches(cwd).pipe( + Effect.mapError((cause) => + toOperationFailure(cause, { + subsystem: 'git', + operation: 'git.branch.list', + publicMessage: 'Failed to list git branches', + }), + ), + ), + STATUS_TTL_MS, + ); const diffCache = yield* makeCache( ([cwd, mode]: GitDiffKey) => readGitDiff(cwd, mode).pipe( @@ -99,13 +120,17 @@ export class GitService { Exit.isSuccess(exit) && exit.value.status !== 'error' ? PR_STATUS_TTL_MS : 0, }, ); - return new GitService(statusCache, prStatusCache, diffCache); + return new GitService(statusCache, branchListCache, prStatusCache, diffCache); }); getStatus(cwd: string): Effect.Effect { return Cache.get(this.statusCache, cwd); } + listBranches(cwd: string): Effect.Effect { + return Cache.get(this.branchListCache, cwd); + } + getDiff(cwd: string, mode: GitDiffMode): Effect.Effect { return Cache.get(this.diffCache, [cwd, mode]); } @@ -113,6 +138,15 @@ export class GitService { getPullRequestStatus(cwd: string): Effect.Effect { return Cache.get(this.prStatusCache, cwd); } + + /** Drop cwd-scoped reads after a git mutation so the next poll observes it immediately. */ + invalidate(cwd: string): Effect.Effect { + return Effect.all([ + Cache.invalidate(this.statusCache, cwd), + Cache.invalidate(this.branchListCache, cwd), + Cache.invalidate(this.prStatusCache, cwd), + ]).pipe(Effect.asVoid); + } } function makeCache(lookup: (key: Key) => Effect.Effect, ttl: number) { diff --git a/packages/host/engine/src/git/request-handler.ts b/packages/host/engine/src/git/request-handler.ts index 8ce2a6bc7..0886d215b 100644 --- a/packages/host/engine/src/git/request-handler.ts +++ b/packages/host/engine/src/git/request-handler.ts @@ -7,7 +7,7 @@ import type { GitService } from './git-service'; type GitRequest = Extract< WirePayload, - { kind: 'git.status.get' | 'git.pr_status.get' | 'git.diff.get' } + { kind: 'git.status.get' | 'git.branch.list' | 'git.pr_status.get' | 'git.diff.get' } >; /** Translates inbound git requests into cached local and provider reads. */ @@ -37,6 +37,23 @@ export class GitRequestHandler { ), ), ); + case 'git.branch.list': + return this.responder.reply( + payload.clientReqId, + this.git.listBranches(payload.cwd).pipe( + Effect.flatMap((branchList) => + Effect.sync(() => + this.transport.send( + createWireMessage({ + kind: 'git.branch.list.result', + replyTo: payload.clientReqId, + branchList, + }), + ), + ), + ), + ), + ); case 'git.pr_status.get': return this.responder.reply( payload.clientReqId, diff --git a/packages/host/engine/src/git/worktrees.ts b/packages/host/engine/src/git/worktrees.ts new file mode 100644 index 000000000..706a65e5a --- /dev/null +++ b/packages/host/engine/src/git/worktrees.ts @@ -0,0 +1,147 @@ +import { Effect } from 'effect'; +import { runCommand } from '../process/run-command'; + +const READ_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } as const; +const WRITE_ENV = { GIT_TERMINAL_PROMPT: '0' } as const; +const MUTATION_TIMEOUT_MS = 120000; +const RE_ZERO = /^0\s*$/; +const RE_TRAILING_SLASH = /\/$/; + +const gitRead = Effect.fn('Git.worktreeRead')(function* (cwd: string, ...args: string[]) { + return yield* runCommand('git', args, { cwd, env: READ_ENV }); +}); + +export const resolveRepoRoot = Effect.fn('Git.resolveRepoRoot')(function* (cwd: string) { + const result = yield* gitRead(cwd, 'rev-parse', '--show-toplevel'); + return result.exitCode === 0 ? result.stdout.trim() : undefined; +}); + +export const readCurrentBranch = Effect.fn('Git.readCurrentBranch')(function* (repoRoot: string) { + const result = yield* gitRead(repoRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD'); + return result.exitCode === 0 ? result.stdout.trim() : undefined; +}); + +export const localBranchExists = Effect.fn('Git.localBranchExists')(function* ( + repoRoot: string, + branch: string, +) { + const result = yield* gitRead( + repoRoot, + 'show-ref', + '--verify', + '--quiet', + `refs/heads/${branch}`, + ); + return result.exitCode === 0; +}); + +export const addWorktree = Effect.fn('Git.addWorktree')(function* ( + repoRoot: string, + worktreePath: string, + branch: string, +) { + return yield* runCommand('git', ['worktree', 'add', '--', worktreePath, branch], { + cwd: repoRoot, + env: WRITE_ENV, + timeoutMs: MUTATION_TIMEOUT_MS, + }); +}); + +export const removeWorktreeBestEffort = Effect.fn('Git.removeWorktreeBestEffort')(function* ( + repoRoot: string, + worktreePath: string, +) { + // Only for rolling back a newly-created worktree whose first ownership save failed. Normal + // lifecycle cleanup must use removeWorktree(), whose non-force check is the TOCTOU safety bound. + yield* runCommand('git', ['worktree', 'remove', '--force', '--', worktreePath], { + cwd: repoRoot, + env: WRITE_ENV, + timeoutMs: MUTATION_TIMEOUT_MS, + }).pipe(Effect.catch(() => Effect.void)); + yield* runCommand('git', ['worktree', 'prune'], { + cwd: repoRoot, + env: WRITE_ENV, + timeoutMs: MUTATION_TIMEOUT_MS, + }).pipe(Effect.catch(() => Effect.void)); +}); + +export const inspectWorktreeCleanup = Effect.fn('Git.inspectWorktreeCleanup')(function* ( + worktreePath: string, + expectedBranch?: string, +) { + const branch = yield* gitRead(worktreePath, 'symbolic-ref', '--quiet', '--short', 'HEAD'); + if (branch.exitCode !== 0 || !branch.stdout.trim()) return false; + if (expectedBranch !== undefined && branch.stdout.trim() !== expectedBranch) return false; + const status = yield* gitRead(worktreePath, 'status', '--porcelain', '--untracked-files=all'); + if (status.exitCode !== 0 || status.stdout.length !== 0) return false; + const upstream = yield* gitRead( + worktreePath, + 'rev-parse', + '--abbrev-ref', + '--symbolic-full-name', + '@{upstream}', + ); + if (upstream.exitCode !== 0 || !upstream.stdout.trim()) return false; + const ahead = yield* gitRead(worktreePath, 'rev-list', '--count', '@{upstream}..HEAD'); + return ahead.exitCode === 0 && RE_ZERO.test(ahead.stdout); +}); + +export const removeWorktree = Effect.fn('Git.removeWorktree')(function* ( + repoRoot: string, + worktreePath: string, +) { + return yield* runCommand('git', ['worktree', 'remove', '--', worktreePath], { + cwd: repoRoot, + env: WRITE_ENV, + timeoutMs: MUTATION_TIMEOUT_MS, + }); +}); + +export const pruneWorktrees = Effect.fn('Git.pruneWorktrees')(function* (repoRoot: string) { + return yield* runCommand('git', ['worktree', 'prune'], { + cwd: repoRoot, + env: WRITE_ENV, + timeoutMs: MUTATION_TIMEOUT_MS, + }); +}); + +export const identifyManagedWorktree = Effect.fn('Git.identifyManagedWorktree')(function* ( + candidate: string, +) { + const common = yield* gitRead( + candidate, + 'rev-parse', + '--path-format=absolute', + '--git-common-dir', + ); + if (common.exitCode !== 0 || !common.stdout.trim()) return; + const branch = yield* gitRead(candidate, 'symbolic-ref', '--quiet', '--short', 'HEAD'); + if (branch.exitCode !== 0 || !branch.stdout.trim()) return; + const list = yield* gitRead(candidate, 'worktree', 'list', '--porcelain', '-z'); + if (list.exitCode !== 0) return; + const paths = list.stdout.split('\0').reduce((found, line) => { + if (line.startsWith('worktree ')) found.push(line.slice('worktree '.length).trim()); + return found; + }, []); + const candidatePath = yield* Effect.sync(() => normalizePath(candidate)); + const commonPath = yield* Effect.sync(() => normalizePath(common.stdout.trim())); + if (!paths.some((path) => normalizePath(path) === candidatePath)) return; + // A standalone repository placed under the managed root is not a LinkCode worktree. A linked + // worktree's common git directory lives with its main checkout, outside the candidate itself. + if (commonPath === candidatePath || commonPath.startsWith(`${candidatePath}/`)) return; + const repoRoot = paths[0]; + if (!repoRoot || normalizePath(repoRoot) === candidatePath) return; + const repoCommon = yield* gitRead( + repoRoot, + 'rev-parse', + '--path-format=absolute', + '--git-common-dir', + ); + if (repoCommon.exitCode !== 0 || normalizePath(repoCommon.stdout.trim()) !== commonPath) return; + return { repoRoot, branch: branch.stdout.trim() }; +}); + +function normalizePath(path: string): string { + const normalized = path.replaceAll('\\', '/').replace(RE_TRAILING_SLASH, ''); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 650d3fe9e..74a6c998b 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -31,3 +31,4 @@ export { MCP_CAPABLE_AGENT_KINDS } from './simulator/mcp'; export { SimulatorService } from './simulator/service'; export type { PtyBackend, PtyOpenOptions, PtyProcess } from './terminal/pty-backend'; export type { WorkspaceStore } from './workspace/workspace-store'; +export type { WorktreeStore } from './worktree/worktree-store'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 035dce0e2..8db4c2561 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -5,6 +5,9 @@ import type { SessionId, SessionRecord, StartOptions, + WorkspaceId, + WorkspaceRecord, + WorktreeRecord, } from '@linkcode/schema'; import { Effect, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; @@ -12,6 +15,7 @@ import type { SessionDriver } from '../automation'; import type { EngineFailure } from '../failure'; import { RequestError, toOperationFailure } from '../failure'; import type { WorkspaceRegistry } from '../workspace/workspace-registry'; +import type { WorktreeService } from '../worktree/worktree-service'; import type { HistoryService } from './history-service'; import type { SessionOrchestrator } from './orchestrator'; import type { SessionRecordRegistry } from './session-record-registry'; @@ -31,6 +35,7 @@ export class SessionLifecycleService { private readonly history: HistoryService, private readonly startOptions: SessionStartOptionsResolver, private readonly workspaces: WorkspaceRegistry, + private readonly worktrees: WorktreeService, ) { this.driver = { createSession: ({ signal, ...options }) => @@ -53,11 +58,36 @@ export class SessionLifecycleService { this.runEffect = runEffect; } + deleteSession(sessionId: SessionId): Effect.Effect { + const { sessions, workspaces, worktrees } = this; + return Effect.gen(function* () { + const worktree = worktrees.get(sessionId); + yield* sessions.delete(sessionId); + yield* worktrees.cleanupDeletedSession(sessionId); + if (worktree && !worktrees.hasPath(worktree.worktreePath)) { + const workspace = workspaces.findByCwd(worktree.worktreePath); + if (workspace) { + yield* Effect.tryPromise(() => workspaces.archive(workspace.workspaceId)).pipe( + Effect.catch((error) => + Effect.logWarning('Failed to archive cleaned worktree workspace metadata', error), + ), + ); + } + } + }); + } + start(replyTo: string, options: StartOptions): Effect.Effect { - const { sessions, startOptions, workspaces } = this; + const { sessions, startOptions, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const resolved = yield* startOptions.resolve(options, sessionId); + const resolvedIntent = yield* startOptions.resolve(options, sessionId); + const resolved = yield* worktrees.provision(resolvedIntent, sessionId); + if (options.cwd) { + const parent = yield* workspaceTouch(workspaces, options.cwd); + const worktree = worktrees.get(sessionId); + if (worktree) yield* workspaceRegisterWorktree(workspaces, worktree, parent.workspaceId); + } const now = Date.now(); const record: SessionRecord = { sessionId, @@ -69,7 +99,6 @@ export class SessionLifecycleService { updatedAt: now, runs: [{ startedAt: now }], }; - if (resolved.cwd) yield* workspaceTouch(workspaces, resolved.cwd); yield* sessions.startLive(replyTo, record, (adapter) => sessions.startAdapter(adapter, resolved), ); @@ -119,10 +148,16 @@ export class SessionLifecycleService { historyId: AgentHistoryId, options: StartOptions, ): Effect.Effect { - const { history, sessions, startOptions: resolver, workspaces } = this; + const { history, sessions, startOptions: resolver, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const startOptions = yield* resolver.resolve({ ...options, kind }, sessionId); + const resolvedIntent = yield* resolver.resolve({ ...options, kind }, sessionId); + const startOptions = yield* worktrees.provision(resolvedIntent, sessionId); + if (options.cwd) { + const parent = yield* workspaceTouch(workspaces, options.cwd); + const worktree = worktrees.get(sessionId); + if (worktree) yield* workspaceRegisterWorktree(workspaces, worktree, parent.workspaceId); + } const now = Date.now(); const record: SessionRecord = { sessionId, @@ -133,7 +168,6 @@ export class SessionLifecycleService { updatedAt: now, runs: [{ historyId, startedAt: now }], }; - if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(replyTo, record, (adapter) => history.resume(adapter, historyId, startOptions), ); @@ -163,15 +197,22 @@ export class SessionLifecycleService { // A never-prompted session has no provider transcript to resume from (the adapter only mints one // on the first prompt); waking it is a fresh start under the same LinkCode id. const historyId = this.records.historyId(sessionId); - const { history, sessions, startOptions: resolver, workspaces } = this; + const { history, sessions, startOptions: resolver, workspaces, worktrees } = this; return Effect.gen(function* () { + yield* worktrees.verifyResume(sessionId); const startOptions = yield* resolver.resolve( { kind: record.kind, cwd: record.cwd }, sessionId, ); // Register before starting so a persistence failure cannot follow a successful // `session.started` reply with a contradictory request failure. - if (record.cwd) yield* workspaceTouch(workspaces, record.cwd); + const worktree = worktrees.get(sessionId); + if (worktree) { + const parent = yield* workspaceTouch(workspaces, worktree.repoRoot); + yield* workspaceRegisterWorktree(workspaces, worktree, parent.workspaceId); + } else if (record.cwd) { + yield* workspaceTouch(workspaces, record.cwd); + } record.runs.push({ historyId, startedAt: Date.now() }); yield* sessions.startLive(replyTo, record, (adapter) => historyId === undefined @@ -238,7 +279,7 @@ export class SessionLifecycleService { function workspaceTouch( workspaces: WorkspaceRegistry, cwd: string, -): Effect.Effect { +): Effect.Effect { return Effect.tryPromise({ try: () => workspaces.touch(cwd), catch: (cause) => @@ -249,3 +290,24 @@ function workspaceTouch( }), }); } + +function workspaceRegisterWorktree( + workspaces: WorkspaceRegistry, + worktree: WorktreeRecord, + parentWorkspaceId: WorkspaceId, +): Effect.Effect { + return Effect.tryPromise({ + try: () => + workspaces.registerWorktree({ + cwd: worktree.worktreePath, + parentWorkspaceId, + branch: worktree.branch, + }), + catch: (cause) => + toOperationFailure(cause, { + subsystem: 'store', + operation: 'workspace.register-worktree', + publicMessage: 'Failed to persist managed worktree workspace', + }), + }); +} diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index 9702512d8..21725d99c 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -61,8 +61,8 @@ export class SessionRequestHandler { // can still be imported after another client deleted the LinkCode record. return this.responder.reply( payload.clientReqId, - this.sessions - .delete(payload.sessionId) + this.lifecycle + .deleteSession(payload.sessionId) .pipe( Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), ), diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 7fade564a..eba04eda3 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -87,6 +87,7 @@ export class WireRequestRouter { return this.handlers.workspace.handle(p); } case 'git.status.get': + case 'git.branch.list': case 'git.pr_status.get': case 'git.diff.get': { return this.handlers.git.handle(p); diff --git a/packages/host/engine/src/workspace/workspace-registry.ts b/packages/host/engine/src/workspace/workspace-registry.ts index 0fa110326..1560e0e23 100644 --- a/packages/host/engine/src/workspace/workspace-registry.ts +++ b/packages/host/engine/src/workspace/workspace-registry.ts @@ -50,11 +50,50 @@ export class WorkspaceRegistry { name?: string; kind?: WorkspaceKind; }): Promise { + if (opts.kind === 'worktree') { + throw new RequestError({ + code: 'invalid_request', + message: 'Managed worktrees cannot be registered by clients', + }); + } const cwd = resolve(opts.cwd); await this.assertDirectoryExists(cwd); return this.upsert(cwd, opts.name, opts.kind ?? 'project'); } + /** Register or freshen trusted daemon metadata for a managed worktree. */ + async registerWorktree(opts: { + cwd: string; + parentWorkspaceId: WorkspaceId; + branch: string; + }): Promise { + const parent = this.byId.get(opts.parentWorkspaceId); + if (!parent || workspaceKind(parent) !== 'project') { + throw new RequestError({ + code: 'invalid_request', + message: `Managed worktree parent is not a project: ${opts.parentWorkspaceId}`, + }); + } + const cwd = resolve(opts.cwd); + await this.assertDirectoryExists(cwd); + const key = normalizeCwdKey(cwd); + const existingId = this.byCwdKey.get(key); + if (!existingId) { + return this.upsert(cwd, opts.branch, 'worktree', opts.parentWorkspaceId); + } + const existing = nullthrow(this.byId.get(existingId), `Unindexed workspace: ${existingId}`); + const updated: WorkspaceRecord = { + ...existing, + name: opts.branch, + kind: 'worktree', + parentWorkspaceId: opts.parentWorkspaceId, + lastUsedAt: Date.now(), + }; + await this.save(updated); + this.index(updated); + return updated; + } + /** Ensure a directory a session just ran in is registered: freshen `lastUsedAt` if known, else * create a record — `chat` if `cwd` is the daemon-owned chat root, `project` otherwise. */ touch(cwd: string, name?: string): Promise { @@ -132,6 +171,7 @@ export class WorkspaceRegistry { rawCwd: string, name: string | undefined, kind: WorkspaceKind, + parentWorkspaceId?: WorkspaceId, ): Promise { const cwd = resolve(rawCwd); const key = normalizeCwdKey(cwd); @@ -149,6 +189,7 @@ export class WorkspaceRegistry { cwd, name: name ?? lastPathSegment(cwd), kind, + parentWorkspaceId, createdAt: now, lastUsedAt: now, }; diff --git a/packages/host/engine/src/worktree/worktree-service.ts b/packages/host/engine/src/worktree/worktree-service.ts new file mode 100644 index 000000000..424ba8c6a --- /dev/null +++ b/packages/host/engine/src/worktree/worktree-service.ts @@ -0,0 +1,405 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync } from 'node:fs'; +import { basename, join, normalize, resolve } from 'node:path'; +import type { SessionId, StartOptions, WorktreeRecord } from '@linkcode/schema'; +import { SessionIdSchema } from '@linkcode/schema'; +import { Effect, Exit, Semaphore } from 'effect'; +import type { EngineFailure } from '../failure'; +import { OperationError, RequestError } from '../failure'; +import type { GitService } from '../git/git-service'; +import { + addWorktree, + identifyManagedWorktree, + inspectWorktreeCleanup, + localBranchExists, + pruneWorktrees, + readCurrentBranch, + removeWorktree, + removeWorktreeBestEffort, + resolveRepoRoot, +} from '../git/worktrees'; +import type { WorktreeStore } from './worktree-store'; + +const RE_UNSAFE_SLUG = /[^\w.-]+/g; +const RE_EDGE_DASHES = /^-+|-+$/g; +const RE_CHECKED_OUT = /already checked out|already used by worktree/i; + +export class WorktreeService { + private readonly bySession = new Map(); + private readonly byRepoBranch = new Map(); + private readonly semaphores = new Map(); + + constructor( + private readonly store: WorktreeStore, + readonly root: string | undefined, + readonly git: GitService, + ) {} + + start( + durableSessionIds: ReadonlySet = new Set(), + ): Effect.Effect { + return storeEffect('worktrees.load', 'Failed to load managed worktrees', () => + this.store.load(), + ).pipe( + Effect.tap((records) => + Effect.sync(() => { + for (const record of records) { + this.bySession.set(record.sessionId, record); + this.byRepoBranch.set(repoBranchKey(record.repoRoot, record.branch), record); + } + }), + ), + Effect.andThen(Effect.suspend(() => this.reconcile(durableSessionIds))), + ); + } + + provision( + options: StartOptions, + sessionId: SessionId, + ): Effect.Effect { + if (!options.branch) return Effect.succeed(options); + const branch = options.branch.name; + return Effect.gen({ self: this }, function* () { + const rawRoot = yield* resolveRepoRoot(options.cwd).pipe( + Effect.mapError((cause) => + gitFailure('git.repo-root', 'Failed to inspect repository', cause), + ), + ); + if (!rawRoot) { + return yield* new RequestError({ + code: 'invalid_request', + message: 'Workspace is not a git repository', + }); + } + const repoRoot = normalizeRepoRoot(rawRoot); + return yield* this.semaphore(repoRoot).withPermit( + this.provisionLocked(options, sessionId, repoRoot, branch), + ); + }); + } + + verifyResume(sessionId: SessionId): Effect.Effect { + const record = this.bySession.get(sessionId); + if (!record || existsSync(record.worktreePath)) return Effect.void; + return Effect.fail( + new RequestError({ + code: 'worktree_missing', + message: `The managed worktree is missing at ${record.worktreePath}. Restore it or delete this session.`, + }), + ); + } + + get(sessionId: SessionId): WorktreeRecord | undefined { + return this.bySession.get(sessionId); + } + + hasPath(path: string): boolean { + const key = normalizeRepoRoot(path); + return [...this.bySession.values()].some( + (record) => normalizeRepoRoot(record.worktreePath) === key, + ); + } + + cleanupDeletedSession(sessionId: SessionId): Effect.Effect { + const record = this.bySession.get(sessionId); + if (!record) return Effect.void; + return this.semaphore(normalizeRepoRoot(record.repoRoot)).withPermit( + this.cleanupRecord(record), + ); + } + + provisionLocked( + options: StartOptions, + sessionId: SessionId, + repoRoot: string, + branch: string, + ): Effect.Effect { + return Effect.gen({ self: this }, function* () { + const exists = yield* localBranchExists(repoRoot, branch).pipe( + Effect.mapError((cause) => + gitFailure('git.branch.exists', 'Failed to inspect branch', cause), + ), + ); + if (!exists) { + return yield* new RequestError({ + code: 'not_found', + message: 'The selected local branch does not exist', + }); + } + const current = yield* readCurrentBranch(repoRoot).pipe( + Effect.mapError((cause) => + gitFailure('git.branch.current', 'Failed to inspect current branch', cause), + ), + ); + if (current === branch) return withoutBranch(options, options.cwd); + if (this.byRepoBranch.has(repoBranchKey(repoRoot, branch))) { + return yield* new RequestError({ + code: 'conflict', + message: 'This repository branch already has a managed worktree', + }); + } + if (!this.root) { + return yield* new RequestError({ + code: 'unsupported', + message: 'Managed worktrees are unavailable', + }); + } + const worktreePath = makeWorktreePath(this.root, repoRoot, branch); + const added = yield* addWorktree(repoRoot, worktreePath, branch).pipe( + Effect.mapError((cause) => + gitFailure('git.worktree.add', 'Failed to create managed worktree', cause), + ), + ); + if (added.exitCode !== 0) { + if (RE_CHECKED_OUT.test(added.stderr)) { + return yield* new RequestError({ + code: 'conflict', + message: 'The selected branch is already checked out in another worktree', + }); + } + return yield* gitFailure( + 'git.worktree.add', + 'Failed to create managed worktree', + new Error(added.stderr.trim() || `git worktree add exited ${added.exitCode}`), + ); + } + const record: WorktreeRecord = { + worktreePath, + repoRoot, + branch, + sessionId, + createdAt: Date.now(), + state: 'active', + }; + const saved = yield* Effect.exit( + storeEffect('worktrees.save', 'Failed to persist managed worktree', () => + this.store.save(record), + ), + ); + if (Exit.isFailure(saved)) { + yield* removeWorktreeBestEffort(repoRoot, worktreePath); + return yield* Effect.failCause(saved.cause); + } + this.bySession.set(sessionId, record); + this.byRepoBranch.set(repoBranchKey(repoRoot, branch), record); + yield* this.git.invalidate(options.cwd); + yield* this.git.invalidate(worktreePath); + return withoutBranch(options, worktreePath); + }); + } + + semaphore(repoRoot: string): Semaphore.Semaphore { + const existing = this.semaphores.get(repoRoot); + if (existing) return existing; + const semaphore = Semaphore.makeUnsafe(1); + this.semaphores.set(repoRoot, semaphore); + return semaphore; + } + + cleanupRecord(record: WorktreeRecord): Effect.Effect { + return Effect.gen({ self: this }, function* () { + if (!existsSync(record.worktreePath)) { + yield* this.pruneAdvisory(record.repoRoot); + yield* this.deleteRecord(record); + return; + } + const safe = yield* inspectWorktreeCleanup(record.worktreePath, record.branch).pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (!safe) { + yield* this.markOrphaned(record); + return; + } + const removed = yield* removeWorktree(record.repoRoot, record.worktreePath).pipe( + Effect.mapError((cause) => + gitFailure('git.worktree.remove', 'Failed to remove managed worktree', cause), + ), + ); + if (removed.exitCode !== 0) { + return yield* gitFailure( + 'git.worktree.remove', + 'Failed to remove managed worktree', + new Error(removed.stderr.trim() || `git worktree remove exited ${removed.exitCode}`), + ); + } + yield* this.pruneAdvisory(record.repoRoot); + yield* this.deleteRecord(record); + }); + } + + reconcile(sessionIds: ReadonlySet): Effect.Effect { + return Effect.gen({ self: this }, function* () { + for (const record of this.bySession.values()) { + const hasSession = sessionIds.has(record.sessionId); + if (!existsSync(record.worktreePath)) { + yield* this.semaphore(normalizeRepoRoot(record.repoRoot)).withPermit( + (hasSession + ? this.pruneAdvisory(record.repoRoot).pipe(Effect.andThen(this.markOrphaned(record))) + : this.cleanupRecord(record) + ).pipe( + Effect.catch((error) => + Effect.logWarning('Managed worktree reconciliation deferred', error), + ), + ), + ); + } else if (!hasSession && record.state === 'active') { + yield* this.semaphore(normalizeRepoRoot(record.repoRoot)).withPermit( + this.cleanupRecord(record).pipe( + Effect.catch((error) => + Effect.logWarning('Managed worktree reconciliation deferred', error), + ), + ), + ); + } + } + yield* this.scanUnknown(); + }); + } + + scanUnknown(): Effect.Effect { + if (!this.root || !existsSync(this.root)) return Effect.void; + const root = this.root; + return Effect.gen({ self: this }, function* () { + const groups = yield* readChildDirectories(root); + for (const group of groups) { + const candidates = yield* readChildDirectories(group); + for (const candidate of candidates) { + if (this.hasPath(candidate)) continue; + const identity = yield* identifyManagedWorktree(candidate).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning('Unable to identify unowned managed worktree', error); + }), + ), + ); + if (!identity) continue; + const record: WorktreeRecord = { + worktreePath: candidate, + repoRoot: identity.repoRoot, + branch: identity.branch, + sessionId: orphanSessionId(candidate), + createdAt: Date.now(), + state: 'orphaned', + }; + yield* this.semaphore(normalizeRepoRoot(identity.repoRoot)).withPermit( + this.saveRecord(record).pipe( + Effect.catch((error) => + Effect.logWarning('Unable to persist unowned managed worktree', error), + ), + ), + ); + } + } + }); + } + + saveRecord(record: WorktreeRecord): Effect.Effect { + return storeEffect('worktrees.save', 'Failed to persist managed worktree', () => + this.store.save(record), + ).pipe( + Effect.tap(() => Effect.sync(() => this.index(record))), + Effect.asVoid, + ); + } + + markOrphaned(record: WorktreeRecord): Effect.Effect { + if (record.state === 'orphaned') return Effect.void; + return this.saveRecord({ ...record, state: 'orphaned' }); + } + + deleteRecord(record: WorktreeRecord): Effect.Effect { + return storeEffect('worktrees.delete', 'Failed to delete managed worktree', () => + this.store.delete(record.worktreePath), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + this.bySession.delete(record.sessionId); + this.byRepoBranch.delete(repoBranchKey(record.repoRoot, record.branch)); + }), + ), + Effect.asVoid, + ); + } + + private index(record: WorktreeRecord): void { + this.bySession.set(record.sessionId, record); + this.byRepoBranch.set(repoBranchKey(record.repoRoot, record.branch), record); + } + + pruneAdvisory(repoRoot: string): Effect.Effect { + return pruneWorktrees(repoRoot).pipe( + Effect.tap((result) => + result.exitCode === 0 + ? Effect.void + : Effect.logWarning('git worktree prune failed', result.stderr), + ), + Effect.catch((error) => Effect.logWarning('git worktree prune failed', error)), + Effect.asVoid, + ); + } +} + +function readChildDirectories(path: string): Effect.Effect { + return Effect.try({ + try: () => + readdirSync(path, { withFileTypes: true }).reduce((found, entry) => { + if (entry.isDirectory()) found.push(join(path, entry.name)); + return found; + }, []), + catch: (cause) => gitFailure('git.worktree.scan', 'Failed to scan managed worktrees', cause), + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning('Managed worktree directory scan deferred', error); + return new Array(); + }), + ), + ); +} + +function orphanSessionId(path: string): SessionId { + const digest = createHash('sha256').update(normalizeRepoRoot(path)).digest('hex'); + return SessionIdSchema.parse(`orphan-worktree-${digest}`); +} + +function normalizeRepoRoot(path: string): string { + const normalized = normalize(resolve(path)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function repoBranchKey(repoRoot: string, branch: string): string { + return `${normalizeRepoRoot(repoRoot)}\0${branch}`; +} + +function shortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function slug(value: string): string { + const safe = value.replaceAll(RE_UNSAFE_SLUG, '-').replaceAll(RE_EDGE_DASHES, ''); + return (safe || 'branch').slice(0, 48); +} + +function makeWorktreePath(root: string, repoRoot: string, branch: string): string { + return join( + root, + `${slug(basename(repoRoot))}-${shortHash(normalizeRepoRoot(repoRoot))}`, + `${slug(branch)}-${shortHash(branch)}`, + ); +} + +function withoutBranch(options: StartOptions, cwd: string): StartOptions { + const { branch: _branch, ...adapterOptions } = options; + return { ...adapterOptions, cwd }; +} + +function gitFailure(operation: string, publicMessage: string, cause: unknown): OperationError { + return new OperationError({ subsystem: 'git', operation, publicMessage, cause }); +} + +function storeEffect(operation: string, publicMessage: string, run: () => Promise) { + return Effect.tryPromise({ + try: run, + catch: (cause) => new OperationError({ subsystem: 'store', operation, publicMessage, cause }), + }); +} diff --git a/packages/host/engine/src/worktree/worktree-store.ts b/packages/host/engine/src/worktree/worktree-store.ts new file mode 100644 index 000000000..fa8c649b1 --- /dev/null +++ b/packages/host/engine/src/worktree/worktree-store.ts @@ -0,0 +1,34 @@ +import type { WorktreeRecord } from '@linkcode/schema'; + +export interface WorktreeStore { + load(): Promise; + save(record: WorktreeRecord): Promise; + delete(worktreePath: string): Promise; +} + +export class InMemoryWorktreeStore implements WorktreeStore { + private readonly records = new Map(); + + load(): Promise { + return Promise.resolve([...this.records.values()].map((record) => structuredClone(record))); + } + + save(record: WorktreeRecord): Promise { + for (const existing of this.records.values()) { + if (existing.worktreePath === record.worktreePath) continue; + if ( + existing.sessionId === record.sessionId || + (existing.repoRoot === record.repoRoot && existing.branch === record.branch) + ) { + return Promise.reject(new Error('worktree already exists')); + } + } + this.records.set(record.worktreePath, structuredClone(record)); + return Promise.resolve(); + } + + delete(worktreePath: string): Promise { + this.records.delete(worktreePath); + return Promise.resolve(); + } +} diff --git a/packages/host/engine/tests/integration/engine-worktree.test.ts b/packages/host/engine/tests/integration/engine-worktree.test.ts new file mode 100644 index 000000000..8364b1339 --- /dev/null +++ b/packages/host/engine/tests/integration/engine-worktree.test.ts @@ -0,0 +1,310 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { StartOptions } from '@linkcode/schema'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createSessionHarness, + FakeAdapter, + startedSessionId, +} from '../../src/__tests__/fixtures/session-harness'; +import type { SessionStore } from '../../src/session/session-store'; +import { InMemorySessionStore } from '../../src/session/session-store'; +import { InMemoryWorkspaceStore } from '../../src/workspace/workspace-store'; +import { InMemoryWorktreeStore } from '../../src/worktree/worktree-store'; + +const tempRoots: string[] = []; + +function makeTempDir(): string { + const path = mkdtempSync(join(tmpdir(), 'linkcode-engine-worktree-')); + tempRoots.push(path); + return path; +} + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' }); +} + +function makeRepo(): string { + const path = makeTempDir(); + const remote = makeTempDir(); + git(remote, 'init', '--bare'); + git(path, 'init', '-b', 'main'); + git( + path, + '-c', + 'user.email=test@test', + '-c', + 'user.name=test', + 'commit', + '--allow-empty', + '-m', + 'init', + ); + git(path, 'branch', 'feature'); + git(path, 'remote', 'add', 'origin', remote); + git(path, 'push', '--all', '--set-upstream', 'origin'); + return path; +} + +class RejectingStartAdapter extends FakeAdapter { + override start(options: StartOptions): Promise { + this.startedWith = options; + return Promise.reject(new Error('private adapter failure')); + } +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('engine managed worktree sessions', () => { + it('deletes a safe managed worktree and its ownership and workspace metadata', async () => { + const repo = makeRepo(); + const sessionStore = new InMemorySessionStore(); + const workspaceStore = new InMemoryWorkspaceStore(); + const worktreeStore = new InMemoryWorktreeStore(); + const h = createSessionHarness( + sessionStore, + undefined, + undefined, + undefined, + workspaceStore, + undefined, + { worktreeStore, worktreeRoot: makeTempDir() }, + ); + await h.engine.start(); + try { + await h.inject({ + kind: 'session.start', + clientReqId: 'start-delete', + opts: { + kind: 'claude-code', + cwd: repo, + branch: { name: 'feature' }, + }, + }); + const sessionId = await vi.waitFor(() => startedSessionId(h.sent, 'start-delete')); + const [record] = await worktreeStore.load(); + await h.inject({ kind: 'session.delete', clientReqId: 'delete', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'delete' }), + ); + expect(existsSync(record.worktreePath)).toBe(false); + expect(await worktreeStore.load()).toEqual([]); + expect((await workspaceStore.load()).some(({ cwd }) => cwd === record.worktreePath)).toBe( + false, + ); + } finally { + await h.engine.stop(); + } + }); + + it('does not clean a worktree when durable session deletion fails', async () => { + const repo = makeRepo(); + const inner = new InMemorySessionStore(); + const sessionStore: SessionStore = { + load: () => inner.load(), + save: (record) => inner.save(record), + delete: () => Promise.reject(new Error('disk unavailable')), + }; + const worktreeStore = new InMemoryWorktreeStore(); + const h = createSessionHarness( + sessionStore, + undefined, + undefined, + undefined, + undefined, + undefined, + { worktreeStore, worktreeRoot: makeTempDir() }, + ); + await h.engine.start(); + try { + await h.inject({ + kind: 'session.start', + clientReqId: 'start-delete-failure', + opts: { kind: 'claude-code', cwd: repo, branch: { name: 'feature' } }, + }); + const sessionId = await vi.waitFor(() => startedSessionId(h.sent, 'start-delete-failure')); + const [record] = await worktreeStore.load(); + + await h.inject({ kind: 'session.delete', clientReqId: 'delete-failure', sessionId }); + + await vi.waitFor(() => + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'delete-failure', + code: 'operation_failed', + message: 'Failed to delete session record', + }), + ); + expect(existsSync(record.worktreePath)).toBe(true); + expect(await worktreeStore.load()).toMatchObject([{ state: 'active' }]); + expect(await inner.load()).toHaveLength(1); + } finally { + await h.engine.stop(); + } + }); + + it('retries orphan cleanup when an already-deleted session is deleted again', async () => { + const repo = makeRepo(); + const workspaceStore = new InMemoryWorkspaceStore(); + const worktreeStore = new InMemoryWorktreeStore(); + const h = createSessionHarness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + workspaceStore, + undefined, + { worktreeStore, worktreeRoot: makeTempDir() }, + ); + await h.engine.start(); + try { + await h.inject({ + kind: 'session.start', + clientReqId: 'start-retry', + opts: { kind: 'claude-code', cwd: repo, branch: { name: 'feature' } }, + }); + const sessionId = await vi.waitFor(() => startedSessionId(h.sent, 'start-retry')); + const [record] = await worktreeStore.load(); + const dirtyPath = join(record.worktreePath, 'untracked'); + writeFileSync(dirtyPath, 'dirty'); + + await h.inject({ kind: 'session.delete', clientReqId: 'delete-dirty', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ + kind: 'request.succeeded', + replyTo: 'delete-dirty', + }), + ); + expect(existsSync(record.worktreePath)).toBe(true); + expect(await worktreeStore.load()).toMatchObject([{ state: 'orphaned' }]); + + rmSync(dirtyPath); + await h.inject({ kind: 'session.delete', clientReqId: 'delete-retry', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ + kind: 'request.succeeded', + replyTo: 'delete-retry', + }), + ); + expect(existsSync(record.worktreePath)).toBe(false); + expect(await worktreeStore.load()).toEqual([]); + expect((await workspaceStore.load()).some(({ cwd }) => cwd === record.worktreePath)).toBe( + false, + ); + } finally { + await h.engine.stop(); + } + }); + + it('starts and resumes in the managed cwd registered under its parent project', async () => { + const repo = makeRepo(); + const sessionStore = new InMemorySessionStore(); + const workspaceStore = new InMemoryWorkspaceStore(); + const worktreeStore = new InMemoryWorktreeStore(); + const h = createSessionHarness( + sessionStore, + undefined, + undefined, + undefined, + workspaceStore, + undefined, + { worktreeStore, worktreeRoot: makeTempDir() }, + ); + await h.engine.start(); + + try { + await h.inject({ + kind: 'session.start', + clientReqId: 'start', + opts: { kind: 'claude-code', cwd: repo, branch: { name: 'feature' } }, + }); + const sessionId = await vi.waitFor(() => startedSessionId(h.sent, 'start')); + const [worktree] = await worktreeStore.load(); + const [session] = await sessionStore.load(); + expect(worktree.sessionId).toBe(sessionId); + expect(session.cwd).toBe(worktree.worktreePath); + expect(h.adapters[0].startedWith).toEqual({ + kind: 'claude-code', + cwd: worktree.worktreePath, + }); + const workspaces = await workspaceStore.load(); + const parent = workspaces.find(({ cwd }) => cwd === repo); + expect(parent?.kind).toBe('project'); + expect(workspaces.find(({ cwd }) => cwd === worktree.worktreePath)).toMatchObject({ + kind: 'worktree', + name: 'feature', + parentWorkspaceId: parent?.workspaceId, + }); + + await h.inject({ kind: 'session.stop', clientReqId: 'stop', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'stop' }), + ); + await h.inject({ kind: 'session.resume', clientReqId: 'resume-success', sessionId }); + await vi.waitFor(() => startedSessionId(h.sent, 'resume-success')); + expect(h.adapters[1].startedWith).toEqual({ + kind: 'claude-code', + cwd: worktree.worktreePath, + }); + await h.inject({ kind: 'session.stop', clientReqId: 'stop-again', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'stop-again' }), + ); + rmSync(worktree.worktreePath, { recursive: true, force: true }); + await h.inject({ kind: 'session.resume', clientReqId: 'resume', sessionId }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'resume', + code: 'worktree_missing', + message: `The managed worktree is missing at ${worktree.worktreePath}. Restore it or delete this session.`, + }), + ); + } finally { + await h.engine.stop(); + } + }); + + it('retains a tracked cold session when adapter startup fails', async () => { + const repo = makeRepo(); + const sessionStore = new InMemorySessionStore(); + const worktreeStore = new InMemoryWorktreeStore(); + const h = createSessionHarness( + sessionStore, + () => new RejectingStartAdapter(), + undefined, + undefined, + undefined, + undefined, + { worktreeStore, worktreeRoot: makeTempDir() }, + ); + await h.engine.start(); + + try { + await h.inject({ + kind: 'session.start', + clientReqId: 'start', + opts: { kind: 'claude-code', cwd: repo, branch: { name: 'feature' } }, + }); + await vi.waitFor(() => + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'start', + code: 'operation_failed', + message: 'Agent failed to start', + }), + ); + expect(JSON.stringify(h.sent)).not.toContain('private adapter failure'); + const [worktree] = await worktreeStore.load(); + const [session] = await sessionStore.load(); + expect(worktree.sessionId).toBe(session.sessionId); + expect(session.cwd).toBe(worktree.worktreePath); + } finally { + await h.engine.stop(); + } + }); +}); diff --git a/packages/host/engine/tests/integration/git-status.test.ts b/packages/host/engine/tests/integration/git-status.test.ts index 1d3b43362..49c5983f9 100644 --- a/packages/host/engine/tests/integration/git-status.test.ts +++ b/packages/host/engine/tests/integration/git-status.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { Effect, Logger as EffectLogger } from 'effect'; import { noop } from 'foxts/noop'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readGitBranches } from '../../src/git/branches'; import { GitService } from '../../src/git/git-service'; import { GitProviderError } from '../../src/git/provider'; import { readGitStatus } from '../../src/git/status'; @@ -26,6 +27,30 @@ function git(cwd: string, ...args: string[]): void { execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' }); } +function commitAt(cwd: string, branch: string, date: string): void { + git(cwd, 'checkout', '-b', branch); + execFileSync( + 'git', + [ + '-c', + 'commit.gpgsign=false', + '-c', + 'user.email=test@test', + '-c', + 'user.name=test', + 'commit', + '--allow-empty', + '-m', + branch, + ], + { + cwd, + stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }, + ); +} + function makeRepo(): string { const dir = makeTempDir(); git(dir, 'init', '-b', 'main'); @@ -161,3 +186,32 @@ describe('readGitStatus', () => { }); }); }); + +describe('readGitBranches', () => { + it('reports a non-repo directory', async () => { + await expect(Effect.runPromise(readGitBranches(makeTempDir()))).resolves.toEqual({ + isRepo: false, + }); + }); + + it('lists local branches current-first, then by descending committer date', async () => { + const dir = makeRepo(); + commitAt(dir, 'older', '2024-01-01T00:00:00Z'); + git(dir, 'checkout', 'main'); + commitAt(dir, 'newer', '2024-02-01T00:00:00Z'); + git(dir, 'checkout', 'main'); + git(dir, 'remote', 'add', 'origin', dir); + git(dir, 'fetch', 'origin'); + + const result = await Effect.runPromise(readGitBranches(dir)); + expect(result.isRepo).toBe(true); + if (!result.isRepo) return; + expect(result.branches[0]?.name).toBe('main'); + expect(result.branches[0]?.isCurrent).toBe(true); + expect(result.branches[0]?.lastCommitAt).toEqual(expect.any(Number)); + expect(result.branches.slice(1)).toEqual([ + { name: 'newer', isCurrent: false, lastCommitAt: Date.parse('2024-02-01T00:00:00Z') }, + { name: 'older', isCurrent: false, lastCommitAt: Date.parse('2024-01-01T00:00:00Z') }, + ]); + }); +}); diff --git a/packages/host/engine/tests/integration/worktree-service.test.ts b/packages/host/engine/tests/integration/worktree-service.test.ts new file mode 100644 index 000000000..b813cc1b5 --- /dev/null +++ b/packages/host/engine/tests/integration/worktree-service.test.ts @@ -0,0 +1,322 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionIdSchema } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; +import { GitService } from '../../src/git/git-service'; +import { WorktreeService } from '../../src/worktree/worktree-service'; +import type { WorktreeStore } from '../../src/worktree/worktree-store'; +import { InMemoryWorktreeStore } from '../../src/worktree/worktree-store'; + +const roots: string[] = []; + +function temp(): string { + const path = mkdtempSync(join(tmpdir(), 'linkcode-worktree-test-')); + roots.push(path); + return path; +} + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' }); +} + +function repo(): string { + const path = temp(); + const remote = temp(); + git(remote, 'init', '--bare'); + git(path, 'init', '-b', 'main'); + git( + path, + '-c', + 'user.email=test@test', + '-c', + 'user.name=test', + 'commit', + '--allow-empty', + '-m', + 'init', + ); + git(path, 'branch', 'feature/a'); + git(path, 'remote', 'add', 'origin', remote); + git(path, 'push', '--all', '--set-upstream', 'origin'); + return path; +} + +async function managedWorktree(store: WorktreeStore = new InMemoryWorktreeStore()) { + const cwd = repo(); + const root = temp(); + const service = new WorktreeService(store, root, await Effect.runPromise(GitService.make([]))); + await Effect.runPromise(service.start()); + const id = SessionIdSchema.parse('sess-cleanup'); + const worktree = await Effect.runPromise( + service.provision({ kind: 'pi', cwd, branch: { name: 'feature/a' } }, id), + ); + return { cwd, root, service, id, worktree }; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('WorktreeService', () => { + it('keeps the original cwd and no row for the current branch', async () => { + const cwd = repo(); + const store = new InMemoryWorktreeStore(); + const service = new WorktreeService( + store, + temp(), + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(service.start()); + + const result = await Effect.runPromise( + service.provision( + { kind: 'pi', cwd, branch: { name: 'main' } }, + SessionIdSchema.parse('sess-current'), + ), + ); + expect(result).toEqual({ kind: 'pi', cwd }); + expect(await store.load()).toEqual([]); + }); + + it('creates and persists a non-current local branch and rejects a duplicate', async () => { + const cwd = repo(); + const store = new InMemoryWorktreeStore(); + const gitService = await Effect.runPromise(GitService.make([])); + const service = new WorktreeService(store, temp(), gitService); + await Effect.runPromise(service.start()); + await Effect.runPromise(gitService.listBranches(cwd)); + + const result = await Effect.runPromise( + service.provision( + { kind: 'pi', cwd, branch: { name: 'feature/a' } }, + SessionIdSchema.parse('sess-feature'), + ), + ); + expect(result.branch).toBeUndefined(); + expect(result.cwd).not.toBe(cwd); + expect(existsSync(result.cwd)).toBe(true); + expect((await store.load())[0]).toMatchObject({ + worktreePath: result.cwd, + repoRoot: cwd, + branch: 'feature/a', + state: 'active', + }); + const status = await Effect.runPromise(gitService.getStatus(result.cwd)); + expect(status.isRepo && status.branch).toBe('feature/a'); + + const failure = await Effect.runPromiseExit( + service.provision( + { kind: 'pi', cwd, branch: { name: 'feature/a' } }, + SessionIdSchema.parse('sess-duplicate'), + ), + ); + expect(failure._tag).toBe('Failure'); + }); + + it('rejects a missing branch and reports a vanished managed worktree on resume', async () => { + const cwd = repo(); + const service = new WorktreeService( + new InMemoryWorktreeStore(), + temp(), + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(service.start()); + const missing = await Effect.runPromiseExit( + service.provision( + { kind: 'pi', cwd, branch: { name: 'missing' } }, + SessionIdSchema.parse('sess-missing-branch'), + ), + ); + expect(missing._tag).toBe('Failure'); + + const id = SessionIdSchema.parse('sess-vanished'); + const result = await Effect.runPromise( + service.provision({ kind: 'pi', cwd, branch: { name: 'feature/a' } }, id), + ); + rmSync(result.cwd, { recursive: true, force: true }); + await expect(Effect.runPromise(service.verifyResume(id))).rejects.toMatchObject({ + code: 'worktree_missing', + }); + }); + + it('serializes concurrent starts for the same repository branch', async () => { + const cwd = repo(); + const service = new WorktreeService( + new InMemoryWorktreeStore(), + temp(), + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(service.start()); + + const starts = await Promise.allSettled([ + Effect.runPromise( + service.provision( + { kind: 'pi', cwd, branch: { name: 'feature/a' } }, + SessionIdSchema.parse('sess-concurrent-a'), + ), + ), + Effect.runPromise( + service.provision( + { kind: 'pi', cwd, branch: { name: 'feature/a' } }, + SessionIdSchema.parse('sess-concurrent-b'), + ), + ), + ]); + expect(starts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + expect(starts.filter(({ status }) => status === 'rejected')).toHaveLength(1); + }); + + it('removes only a clean, tracked worktree with no unpushed commits', async () => { + const store = new InMemoryWorktreeStore(); + const { service, id, worktree } = await managedWorktree(store); + + await Effect.runPromise(service.cleanupDeletedSession(id)); + + expect(existsSync(worktree.cwd)).toBe(false); + expect(await store.load()).toEqual([]); + }); + + it.each([ + ['dirty', (path: string) => writeFileSync(join(path, 'untracked'), 'dirty')], + ['without an upstream', (path: string) => git(path, 'branch', '--unset-upstream')], + [ + 'ahead of its upstream', + (path: string) => + git( + path, + '-c', + 'user.email=test@test', + '-c', + 'user.name=test', + 'commit', + '--allow-empty', + '-m', + 'unpushed', + ), + ], + ])('orphans and preserves a deleted-session worktree that is %s', async (_name, makeUnsafe) => { + const store = new InMemoryWorktreeStore(); + const { service, id, worktree } = await managedWorktree(store); + makeUnsafe(worktree.cwd); + + await Effect.runPromise(service.cleanupDeletedSession(id)); + + expect(existsSync(worktree.cwd)).toBe(true); + expect(await store.load()).toMatchObject([{ state: 'orphaned' }]); + }); + + it('does not update the in-memory ownership state when orphan persistence fails', async () => { + const inner = new InMemoryWorktreeStore(); + let rejectSaves = false; + const store: WorktreeStore = { + load: () => inner.load(), + save: (record) => + rejectSaves ? Promise.reject(new Error('disk unavailable')) : inner.save(record), + delete: (path) => inner.delete(path), + }; + const { service, id, worktree } = await managedWorktree(store); + writeFileSync(join(worktree.cwd, 'untracked'), 'dirty'); + rejectSaves = true; + + const result = await Effect.runPromiseExit(service.cleanupDeletedSession(id)); + + expect(result._tag).toBe('Failure'); + expect(service.get(id)?.state).toBe('active'); + expect(await inner.load()).toMatchObject([{ state: 'active' }]); + expect(existsSync(worktree.cwd)).toBe(true); + }); + + it('keeps active ownership when non-force removal fails', async () => { + const store = new InMemoryWorktreeStore(); + const { root, id, worktree } = await managedWorktree(store); + const [record] = await store.load(); + await store.save({ ...record, repoRoot: join(temp(), 'missing') }); + const restarted = new WorktreeService( + store, + root, + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(restarted.start(new Set([id]))); + + const result = await Effect.runPromiseExit(restarted.cleanupDeletedSession(id)); + + expect(result._tag).toBe('Failure'); + expect(existsSync(worktree.cwd)).toBe(true); + expect(await store.load()).toMatchObject([{ state: 'active' }]); + }); + + it('reconciles active rows without sessions and missing rows on boot', async () => { + const safeStore = new InMemoryWorktreeStore(); + const safe = await managedWorktree(safeStore); + const safeRestart = new WorktreeService( + safeStore, + safe.root, + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(safeRestart.start()); + expect(existsSync(safe.worktree.cwd)).toBe(false); + expect(await safeStore.load()).toEqual([]); + + const missingStore = new InMemoryWorktreeStore(); + const missing = await managedWorktree(missingStore); + rmSync(missing.worktree.cwd, { recursive: true, force: true }); + const missingRestart = new WorktreeService( + missingStore, + missing.root, + await Effect.runPromise(GitService.make([])), + ); + await Effect.runPromise(missingRestart.start()); + expect(await missingStore.load()).toEqual([]); + }); + + it('retains a missing ownership row when its durable session still exists', async () => { + const store = new InMemoryWorktreeStore(); + const { root, id, worktree } = await managedWorktree(store); + rmSync(worktree.cwd, { recursive: true, force: true }); + const restarted = new WorktreeService( + store, + root, + await Effect.runPromise(GitService.make([])), + ); + + await Effect.runPromise(restarted.start(new Set([id]))); + + expect(await store.load()).toMatchObject([{ state: 'orphaned' }]); + await expect(Effect.runPromise(restarted.verifyResume(id))).rejects.toMatchObject({ + code: 'worktree_missing', + }); + }); + + it('records unknown linked worktrees as orphaned but ignores ordinary directories', async () => { + const cwd = repo(); + const root = temp(); + const candidate = join(root, 'repo-group', 'feature-leaf'); + mkdirSync(join(root, 'repo-group')); + git(cwd, 'worktree', 'add', '--', candidate, 'feature/a'); + + const standalone = join(root, 'standalone-group', 'repo'); + mkdirSync(standalone, { recursive: true }); + git(standalone, 'init', '-b', 'main'); + const malformed = join(root, 'malformed-group', 'leaf'); + mkdirSync(malformed, { recursive: true }); + writeFileSync(join(malformed, '.git'), 'not git metadata'); + + const store = new InMemoryWorktreeStore(); + const service = new WorktreeService(store, root, await Effect.runPromise(GitService.make([]))); + await Effect.runPromise(service.start()); + await Effect.runPromise(service.start()); + + expect(existsSync(candidate)).toBe(true); + expect(await store.load()).toMatchObject([ + { + worktreePath: candidate, + repoRoot: cwd, + branch: 'feature/a', + state: 'orphaned', + }, + ]); + }); +}); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 791d86d75..0738d605d 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -345,6 +345,10 @@ export const en = { noProject: "Don't work in a project", workLocally: 'Work locally', branch: 'Branch', + branchLoading: 'Loading local branches…', + branchEmpty: 'No local branches', + branchError: 'Could not load local branches', + branchWorktreeHint: 'Will start in an isolated worktree', }, panel: { window: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 12afb6b67..59d961420 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -335,6 +335,10 @@ export const zhCN = { noProject: '不使用项目', workLocally: '本地运行', branch: '分支', + branchLoading: '正在加载本地分支…', + branchEmpty: '没有本地分支', + branchError: '无法加载本地分支', + branchWorktreeHint: '将在隔离 worktree 中启动', }, panel: { window: { diff --git a/packages/presentation/ui/src/__tests__/thread-groups.test.ts b/packages/presentation/ui/src/__tests__/thread-groups.test.ts index 72b877ae3..116c72a1c 100644 --- a/packages/presentation/ui/src/__tests__/thread-groups.test.ts +++ b/packages/presentation/ui/src/__tests__/thread-groups.test.ts @@ -93,6 +93,37 @@ describe('groupThreadsByWorkspace', () => { expect(groups.find((g) => g.key === UNREGISTERED_THREAD_GROUP_KEY)?.isChat).toBe(false); expect(groups.every((g) => !g.isPinned)).toBe(true); }); + + it('groups worktree sessions under their parent without emitting a worktree group', () => { + const parent = createWorkspace('ws-parent', '/repo/app', 1, 'project'); + const worktree = { + ...createWorkspace('ws-worktree', '/managed/feature', 2, 'worktree'), + parentWorkspaceId: parent.workspaceId, + name: 'feature', + }; + const session = createSession('s-worktree', worktree.cwd, 100); + + const groups = groupThreadsByWorkspace([session], [worktree, parent]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.workspace).toBe(parent); + expect(groups[0]?.sessions).toEqual([session]); + }); + + it('falls back to unregistered when a worktree parent is missing', () => { + const missingParentId = createWorkspace('ws-missing', '/missing', 1).workspaceId; + const worktree = { + ...createWorkspace('ws-worktree', '/managed/feature', 2, 'worktree'), + parentWorkspaceId: missingParentId, + }; + + const groups = groupThreadsByWorkspace( + [createSession('s-worktree', worktree.cwd, 100)], + [worktree], + ); + + expect(groups.map((group) => group.key)).toEqual([UNREGISTERED_THREAD_GROUP_KEY]); + }); }); describe('extractPinnedGroup', () => { diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index cb8e02ad4..1d971917f 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -32,6 +32,13 @@ const CHAT_WORKSPACE = { createdAt: 1, lastUsedAt: 1, }; +const PROJECT_WORKSPACE = { + workspaceId: WorkspaceIdSchema.parse('workspace-2'), + cwd: '/project', + kind: 'project' as const, + createdAt: 1, + lastUsedAt: 1, +}; const RE_MODEL_DEFAULT = /modelDefault/; const RE_SONNET_5 = /Sonnet 5/; const RE_CONFIGURED_CLAUDE_MODEL = /configured\/claude-model/; @@ -65,7 +72,62 @@ function NewSessionSurface({ onWorkspaceChange, ...props }: StandaloneProps): Re ); } +function BranchPickerTest({ onSelect }: { onSelect: (branch: string) => void }) { + return ( + + ); +} + describe('NewSessionSurface', () => { + it.each([ + { chatWorkspace: CHAT_WORKSPACE, initialWorkspaceId: CHAT_WORKSPACE.workspaceId }, + { chatWorkspace: null, initialWorkspaceId: null }, + ])('does not mount the branch picker for chat or no selection', (selection) => { + const BranchPicker = vi.fn(() => null); + render( + , + ); + + expect(BranchPicker).not.toHaveBeenCalled(); + }); + + it('submits an explicitly selected repository branch', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pick branch' })); + typeInComposer('start here'); + await pressInComposer('Enter'); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ branch: { name: 'feature/code-428' } }), + ), + ); + }); + it.each(['claude-code', 'codex', 'opencode', 'pi'] as const)( 'submits a leading slash command for %s', async (provider) => { diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 14658c8ea..8ffa5ec56 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -13,6 +13,7 @@ export * from './history/history-browser'; export * from './history/sort'; export * from './history/sort-select'; export * from './im-channel-settings-panel'; +export * from './new-session-branch-picker'; export * from './new-session-surface'; export * from './notifications-settings-panel'; export * from './providers/account-detail'; diff --git a/packages/presentation/ui/src/shell/new-session-branch-picker.tsx b/packages/presentation/ui/src/shell/new-session-branch-picker.tsx new file mode 100644 index 000000000..e6f1e2433 --- /dev/null +++ b/packages/presentation/ui/src/shell/new-session-branch-picker.tsx @@ -0,0 +1,85 @@ +import type { GitBranch } from '@linkcode/schema'; +import { Button } from 'coss-ui/components/button'; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuTrigger, +} from 'coss-ui/components/menu'; +import { ChevronDownIcon, GitBranchIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; + +export interface NewSessionBranchPickerComponentProps { + cwd: string; + selectedBranch?: string; + disabled: boolean; + onSelect: (branch: string) => void; +} + +export type NewSessionBranchPickerComponent = + React.ComponentType; + +export interface NewSessionBranchPickerProps + extends Omit { + currentBranch: string | null; + branches?: readonly GitBranch[]; + loading: boolean; + error: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** Pure branch-menu presentation. Runtime-backed adapters supply repository state and branches. */ +export function NewSessionBranchPicker({ + selectedBranch, + disabled, + onSelect, + currentBranch, + branches, + loading, + error, + open, + onOpenChange, +}: NewSessionBranchPickerProps): React.ReactNode { + const t = useTranslations('workbench.newSession'); + const visualBranch = selectedBranch ?? currentBranch ?? undefined; + + return ( + + + } + > + + {visualBranch ?? t('branch')} + + + + {loading ? ( +
{t('branchLoading')}
+ ) : error ? ( +
{t('branchError')}
+ ) : branches?.length ? ( + + {branches.map((branch) => ( + + + {branch.name} + {!branch.isCurrent && ( + {t('branchWorktreeHint')} + )} + + + ))} + + ) : ( +
{t('branchEmpty')}
+ )} +
+
+ ); +} diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index a5beb19e6..3fd75c6cc 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -25,7 +25,6 @@ import { ChevronDownIcon, FolderIcon, FolderPlusIcon, - GitBranchIcon, LaptopMinimalIcon, MessagesSquareIcon, XIcon, @@ -41,6 +40,7 @@ import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; import { Composer } from './composer'; import type { ComposerAttachment } from './composer-attachments'; +import type { NewSessionBranchPickerComponent } from './new-session-branch-picker'; import { DEFAULT_MODE_ID } from './session-modes'; export interface NewSessionDraft { @@ -60,6 +60,7 @@ export interface NewSessionSubmission { effort?: EffortLevel | null; approvalPolicyId?: string; modeId?: SessionModeId; + branch?: { name: string }; input: Extract; } @@ -91,6 +92,9 @@ export interface NewSessionSurfaceProps { preferredModels?: Readonly>>; /** Last accepted effort per provider. Missing kinds retain the provider default. */ preferredEfforts?: Readonly>>; + /** Last successfully used branch per workspace. */ + preferredBranches?: Readonly>; + NewSessionBranchPickerComponent?: NewSessionBranchPickerComponent; /** Ranked files for the active draft workspace's `@` query. */ mentionItems: MentionItem[]; /** Queries files in the draft's currently selected workspace. */ @@ -145,6 +149,8 @@ export function NewSessionSurface({ defaultModels, preferredModels, preferredEfforts, + preferredBranches, + NewSessionBranchPickerComponent, mentionItems, onMentionQueryChange, onDownloadAgent, @@ -168,10 +174,14 @@ export function NewSessionSurface({ const [modeId, setModeId] = useState(DEFAULT_MODE_ID); const [selectedPolicies, setSelectedPolicies] = useState>>({}); const [pending, setPending] = useState(false); + const [selectedBranches, setSelectedBranches] = useState>({}); const selectableWorkspaces = chatWorkspace ? [chatWorkspace, ...workspaces] : workspaces; const selected = workspaceById(selectableWorkspaces, workspaceId); const isChatSelected = selected != null && selected === chatWorkspace; + const selectedBranch = selected + ? (selectedBranches[selected.workspaceId] ?? preferredBranches?.[selected.workspaceId]) + : undefined; const localModel = selectedModels[provider]; const selectedModel = localModel === undefined ? (preferredModels?.[provider] ?? null) : localModel; @@ -213,6 +223,7 @@ export function NewSessionSurface({ : constrainedEffort !== null && { effort: constrainedEffort }), ...(pickedPolicyId && { approvalPolicyId: pickedPolicyId }), modeId: modeId === DEFAULT_MODE_ID ? undefined : modeId, + ...(selectedBranch && { branch: { name: selectedBranch } }), input, }); } finally { @@ -285,6 +296,11 @@ export function NewSessionSurface({ onWorkspaceChange(nextWorkspaceId); } + function handleBranchChange(branch: string): void { + if (!selected) return; + setSelectedBranches((current) => ({ ...current, [selected.workspaceId]: branch })); + } + return (
{topContent} @@ -347,6 +363,9 @@ export function NewSessionSurface({ onSelect={handleWorkspaceChange} onPickDirectory={onPickDirectory} onRegisterWorkspace={onRegisterWorkspace} + selectedBranch={selectedBranch} + onSelectBranch={handleBranchChange} + NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} /> } /> @@ -365,6 +384,9 @@ function NewSessionContextBar({ onSelect, onPickDirectory, onRegisterWorkspace, + selectedBranch, + onSelectBranch, + NewSessionBranchPickerComponent, }: { workspaces: WorkspaceRecord[]; chatWorkspace: WorkspaceRecord | null; @@ -374,6 +396,9 @@ function NewSessionContextBar({ onSelect: (workspaceId: WorkspaceId) => void; onPickDirectory?: () => Promise; onRegisterWorkspace: (cwd: string) => Promise; + selectedBranch?: string; + onSelectBranch: (branch: string) => void; + NewSessionBranchPickerComponent?: NewSessionBranchPickerComponent; }): React.ReactNode { const t = useTranslations('workbench.newSession'); const tSidebar = useTranslations('workbench.sidebar'); @@ -471,12 +496,14 @@ function NewSessionContextBar({ {t('workLocally')} - {/* TODO(backend): branch/worktree selection for the new session — stub until the daemon exposes it. */} - + {selected && !isChatSelected && NewSessionBranchPickerComponent && ( + + )} {registerError != null && ( {tSidebar('registerWorkspaceError', { diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 7e934a73c..134908d82 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -14,6 +14,7 @@ import type { MentionItem } from './composer'; import type { ConversationComposerController } from './conversation-surface'; import { ConversationSurface } from './conversation-surface'; import { ErrorBanner } from './error-banner'; +import type { NewSessionBranchPickerComponent } from './new-session-branch-picker'; import type { AgentStartCatalogs, AttachmentSupportByAgent, @@ -58,6 +59,8 @@ export interface ShellFrameProps newSessionPreferredModels: Readonly>>; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; + newSessionPreferredBranches: Readonly>; + NewSessionBranchPickerComponent?: NewSessionBranchPickerComponent; /** Triggers (or retries) the managed download for an agent whose CLI is missing. */ onDownloadAgent?: (kind: AgentKind) => void; /** Accepts an out-of-range detected version for the current pick. */ @@ -125,6 +128,8 @@ export function ShellFrame({ newSessionDefaultModels, newSessionPreferredModels, newSessionPreferredEfforts, + newSessionPreferredBranches, + NewSessionBranchPickerComponent, onDownloadAgent, onContinueUnverified, onLoginAgent, @@ -214,6 +219,8 @@ export function ShellFrame({ defaultModels={newSessionDefaultModels} preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} + preferredBranches={newSessionPreferredBranches} + NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} mentionItems={mentionItems} onContinueUnverified={onContinueUnverified} onDownloadAgent={onDownloadAgent} diff --git a/packages/presentation/ui/src/thread-groups.ts b/packages/presentation/ui/src/thread-groups.ts index 643255531..66ab60cfa 100644 --- a/packages/presentation/ui/src/thread-groups.ts +++ b/packages/presentation/ui/src/thread-groups.ts @@ -37,9 +37,9 @@ export interface ThreadGroup { /** * Groups sessions by the workspace whose `cwd` matches (via `normalizeCwdKey`): groups order by * `lastUsedAt` desc, sessions by `createdAt` desc, unmatched sessions in one fallback group, last. - * Every registered workspace produces a group even with zero sessions — an empty one still needs - * a header to rename/archive/start a thread in. The chat workspace's group is marked `isChat`; - * callers split it out into the "Chats" section. + * Project/chat workspaces produce groups even with zero sessions; worktree sessions join their + * parent project and never produce a top-level group. The chat workspace's group is marked + * `isChat`; callers split it out into the "Chats" section. */ export function groupThreadsByWorkspace( sessions: readonly SessionInfo[], @@ -48,11 +48,20 @@ export function groupThreadsByWorkspace( const workspaceByCwdKey = new Map( workspaces.map((workspace) => [normalizeCwdKey(workspace.cwd), workspace]), ); + const workspaceById = new Map(workspaces.map((workspace) => [workspace.workspaceId, workspace])); const sessionsByWorkspaceId = new Map(); const unregistered: SessionInfo[] = []; for (const session of sessions) { - const workspace = workspaceByCwdKey.get(normalizeCwdKey(session.cwd)); + const matched = workspaceByCwdKey.get(normalizeCwdKey(session.cwd)); + let workspace = matched; + if (matched && workspaceKind(matched) === 'worktree') { + const parent = + matched.parentWorkspaceId === undefined + ? undefined + : workspaceById.get(matched.parentWorkspaceId); + workspace = parent && workspaceKind(parent) === 'project' ? parent : undefined; + } if (!workspace) { unregistered.push(session); continue; @@ -62,7 +71,8 @@ export function groupThreadsByWorkspace( else sessionsByWorkspaceId.set(workspace.workspaceId, [session]); } - const groups: ThreadGroup[] = [...workspaces] + const groups: ThreadGroup[] = workspaces + .filter((workspace) => workspaceKind(workspace) !== 'worktree') .sort((a, b) => b.lastUsedAt - a.lastUsedAt) .map((workspace) => ({ key: workspace.workspaceId,