From 6ec3f3bd91f14ce1d9efbf3f76188ec783a192b4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:16:34 -0400 Subject: [PATCH 1/4] feat(schedules): add workspace modes and worktree management --- backend/src/db/migration-runner.ts | 118 +++--- backend/src/db/migrations/001-base-schema.ts | 4 +- .../db/migrations/002-repos-nullable-url.ts | 4 +- .../db/migrations/003-repos-add-columns.ts | 4 +- .../src/db/migrations/004-repos-indexes.ts | 4 +- .../migrations/005-repos-local-path-prefix.ts | 4 +- .../006-git-token-to-credentials.ts | 4 +- backend/src/db/migrations/007-schedules.ts | 4 +- .../migrations/008-schedule-cron-support.ts | 4 +- .../src/db/migrations/009-prompt-templates.ts | 4 +- .../src/db/migrations/009-repo-source-path.ts | 4 +- .../db/migrations/011-repo-last-accessed.ts | 4 +- .../db/migrations/012-opencode-model-state.ts | 4 +- backend/src/db/migrations/013-app-secrets.ts | 4 +- .../src/db/migrations/014-repos-add-name.ts | 4 +- .../015-schedule-worktree-isolation.ts | 4 +- .../016-schedule-permission-config.ts | 4 +- .../017-schedule-run-workspace-id.ts | 4 +- backend/src/db/migrations/018-session-pins.ts | 4 +- .../migrations/019-drop-opencode-configs.ts | 4 +- .../020-drop-opencode-model-state.ts | 4 +- .../021-drop-schedule-run-workspace-id.ts | 4 +- .../022-schedule-runs-session-index.ts | 4 +- .../db/migrations/023-schedule-mcp-servers.ts | 4 +- .../migrations/024-schedule-runs-viewed-at.ts | 4 +- .../025-session-permission-modes.ts | 4 +- .../src/db/migrations/026-session-goals.ts | 4 +- backend/src/db/migrations/027-multi-runs.ts | 4 +- .../202610061345-schedule-workspace-mode.ts | 22 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/schedules.ts | 24 +- backend/src/index.ts | 2 +- backend/src/routes/repos.test.ts | 2 +- backend/src/routes/repos.ts | 9 +- backend/src/routes/schedules.ts | 28 +- backend/src/services/assistant-mode.ts | 2 + backend/src/services/git/GitService.ts | 26 +- backend/src/services/repo-workspace.ts | 26 +- backend/src/services/repo.ts | 183 ++++++--- backend/src/services/schedule-config.ts | 4 + .../src/services/schedule-worktree-paths.ts | 30 ++ backend/src/services/schedule-worktree.ts | 175 +++++++-- backend/src/services/schedules.ts | 89 ++++- backend/test/db/migration-runner.test.ts | 112 +++++- .../test/db/opencode-config-migration.test.ts | 2 +- .../db/opencode-model-state-migration.test.ts | 2 +- backend/test/db/schedules.permission.test.ts | 1 + backend/test/db/schedules.test.ts | 19 +- backend/test/db/schema.test.ts | 6 +- .../test/routes/internal-schedules.test.ts | 2 +- backend/test/routes/repos.test.ts | 37 +- backend/test/services/assistant-mode.test.ts | 2 +- .../test/services/git/GitService.real.test.ts | 1 + backend/test/services/repo-git.test.ts | 54 ++- .../services/repo-working-directory.test.ts | 2 +- backend/test/services/repo-workspace.test.ts | 53 ++- backend/test/services/repo.test.ts | 42 +- backend/test/services/schedule-config.test.ts | 8 + .../test/services/schedule-worktree.test.ts | 83 +++- .../services/schedules.permission.test.ts | 1 + backend/test/services/schedules.test.ts | 63 ++- frontend/src/api/repos.ts | 19 +- frontend/src/api/schedules.ts | 14 + .../src/components/repo/WorkspaceManager.tsx | 250 ------------ .../repo/WorktreeSessionGroups.test.tsx | 189 +++++++++ .../components/repo/WorktreeSessionGroups.tsx | 359 ++++++++++++++++++ .../src/components/repo/WorktreeTabs.test.tsx | 13 +- frontend/src/components/repo/WorktreeTabs.tsx | 29 +- .../src/components/schedules/GeneralTab.tsx | 52 ++- .../src/components/schedules/JobDetailTab.tsx | 57 ++- .../schedules/RunDetailPanel.test.tsx | 9 + .../components/schedules/RunDetailPanel.tsx | 2 +- .../schedules/ScheduleJobDialog.tsx | 8 +- .../schedules/ScheduleJobsTable.tsx | 8 + .../components/schedules/schedule-utils.ts | 32 +- .../components/session/SessionList.test.tsx | 17 + .../src/components/session/SessionList.tsx | 24 +- .../src/components/terminal/TerminalPanel.tsx | 4 +- frontend/src/hooks/useOpenCode.ts | 6 +- frontend/src/hooks/useOpenTerminal.ts | 16 +- frontend/src/hooks/useRepoSiblings.ts | 28 +- frontend/src/hooks/useSchedules.ts | 36 ++ frontend/src/pages/GlobalSchedules.tsx | 4 +- frontend/src/pages/RepoDetail.tsx | 259 ++++++------- frontend/src/pages/Schedules.tsx | 4 +- .../RepoDetail.worktree-setup.test.tsx | 42 +- shared/src/schemas/repo.ts | 8 + shared/src/schemas/schedule.ts | 29 ++ shared/src/types/index.ts | 5 + shared/src/utils/repo.ts | 20 +- 90 files changed, 2083 insertions(+), 800 deletions(-) create mode 100644 backend/src/db/migrations/202610061345-schedule-workspace-mode.ts create mode 100644 backend/src/services/schedule-worktree-paths.ts delete mode 100644 frontend/src/components/repo/WorkspaceManager.tsx create mode 100644 frontend/src/components/repo/WorktreeSessionGroups.test.tsx create mode 100644 frontend/src/components/repo/WorktreeSessionGroups.tsx diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 503bb7ecf..285fc7880 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -1,65 +1,38 @@ import { Database } from 'bun:sqlite' import { logger } from '../utils/logger' +/** + * A schema change applied once per database. Migrations run in the order they appear in + * `allMigrations`, and each is recorded by its `id`, which must never change once released. + * + * New migrations use a UTC timestamp id, `YYYYMMDDHHmm-short-name` (for example + * `202610061345-schedule-workspace-mode`), in a file of the same name, so migrations written on + * different branches cannot claim the same identity. Make `up` safe to run on a database that + * already has the change, because merged branches may apply migrations in a different order. + * + * `legacy` only exists on the numbered migrations written before ids; it lets databases that + * recorded them by version number in `schema_migrations` recognise them as applied. + */ export interface Migration { - version: number - name: string + id: string + legacy?: { version: number; name: string } up(db: Database): void down(db: Database): void } -interface MigrationRecord { +interface LegacyMigrationRecord { version: number name: string applied_at: number } -function ensureMigrationsTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL, - applied_at INTEGER NOT NULL - ) - `) -} - -function getAppliedMigrations(db: Database): Map { - const rows = db.prepare('SELECT version, name FROM schema_migrations ORDER BY version').all() as MigrationRecord[] - return new Map(rows.map(r => [r.version, r.name])) -} - -/** - * Surfaces version-number collisions where a recorded migration's name differs - * from the code migration registered under the same version. The runner keys on - * version number alone, so a reused version silently skips the real migration - * (e.g. an ADD COLUMN), producing later runtime errors that are hard to trace. - * This converts that silent skip into a loud, actionable warning. - */ -function warnOnVersionNameMismatch(applied: Map, migrations: Migration[]): void { - for (const migration of migrations) { - const recordedName = applied.get(migration.version) - if (recordedName !== undefined && recordedName !== migration.name) { - logger.warn( - `Migration version ${migration.version} is recorded as "${recordedName}" but the code defines "${migration.name}". ` + - `This migration was skipped; its schema changes may be missing. Verify the database schema and apply the changes manually if needed.`, - ) - } - } -} - -function markApplied(db: Database, migration: Migration): void { - db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)') - .run(migration.version, migration.name, Date.now()) -} - export function migrate(db: Database, migrations: Migration[]): void { - ensureMigrationsTable(db) + assertUniqueIds(migrations) + db.run('CREATE TABLE IF NOT EXISTS applied_migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)') + adoptLegacyMigrations(db, migrations) - const applied = getAppliedMigrations(db) - warnOnVersionNameMismatch(applied, migrations) - const sorted = [...migrations].sort((a, b) => a.version - b.version) - const pending = sorted.filter(m => !applied.has(m.version)) + const applied = new Set((db.prepare('SELECT id FROM applied_migrations').all() as Array<{ id: string }>).map((row) => row.id)) + const pending = migrations.filter((migration) => !applied.has(migration.id)) if (pending.length === 0) { logger.info('Database schema is up to date') @@ -69,16 +42,16 @@ export function migrate(db: Database, migrations: Migration[]): void { logger.info(`Running ${pending.length} pending migration(s)`) for (const migration of pending) { - logger.info(`Applying migration ${migration.version}: ${migration.name}`) + logger.info(`Applying migration ${migration.id}`) db.run('BEGIN TRANSACTION') try { migration.up(db) - markApplied(db, migration) + db.prepare('INSERT INTO applied_migrations (id, applied_at) VALUES (?, ?)').run(migration.id, Date.now()) db.run('COMMIT') - logger.info(`Migration ${migration.version} applied successfully`) + logger.info(`Migration ${migration.id} applied successfully`) } catch (error) { db.run('ROLLBACK') - logger.error(`Migration ${migration.version} failed:`, error) + logger.error(`Migration ${migration.id} failed:`, error) throw error } } @@ -86,4 +59,47 @@ export function migrate(db: Database, migrations: Migration[]): void { logger.info('All migrations applied successfully') } +function assertUniqueIds(migrations: Migration[]): void { + const seen = new Set() + for (const migration of migrations) { + if (seen.has(migration.id)) throw new Error(`Duplicate migration id "${migration.id}"`) + seen.add(migration.id) + } +} + +/** + * Records numbered migrations that a database applied under the old version-keyed table. + * A version recorded under a different name is still treated as applied, as before, but warned + * about because its schema change may be missing. + */ +function adoptLegacyMigrations(db: Database, migrations: Migration[]): void { + const hasLegacyTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get() + if (!hasLegacyTable) return + + const records = new Map( + (db.prepare('SELECT version, name, applied_at FROM schema_migrations').all() as LegacyMigrationRecord[]) + .map((record) => [record.version, record] as const), + ) + const adopted = migrations.flatMap((migration) => { + const record = migration.legacy ? records.get(migration.legacy.version) : undefined + if (!migration.legacy || !record) return [] + if (record.name !== migration.legacy.name) { + logger.warn( + `Migration version ${migration.legacy.version} is recorded as "${record.name}" but the code defines "${migration.legacy.name}". ` + + `This migration was skipped; its schema changes may be missing. Verify the database schema and apply the changes manually if needed.`, + ) + } + return [{ id: migration.id, appliedAt: record.applied_at }] + }) + if (adopted.length === 0) return + const insert = db.prepare('INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)') + db.run('BEGIN TRANSACTION') + try { + adopted.forEach((entry) => insert.run(entry.id, entry.appliedAt)) + db.run('COMMIT') + } catch (error) { + db.run('ROLLBACK') + throw error + } +} diff --git a/backend/src/db/migrations/001-base-schema.ts b/backend/src/db/migrations/001-base-schema.ts index 4e832ef5d..43fece373 100644 --- a/backend/src/db/migrations/001-base-schema.ts +++ b/backend/src/db/migrations/001-base-schema.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 1, - name: 'base-schema', + id: '001-base-schema', + legacy: { version: 1, name: 'base-schema' }, up(db) { db.run(` diff --git a/backend/src/db/migrations/002-repos-nullable-url.ts b/backend/src/db/migrations/002-repos-nullable-url.ts index c4033b763..5996c72eb 100644 --- a/backend/src/db/migrations/002-repos-nullable-url.ts +++ b/backend/src/db/migrations/002-repos-nullable-url.ts @@ -6,8 +6,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 2, - name: 'repos-nullable-url', + id: '002-repos-nullable-url', + legacy: { version: 2, name: 'repos-nullable-url' }, up(db) { const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/003-repos-add-columns.ts b/backend/src/db/migrations/003-repos-add-columns.ts index 91325014a..0b4ca09ca 100644 --- a/backend/src/db/migrations/003-repos-add-columns.ts +++ b/backend/src/db/migrations/003-repos-add-columns.ts @@ -16,8 +16,8 @@ const COLUMNS = [ ] const migration: Migration = { - version: 3, - name: 'repos-add-columns', + id: '003-repos-add-columns', + legacy: { version: 3, name: 'repos-add-columns' }, up(db) { const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/004-repos-indexes.ts b/backend/src/db/migrations/004-repos-indexes.ts index 370eb34cb..3fa47240b 100644 --- a/backend/src/db/migrations/004-repos-indexes.ts +++ b/backend/src/db/migrations/004-repos-indexes.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 4, - name: 'repos-indexes', + id: '004-repos-indexes', + legacy: { version: 4, name: 'repos-indexes' }, up(db) { db.run(` diff --git a/backend/src/db/migrations/005-repos-local-path-prefix.ts b/backend/src/db/migrations/005-repos-local-path-prefix.ts index 188e9557a..9270ca704 100644 --- a/backend/src/db/migrations/005-repos-local-path-prefix.ts +++ b/backend/src/db/migrations/005-repos-local-path-prefix.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { logger } from '../../utils/logger' const migration: Migration = { - version: 5, - name: 'repos-local-path-prefix', + id: '005-repos-local-path-prefix', + legacy: { version: 5, name: 'repos-local-path-prefix' }, up(db) { const repos = db.prepare("SELECT id, local_path FROM repos WHERE local_path LIKE 'repos/%'").all() as Array<{ diff --git a/backend/src/db/migrations/006-git-token-to-credentials.ts b/backend/src/db/migrations/006-git-token-to-credentials.ts index c3f717a7f..12ffaee7d 100644 --- a/backend/src/db/migrations/006-git-token-to-credentials.ts +++ b/backend/src/db/migrations/006-git-token-to-credentials.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { logger } from '../../utils/logger' const migration: Migration = { - version: 6, - name: 'git-token-to-credentials', + id: '006-git-token-to-credentials', + legacy: { version: 6, name: 'git-token-to-credentials' }, up(db) { const rows = db.prepare('SELECT user_id, preferences FROM user_preferences').all() as Array<{ diff --git a/backend/src/db/migrations/007-schedules.ts b/backend/src/db/migrations/007-schedules.ts index e0f795916..2c9c9ecb7 100644 --- a/backend/src/db/migrations/007-schedules.ts +++ b/backend/src/db/migrations/007-schedules.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 7, - name: 'schedules', + id: '007-schedules', + legacy: { version: 7, name: 'schedules' }, up(db) { db.run(` diff --git a/backend/src/db/migrations/008-schedule-cron-support.ts b/backend/src/db/migrations/008-schedule-cron-support.ts index a00369894..fd00a1811 100644 --- a/backend/src/db/migrations/008-schedule-cron-support.ts +++ b/backend/src/db/migrations/008-schedule-cron-support.ts @@ -7,8 +7,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 8, - name: 'schedule-cron-support', + id: '008-schedule-cron-support', + legacy: { version: 8, name: 'schedule-cron-support' }, up(db) { const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schedule_jobs'").get() diff --git a/backend/src/db/migrations/009-prompt-templates.ts b/backend/src/db/migrations/009-prompt-templates.ts index e1041d076..416e2a524 100644 --- a/backend/src/db/migrations/009-prompt-templates.ts +++ b/backend/src/db/migrations/009-prompt-templates.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 10, - name: '010-prompt-templates', + id: '009-prompt-templates', + legacy: { version: 10, name: '010-prompt-templates' }, up(db) { db.run(` diff --git a/backend/src/db/migrations/009-repo-source-path.ts b/backend/src/db/migrations/009-repo-source-path.ts index be6247d5b..02900a849 100644 --- a/backend/src/db/migrations/009-repo-source-path.ts +++ b/backend/src/db/migrations/009-repo-source-path.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 9, - name: 'repo-source-path', + id: '009-repo-source-path', + legacy: { version: 9, name: 'repo-source-path' }, up(db) { const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/011-repo-last-accessed.ts b/backend/src/db/migrations/011-repo-last-accessed.ts index 8b440f45b..733a7e096 100644 --- a/backend/src/db/migrations/011-repo-last-accessed.ts +++ b/backend/src/db/migrations/011-repo-last-accessed.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 11, - name: 'repo-last-accessed', + id: '011-repo-last-accessed', + legacy: { version: 11, name: 'repo-last-accessed' }, up(db) { const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/012-opencode-model-state.ts b/backend/src/db/migrations/012-opencode-model-state.ts index 4fd2ebe96..261deeda0 100644 --- a/backend/src/db/migrations/012-opencode-model-state.ts +++ b/backend/src/db/migrations/012-opencode-model-state.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 12, - name: 'opencode-model-state', + id: '012-opencode-model-state', + legacy: { version: 12, name: 'opencode-model-state' }, up(db) { db.run(` CREATE TABLE IF NOT EXISTS opencode_model_state ( diff --git a/backend/src/db/migrations/013-app-secrets.ts b/backend/src/db/migrations/013-app-secrets.ts index ce64c2cde..f27ca7c06 100644 --- a/backend/src/db/migrations/013-app-secrets.ts +++ b/backend/src/db/migrations/013-app-secrets.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 13, - name: 'app-secrets', + id: '013-app-secrets', + legacy: { version: 13, name: 'app-secrets' }, up(db) { db.run(` CREATE TABLE IF NOT EXISTS app_secrets ( diff --git a/backend/src/db/migrations/014-repos-add-name.ts b/backend/src/db/migrations/014-repos-add-name.ts index dc8b82433..86f76a77e 100644 --- a/backend/src/db/migrations/014-repos-add-name.ts +++ b/backend/src/db/migrations/014-repos-add-name.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 14, - name: 'repos-add-name', + id: '014-repos-add-name', + legacy: { version: 14, name: 'repos-add-name' }, up(db) { const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/015-schedule-worktree-isolation.ts b/backend/src/db/migrations/015-schedule-worktree-isolation.ts index 8d6ae269d..bce41478d 100644 --- a/backend/src/db/migrations/015-schedule-worktree-isolation.ts +++ b/backend/src/db/migrations/015-schedule-worktree-isolation.ts @@ -7,8 +7,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 15, - name: 'schedule-worktree-isolation', + id: '015-schedule-worktree-isolation', + legacy: { version: 15, name: 'schedule-worktree-isolation' }, up(db) { const jobColumns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/016-schedule-permission-config.ts b/backend/src/db/migrations/016-schedule-permission-config.ts index a6a05aa35..764d8730c 100644 --- a/backend/src/db/migrations/016-schedule-permission-config.ts +++ b/backend/src/db/migrations/016-schedule-permission-config.ts @@ -7,8 +7,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 16, - name: 'schedule-permission-config', + id: '016-schedule-permission-config', + legacy: { version: 16, name: 'schedule-permission-config' }, up(db) { const cols = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/017-schedule-run-workspace-id.ts b/backend/src/db/migrations/017-schedule-run-workspace-id.ts index 5affc233f..fbbf288d7 100644 --- a/backend/src/db/migrations/017-schedule-run-workspace-id.ts +++ b/backend/src/db/migrations/017-schedule-run-workspace-id.ts @@ -7,8 +7,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 17, - name: 'schedule-run-workspace-id', + id: '017-schedule-run-workspace-id', + legacy: { version: 17, name: 'schedule-run-workspace-id' }, up(db) { const cols = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/018-session-pins.ts b/backend/src/db/migrations/018-session-pins.ts index 1cf09c6e9..2bfa40c69 100644 --- a/backend/src/db/migrations/018-session-pins.ts +++ b/backend/src/db/migrations/018-session-pins.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { ensureSessionPinsTable } from '../session-pins' const migration: Migration = { - version: 18, - name: 'session-pins', + id: '018-session-pins', + legacy: { version: 18, name: 'session-pins' }, up(db) { ensureSessionPinsTable(db) }, diff --git a/backend/src/db/migrations/019-drop-opencode-configs.ts b/backend/src/db/migrations/019-drop-opencode-configs.ts index 434eb7647..a6a88a46d 100644 --- a/backend/src/db/migrations/019-drop-opencode-configs.ts +++ b/backend/src/db/migrations/019-drop-opencode-configs.ts @@ -45,8 +45,8 @@ function archiveConfigs(rows: Array<{ config_name: string; config_content: strin } const migration: Migration = { - version: 19, - name: 'drop-opencode-configs', + id: '019-drop-opencode-configs', + legacy: { version: 19, name: 'drop-opencode-configs' }, up(db) { const rows = db.prepare('SELECT config_name, config_content, is_default FROM opencode_configs').all() as Array<{ config_name: string; config_content: string; is_default: number | null }> diff --git a/backend/src/db/migrations/020-drop-opencode-model-state.ts b/backend/src/db/migrations/020-drop-opencode-model-state.ts index 06239c333..a2dc55796 100644 --- a/backend/src/db/migrations/020-drop-opencode-model-state.ts +++ b/backend/src/db/migrations/020-drop-opencode-model-state.ts @@ -49,8 +49,8 @@ function restoreModelStateFile(row: ModelStateRow): void { } const migration: Migration = { - version: 20, - name: 'drop-opencode-model-state', + id: '020-drop-opencode-model-state', + legacy: { version: 20, name: 'drop-opencode-model-state' }, up(db) { const row = readDefaultRow(db) diff --git a/backend/src/db/migrations/021-drop-schedule-run-workspace-id.ts b/backend/src/db/migrations/021-drop-schedule-run-workspace-id.ts index d07957973..2e26ece89 100644 --- a/backend/src/db/migrations/021-drop-schedule-run-workspace-id.ts +++ b/backend/src/db/migrations/021-drop-schedule-run-workspace-id.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 21, - name: 'drop-schedule-run-workspace-id', + id: '021-drop-schedule-run-workspace-id', + legacy: { version: 21, name: 'drop-schedule-run-workspace-id' }, up(db) { const cols = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/022-schedule-runs-session-index.ts b/backend/src/db/migrations/022-schedule-runs-session-index.ts index 9e56f04b7..c1eb9b886 100644 --- a/backend/src/db/migrations/022-schedule-runs-session-index.ts +++ b/backend/src/db/migrations/022-schedule-runs-session-index.ts @@ -1,8 +1,8 @@ import type { Migration } from '../migration-runner' const migration: Migration = { - version: 22, - name: 'schedule-runs-session-index', + id: '022-schedule-runs-session-index', + legacy: { version: 22, name: 'schedule-runs-session-index' }, up(db) { db.run(` diff --git a/backend/src/db/migrations/023-schedule-mcp-servers.ts b/backend/src/db/migrations/023-schedule-mcp-servers.ts index 32a0d5b0f..6ba490caf 100644 --- a/backend/src/db/migrations/023-schedule-mcp-servers.ts +++ b/backend/src/db/migrations/023-schedule-mcp-servers.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 23, - name: 'schedule-mcp-servers', + id: '023-schedule-mcp-servers', + legacy: { version: 23, name: 'schedule-mcp-servers' }, up(db) { const columns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/024-schedule-runs-viewed-at.ts b/backend/src/db/migrations/024-schedule-runs-viewed-at.ts index b9e39c9b5..408e7a062 100644 --- a/backend/src/db/migrations/024-schedule-runs-viewed-at.ts +++ b/backend/src/db/migrations/024-schedule-runs-viewed-at.ts @@ -5,8 +5,8 @@ interface ColumnInfo { } const migration: Migration = { - version: 24, - name: 'schedule-runs-viewed-at', + id: '024-schedule-runs-viewed-at', + legacy: { version: 24, name: 'schedule-runs-viewed-at' }, up(db) { const columns = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] diff --git a/backend/src/db/migrations/025-session-permission-modes.ts b/backend/src/db/migrations/025-session-permission-modes.ts index a952f392a..4a605bf86 100644 --- a/backend/src/db/migrations/025-session-permission-modes.ts +++ b/backend/src/db/migrations/025-session-permission-modes.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { ensureSessionPermissionModesTable } from '../session-permission-modes' const migration: Migration = { - version: 25, - name: 'session-permission-modes', + id: '025-session-permission-modes', + legacy: { version: 25, name: 'session-permission-modes' }, up(db) { ensureSessionPermissionModesTable(db) }, diff --git a/backend/src/db/migrations/026-session-goals.ts b/backend/src/db/migrations/026-session-goals.ts index bde0fe612..a78f1d2d4 100644 --- a/backend/src/db/migrations/026-session-goals.ts +++ b/backend/src/db/migrations/026-session-goals.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { ensureSessionGoalsTable } from '../session-goals' const migration: Migration = { - version: 26, - name: 'session-goals', + id: '026-session-goals', + legacy: { version: 26, name: 'session-goals' }, up(db) { ensureSessionGoalsTable(db) }, diff --git a/backend/src/db/migrations/027-multi-runs.ts b/backend/src/db/migrations/027-multi-runs.ts index c97f69583..0b4521a7a 100644 --- a/backend/src/db/migrations/027-multi-runs.ts +++ b/backend/src/db/migrations/027-multi-runs.ts @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner' import { ensureMultiRunTables } from '../multi-runs' const migration: Migration = { - version: 27, - name: 'multi-runs', + id: '027-multi-runs', + legacy: { version: 27, name: 'multi-runs' }, up(db) { ensureMultiRunTables(db) }, diff --git a/backend/src/db/migrations/202610061345-schedule-workspace-mode.ts b/backend/src/db/migrations/202610061345-schedule-workspace-mode.ts new file mode 100644 index 000000000..8c4e1441e --- /dev/null +++ b/backend/src/db/migrations/202610061345-schedule-workspace-mode.ts @@ -0,0 +1,22 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + id: '202610061345-schedule-workspace-mode', + + up(db) { + const columns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] + if (!columns.some((column) => column.name === 'workspace_mode')) { + db.run("ALTER TABLE schedule_jobs ADD COLUMN workspace_mode TEXT NOT NULL DEFAULT 'worktree'") + } + }, + + down(db) { + db.run('ALTER TABLE schedule_jobs DROP COLUMN workspace_mode') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 3fbc66f03..b2c66a0f8 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -26,6 +26,7 @@ import migration024 from './024-schedule-runs-viewed-at' import migration025 from './025-session-permission-modes' import migration026 from './026-session-goals' import migration027 from './027-multi-runs' +import migration202610061345 from './202610061345-schedule-workspace-mode' export const allMigrations: Migration[] = [ migration001, @@ -55,4 +56,5 @@ export const allMigrations: Migration[] = [ migration025, migration026, migration027, + migration202610061345, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index b7b869d2f..d04968fbd 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -12,6 +12,7 @@ import { type ScheduleRun, type ScheduleRunStatus, type ScheduleRunTriggerSource, + type ScheduleWorkspaceMode, } from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_NAME, ASSISTANT_REPO_PATH, getRepoDisplayName } from '@opencode-manager/shared/utils' import type { ScheduleJobPersistenceInput } from '../services/schedule-config' @@ -33,6 +34,7 @@ interface ScheduleJobRow { permission_config: string | null mcp_servers: string | null branch: string | null + workspace_mode: ScheduleWorkspaceMode | null created_at: number updated_at: number last_run_at: number | null @@ -118,6 +120,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { permissionConfig: parsePermissionConfig(row.permission_config), mcpServers: parseMcpServers(row.mcp_servers), branch: row.branch, + workspaceMode: row.workspace_mode ?? 'worktree', createdAt: row.created_at, updatedAt: row.updated_at, lastRunAt: row.last_run_at, @@ -199,9 +202,10 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ permission_config, mcp_servers, branch, + workspace_mode, created_at, updated_at, last_run_at, next_run_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( @@ -220,6 +224,7 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ serializePermissionConfig(input.permissionConfig), serializeMcpServers(input.mcpServers), input.branch, + input.workspaceMode, now, now, null, @@ -244,7 +249,7 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i const stmt = db.prepare(` UPDATE schedule_jobs SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?, - agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, updated_at = ?, next_run_at = ? + agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, updated_at = ?, next_run_at = ? WHERE repo_id = ? AND id = ? `) @@ -263,6 +268,7 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i serializePermissionConfig(input.permissionConfig), serializeMcpServers(input.mcpServers), input.branch, + input.workspaceMode, now, input.nextRunAt, repoId, @@ -298,11 +304,15 @@ export function listScheduleRunArtifactsByJob(db: Database, repoId: number, jobI })) } -export function listActiveScheduleRunWorktreePaths(db: Database): string[] { - const rows = db - .prepare('SELECT worktree_path FROM schedule_runs WHERE worktree_path IS NOT NULL') - .all() as { worktree_path: string }[] - return rows.map((row) => row.worktree_path) +export function getScheduleJobRepoId(db: Database, jobId: number): number | null { + const row = db.prepare('SELECT repo_id FROM schedule_jobs WHERE id = ?').get(jobId) as { repo_id: number } | undefined + return row?.repo_id ?? null +} + +export function clearScheduleRunWorktreePath(db: Database, repoId: number, jobId: number, worktreePath: string): number { + return db + .prepare('UPDATE schedule_runs SET worktree_path = NULL WHERE repo_id = ? AND job_id = ? AND worktree_path = ?') + .run(repoId, jobId, worktreePath).changes } export function deleteScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): boolean { diff --git a/backend/src/index.ts b/backend/src/index.ts index 86f1f47cf..d771af97f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -243,9 +243,9 @@ const terminalService = new TerminalService( () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), ) const projectConfigService = new ProjectConfigService(db, createGitService(gitAuthService), gitAuthService) -const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) +const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index 9da42bac7..d85e8e4a6 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -53,7 +53,7 @@ function createTestApp( prepareRepoDelete: () => {}, } as any const projectConfigService = new ProjectConfigService(db, createGitService(stubGitAuthService), stubGitAuthService) - const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, stubGitAuthService, projectConfigService, terminalService) + const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, stubGitAuthService, projectConfigService, terminalService, scheduleService) app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces)) return app } diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index a421a2f67..929c78213 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' import type { DeleteRepoResult, Repo } from '@opencode-manager/shared/types' -import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema, UpdateRepoRequestSchema, DeleteRepoRequestSchema } from '@opencode-manager/shared/schemas' +import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema, UpdateRepoRequestSchema, CreateRepoWorkspaceRequestSchema, DeleteRepoRequestSchema } from '@opencode-manager/shared/schemas' import { listRepos, getRepoById, updateLastAccessed, getRepoGitCredentialId, setRepoGitCredentialId, updateRepoName } from '../db/queries' import * as repoService from '../services/repo' import * as archiveService from '../services/archive' @@ -358,8 +358,13 @@ app.get('/', async (c) => { const repo = getRepoById(database, id) if (!repo || repo.cloneStatus !== 'ready') return c.json({ error: 'Repo not found' }, 404) + const parsed = CreateRepoWorkspaceRequestSchema.safeParse(await c.req.json().catch(() => ({}))) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400) + } + try { - return c.json(await repoWorkspaces.create(repo)) + return c.json(await repoWorkspaces.create(repo, { name: parsed.data.name || undefined })) } catch (error: unknown) { if (isWorktreeError(error)) { return c.json({ error: error.data.message }, 409) diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index 98b9c5d8d..8c8d347d2 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { CreateScheduleJobRequestSchema, + RemoveScheduleWorktreesRequestSchema, UpdateScheduleJobRequestSchema, } from '@opencode-manager/shared/schemas' import { ScheduleService, ScheduleServiceError } from '../services/schedules' @@ -152,17 +153,40 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { } }) - app.delete('/:jobId', (c) => { + app.delete('/:jobId', async (c) => { try { const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) - scheduleService.deleteJob(repoId, jobId) + await scheduleService.deleteJob(repoId, jobId) return c.json({ success: true }) } catch (error) { return handleServiceError(c, error, 'Failed to delete schedule', ScheduleServiceError) } }) + app.get('/:jobId/worktrees', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + return c.json({ worktrees: scheduleService.listWorktrees(repoId, jobId) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list schedule worktrees', ScheduleServiceError) + } + }) + + app.delete('/:jobId/worktrees', async (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const body = await c.req.json().catch(() => ({})) + const input = RemoveScheduleWorktreesRequestSchema.parse(body) + const result = await scheduleService.removeWorktrees(repoId, jobId, input.worktreePath) + return c.json(result) + } catch (error) { + return handleServiceError(c, error, 'Failed to remove schedule worktrees', ScheduleServiceError) + } + }) + app.post('/:jobId/run', async (c) => { try { const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index c01dcbdb2..4391a2914 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -400,6 +400,8 @@ Create a new schedule job. Body matches \`CreateScheduleJobRequest\` schema (discriminated union with \`scheduleMode: 'interval' | 'cron'\`). +Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fresh worktree removed after each run), \`kept-worktree\` (a fresh worktree kept after each run), \`shared-worktree\` (one worktree and branch reused by every run), or \`repo\` (the repository checkout itself). Kept and shared worktrees are listed with \`GET /repos/:repoId/schedules/:jobId/worktrees\` and removed with \`DELETE /repos/:repoId/schedules/:jobId/worktrees\` (optional body \`{ "worktreePath": "..." }\`; omit it to remove every idle worktree). Pending changes are committed to the worktree's branch before removal. + \`\`\`json { "action": "request", diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts index 95038948e..907ff8878 100644 --- a/backend/src/services/git/GitService.ts +++ b/backend/src/services/git/GitService.ts @@ -6,7 +6,7 @@ import { getRepoById, getRepoByDirectory, updateRepoBranch, listRepos } from '.. import { updateScheduleJobsBranch } from '../../db/schedules' import { isSSHUrl, getBranchNameError } from '@opencode-manager/shared/utils' import { GitOperationError, isNoUpstreamError, parseGitError } from '../../utils/git-errors' -import { resolveMainCheckoutPath } from '../repo' +import { listGitWorktrees, resolveMainCheckoutPath } from '../repo' import { MAX_COMMIT_PROMPT_DIFF_CHARS, type CommitMessageContext } from './commit-message-prompt' import type { Database } from 'bun:sqlite' import type { DeleteBranchResult, GitOperationKind, GitOperationState, GitStashEntry, IntegrateBranchRequest, IntegrateBranchResult, StashPushRequest } from '@opencode-manager/shared' @@ -1058,30 +1058,8 @@ export class GitService { ) } - private async listWorktreeCheckouts(repoPath: string, env: Record | undefined): Promise> { - try { - const output = await executeCommand(['git', '-C', repoPath, 'worktree', 'list', '--porcelain'], { env, silent: true }) - const checkouts: Array<{ path: string; branch: string | null }> = [] - let current: { path: string; branch: string | null } | null = null - - for (const line of output.split('\n')) { - if (line.startsWith('worktree ')) { - if (current) checkouts.push(current) - current = { path: line.slice('worktree '.length).trim(), branch: null } - } else if (line.startsWith('branch refs/heads/') && current) { - current.branch = line.slice('branch refs/heads/'.length).trim() - } - } - if (current) checkouts.push(current) - - return checkouts - } catch { - return [] - } - } - private async findCheckoutPath(repoPath: string, branch: string, env: Record | undefined): Promise { - const checkouts = await this.listWorktreeCheckouts(repoPath, env) + const checkouts = await listGitWorktrees(repoPath, env) return checkouts.find((checkout) => checkout.branch === branch)?.path ?? null } diff --git a/backend/src/services/repo-workspace.ts b/backend/src/services/repo-workspace.ts index 07f3d32ea..b09ea17a6 100644 --- a/backend/src/services/repo-workspace.ts +++ b/backend/src/services/repo-workspace.ts @@ -6,9 +6,12 @@ import type { GitAuthService } from './git-auth' import type { OpenCodeClient } from './opencode/client' import type { ProjectConfigService } from './project-config' import type { TerminalService } from './terminal' +import type { ScheduleService } from './schedules' import { findSiblingByDirectory, getSiblingRepos, RepoWorkspaceError, resolveRepoProjectId } from './repo' +import { executeCommand } from '../utils/process' +import { getErrorMessage } from '../utils/error-utils' -/** Single owner of OpenCode workspace lifecycle side effects: worktree setup on create and terminal cleanup on remove. */ +/** Single owner of repo worktree lifecycle side effects: worktree setup on create, and terminal cleanup plus owner-specific removal on remove. */ export class RepoWorkspaceService { constructor( private readonly database: Database, @@ -16,6 +19,7 @@ export class RepoWorkspaceService { private readonly gitAuthService: GitAuthService, private readonly projectConfigService: ProjectConfigService, private readonly terminalService: TerminalService, + private readonly scheduleWorktrees: Pick, ) {} async create(repo: Repo, options: { name?: string; ref?: string } = {}) { @@ -29,11 +33,31 @@ export class RepoWorkspaceService { return { ...worktree, worktreeSetup } } + /** + * Removes a worktree of the repo the way its owner expects: OpenCode worktrees through + * OpenCode, schedule worktrees through the schedule (committing pending changes first), + * and plain git worktrees with `git worktree remove`, which refuses uncommitted changes. + */ async remove(repo: Repo, directory: string): Promise { const worktree = findSiblingByDirectory(await this.listWorktreeSiblings(repo.id), directory) if (!worktree) throw new RepoWorkspaceError('Not a deletable worktree of this repo', 400) + if (worktree.schedule?.inUse) throw new RepoWorkspaceError('This worktree is in use by a running scheduled run. Cancel the run first.', 400) await this.removeTerminals(worktree.fullPath) + + if (worktree.schedule) { + await this.scheduleWorktrees.removeWorktrees(worktree.schedule.repoId, worktree.schedule.jobId, worktree.fullPath) + return + } + + if (worktree.worktreeSource === 'git') { + await executeCommand(['git', '-C', repo.fullPath, 'worktree', 'remove', worktree.fullPath], { env: this.gitAuthService.getGitEnvironment(true) }) + .catch((error: unknown) => { + throw new RepoWorkspaceError(`Could not remove worktree: ${getErrorMessage(error)}`, 400) + }) + return + } + const projectID = await resolveRepoProjectId(this.openCodeClient, repo.fullPath) await this.openCodeClient.api.worktree.remove({ projectID, directory: worktree.fullPath, force: true }) } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 284f112af..6b2092658 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID, type RepoWorktreeSchedule, type RepoWorktreeSource } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -16,7 +16,8 @@ import { getErrorMessage } from '../utils/error-utils' import { sseAggregator } from './sse-aggregator' import { resolveProjectId, isGitMainCheckout } from './project-id-resolver' import { listRepos } from '../db/queries' -import { listActiveScheduleRunWorktreePaths } from '../db/schedules' +import { getScheduleJobRepoId, listRunningScheduleRuns } from '../db/schedules' +import { parseScheduleWorktreeName } from './schedule-worktree-paths' import { SettingsService } from './settings' import { buildAssistantRepo } from './assistant-mode' import type { OpenCodeClient } from './opencode/client' @@ -1228,13 +1229,20 @@ export async function resolveRepoWorkingDirectory( return findSiblingByDirectory(await loadSiblings(), directory)?.fullPath ?? null } +export type RepoSibling = Repo & { + currentBranch: string | undefined + worktreeSource?: RepoWorktreeSource + worktreeStrategy?: string + schedule?: RepoWorktreeSchedule +} + export async function getSiblingRepos( database: Database, repoId: number, gitEnv: Record, openCodeClient?: OpenCodeClient, options: { includeBranch?: boolean } = {}, -): Promise> { +): Promise { const settingsService = new SettingsService(database) const settings = settingsService.getSettings() const allRepos = listRepos(database, settings.preferences.repoOrder) @@ -1265,64 +1273,129 @@ export async function getSiblingRepos( })), ) - if (!openCodeClient) return repoSiblings - - try { - const projectID = await resolveRepoProjectId(openCodeClient, target.fullPath) - const worktrees = await openCodeClient.api.worktree.list({ projectID }) - - const knownDirectories = new Set(repoSiblings.map((repo) => canonicalPathSync(path.resolve(repo.fullPath)))) - const targetDirectory = canonicalPathSync(path.resolve(target.fullPath)) - const reposRoot = canonicalPathSync(path.resolve(getReposPath())) - const scheduleWorktreeRoot = canonicalPathSync(path.resolve(getScheduleWorktreesPath())) - const activeRunDirectories = new Set( - listActiveScheduleRunWorktreePaths(database).map((p) => canonicalPathSync(path.resolve(p))), - ) + const [openCodeWorktrees, gitWorktrees] = await Promise.all([ + listOpenCodeWorktrees(openCodeClient, target.fullPath), + listGitWorktrees(target.fullPath, gitEnv), + ]) + + const excludedDirectories = new Set([ + ...repoSiblings.map((repo) => canonicalPathSync(path.resolve(repo.fullPath))), + canonicalPathSync(path.resolve(getReposPath())), + ]) + const candidates = new Map() + openCodeWorktrees.forEach((worktree) => { + const key = canonicalPathSync(path.resolve(worktree.directory)) + if (!excludedDirectories.has(key) && !candidates.has(key) && existsSync(worktree.directory)) { + candidates.set(key, { directory: worktree.directory, strategy: worktree.strategy, inGit: false }) + } + }) + gitWorktrees.slice(1).forEach((worktree) => { + const key = canonicalPathSync(path.resolve(worktree.path)) + if (excludedDirectories.has(key)) return + const existing = candidates.get(key) + if (existing) { + existing.branch = worktree.branch ?? undefined + existing.inGit = true + return + } + if (existsSync(worktree.path)) { + candidates.set(key, { directory: worktree.path, branch: worktree.branch ?? undefined, inGit: true }) + } + }) - const candidates = worktrees.filter((worktree) => { - const directory = canonicalPathSync(path.resolve(worktree.directory)) - if (directory === targetDirectory) return false - if (directory === reposRoot) return false - if (directory.startsWith(`${scheduleWorktreeRoot}${path.sep}`)) return false - if (activeRunDirectories.has(directory)) return false - if (knownDirectories.has(directory)) return false - return true + const entries = Array.from(candidates.entries()) + const mainChecks = await Promise.all( + entries.map(([, candidate]) => isGitMainCheckout(candidate.directory).catch(() => false)), + ) + const describeSchedule = createScheduleWorktreeDescriber(database) + + const worktreeSiblings = entries + .filter((_, index) => !mainChecks[index]) + .map(([key, candidate]) => { + const schedule = describeSchedule(key) + const worktreeSource: RepoWorktreeSource | undefined = schedule + ? 'schedule' + : candidate.strategy ? 'opencode' : candidate.inGit ? 'git' : undefined + return { + id: -1, + repoUrl: target.repoUrl, + localPath: path.basename(candidate.directory), + fullPath: candidate.directory, + sourcePath: candidate.directory, + branch: candidate.branch, + defaultBranch: target.defaultBranch, + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + isWorktree: true, + isLocal: true, + currentBranch: candidate.branch, + worktreeSource, + worktreeStrategy: candidate.strategy, + ...(schedule ? { schedule } : {}), + } }) - const mainChecks = await Promise.all( - candidates.map((worktree) => isGitMainCheckout(worktree.directory).catch(() => false)), - ) - - const uniqueWorktrees = new Map() - candidates - .filter((_, index) => !mainChecks[index]) - .forEach((worktree) => { - const directory = canonicalPathSync(path.resolve(worktree.directory)) - if (!uniqueWorktrees.has(directory)) { - uniqueWorktrees.set(directory, worktree) - } - }) + return [...repoSiblings, ...worktreeSiblings] +} - const worktreeSiblings = Array.from(uniqueWorktrees.values()).map((worktree) => ({ - id: -1, - repoUrl: target.repoUrl, - localPath: path.basename(worktree.directory), - fullPath: worktree.directory, - sourcePath: worktree.directory, - branch: undefined, - defaultBranch: target.defaultBranch, - cloneStatus: 'ready' as const, - clonedAt: Date.now(), - isWorktree: true, - isLocal: true, - currentBranch: undefined, - worktreeStrategy: worktree.strategy, - })) - - return [...repoSiblings, ...worktreeSiblings] +async function listOpenCodeWorktrees(openCodeClient: OpenCodeClient | undefined, directory: string) { + if (!openCodeClient) return [] + try { + const projectID = await resolveRepoProjectId(openCodeClient, directory) + return await openCodeClient.api.worktree.list({ projectID }) } catch (error) { logger.warn('Failed to list OpenCode worktrees:', error) - return repoSiblings + return [] + } +} + +/** + * Lists a repository's git worktrees with their checked-out branch; the main worktree comes first. + */ +export async function listGitWorktrees(repoPath: string, env: Record | undefined): Promise> { + try { + const output = await executeCommand(['git', '-C', repoPath, 'worktree', 'list', '--porcelain'], { env, silent: true }) + const checkouts: Array<{ path: string; branch: string | null }> = [] + let current: { path: string; branch: string | null } | null = null + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + current = { path: line.slice('worktree '.length).trim(), branch: null } + checkouts.push(current) + } else if (line.startsWith('branch refs/heads/') && current) { + current.branch = line.slice('branch refs/heads/'.length).trim() + } + } + return checkouts + } catch { + return [] + } +} + +/** + * Builds a lookup that says which schedule, if any, owns a canonical worktree directory: + * either a directory named for a schedule under the schedule worktree root, or the + * worktree a running run works in. Directories of deleted schedules are not owned. + */ +function createScheduleWorktreeDescriber(database: Database): (directory: string) => RepoWorktreeSchedule | undefined { + const scheduleRoot = canonicalPathSync(path.resolve(getScheduleWorktreesPath())) + const runningRuns = listRunningScheduleRuns(database) + const runningByDirectory = new Map(runningRuns.flatMap((run) => ( + run.worktreePath ? [[canonicalPathSync(path.resolve(run.worktreePath)), run] as const] : [] + ))) + + return (directory) => { + const running = runningByDirectory.get(directory) + const parsed = path.dirname(directory) === scheduleRoot ? parseScheduleWorktreeName(path.basename(directory)) : null + const jobId = parsed?.jobId ?? running?.jobId + if (jobId === undefined) return undefined + const repoId = running?.repoId ?? getScheduleJobRepoId(database, jobId) + if (repoId === null) return undefined + return { + repoId, + jobId, + runId: parsed ? parsed.runId : running?.id ?? null, + inUse: running !== undefined || (parsed?.runId === null && runningRuns.some((run) => run.jobId === jobId)), + } } } diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts index 8faf32451..3a118fcf7 100644 --- a/backend/src/services/schedule-config.ts +++ b/backend/src/services/schedule-config.ts @@ -6,6 +6,7 @@ import type { ScheduleMode, SchedulePermissionConfig, ScheduleSkillMetadata, + ScheduleWorkspaceMode, UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' @@ -26,6 +27,7 @@ export interface ScheduleJobPersistenceInput { permissionConfig: SchedulePermissionConfig | null mcpServers: ScheduleMcpServer[] branch: string | null + workspaceMode: ScheduleWorkspaceMode nextRunAt: number | null } @@ -101,6 +103,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ model: input.model?.trim() || null, skillMetadata: input.skillMetadata, branch: input.branch?.trim() || null, + workspaceMode: input.workspaceMode ?? 'worktree', } const scheduleConfig = input.scheduleMode === 'cron' @@ -162,6 +165,7 @@ export function buildUpdatedSchedulePersistenceInput( permissionConfig: input.permissionConfig === undefined ? existing.permissionConfig : (input.permissionConfig ?? null), mcpServers: input.mcpServers ?? existing.mcpServers, branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null), + workspaceMode: input.workspaceMode ?? existing.workspaceMode, nextRunAt, } } diff --git a/backend/src/services/schedule-worktree-paths.ts b/backend/src/services/schedule-worktree-paths.ts new file mode 100644 index 000000000..2aeda228b --- /dev/null +++ b/backend/src/services/schedule-worktree-paths.ts @@ -0,0 +1,30 @@ +import path from 'path' +import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' + +const SCHEDULE_WORKTREE_DIRECTORY = /^job-(\d+)-(?:run-(\d+)|shared)$/ + +export function getSharedScheduleWorktreePath(jobId: number): string { + return path.join(getScheduleWorktreesPath(), `job-${jobId}-shared`) +} + +export function getRunScheduleWorktreePath(jobId: number, runId: number): string { + return path.join(getScheduleWorktreesPath(), `job-${jobId}-run-${runId}`) +} + +export function getSharedScheduleBranch(jobId: number): string { + return `schedule/${jobId}/shared` +} + +export function getRunScheduleBranch(jobId: number, runId: number): string { + return `schedule/${jobId}/run-${runId}` +} + +/** + * Reads the job and run a schedule worktree directory name belongs to. `runId` is null + * for a schedule's shared worktree; non-schedule names return null. + */ +export function parseScheduleWorktreeName(name: string): { jobId: number; runId: number | null } | null { + const match = SCHEDULE_WORKTREE_DIRECTORY.exec(name) + if (!match) return null + return { jobId: Number(match[1]), runId: match[2] ? Number(match[2]) : null } +} diff --git a/backend/src/services/schedule-worktree.ts b/backend/src/services/schedule-worktree.ts index ec1fa7ce5..aa56c624d 100644 --- a/backend/src/services/schedule-worktree.ts +++ b/backend/src/services/schedule-worktree.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import path from 'path' import type { Database } from 'bun:sqlite' import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' +import type { ScheduleWorkspaceMode } from '@opencode-manager/shared/types' import type { Repo } from '../types/repo' import type { GitAuthService } from './git-auth' import { isSSHUrl } from '@opencode-manager/shared/utils' @@ -10,6 +11,13 @@ import { executeCommand } from '../utils/process' import { resolveDefaultBranch, createWorktreeSafely, removeWorktree } from './repo' import { logger } from '../utils/logger' import { mkdirSyncSafe } from '../utils/fs-safe' +import { + getRunScheduleBranch, + getRunScheduleWorktreePath, + getSharedScheduleBranch, + getSharedScheduleWorktreePath, + parseScheduleWorktreeName, +} from './schedule-worktree-paths' export interface ScheduleWorktreeContext { directory: string @@ -17,6 +25,20 @@ export interface ScheduleWorktreeContext { runBranch: string } +/** + * A schedule worktree directory found on disk. `runId` is null for a schedule's shared worktree. + */ +export interface ScheduleWorktreeEntry { + jobId: number + runId: number | null + worktreePath: string + branch: string +} + +function isSharedWorktreePath(worktreePath: string): boolean { + return path.basename(worktreePath).endsWith('-shared') +} + /** * Build repo-context environment variables used by the GIT_ASKPASS handler * to resolve repo-specific credentials. Mirrors GitService.getEnvironmentForRepo. @@ -34,12 +56,17 @@ export class ScheduleWorktreeManager { private readonly db: Database, ) {} + /** + * Prepares the directory a run works in. Returns null when the run works in the + * repository checkout itself. A shared worktree that already exists is reused as-is; + * otherwise a worktree is created, continuing the shared branch when it already exists. + */ async prepare( repo: Repo, - job: { id: number; branch: string | null }, + job: { id: number; branch: string | null; workspaceMode: ScheduleWorkspaceMode }, runId: number, ): Promise { - if (repo.id === ASSISTANT_REPO_ID) return null + if (repo.id === ASSISTANT_REPO_ID || job.workspaceMode === 'repo') return null try { await executeCommand(['git', '-C', repo.fullPath, 'rev-parse', '--is-inside-work-tree'], { silent: true }) @@ -47,6 +74,10 @@ export class ScheduleWorktreeManager { return null } + const shared = job.workspaceMode === 'shared-worktree' + const runBranch = shared ? getSharedScheduleBranch(job.id) : getRunScheduleBranch(job.id, runId) + const worktreePath = shared ? getSharedScheduleWorktreePath(job.id) : getRunScheduleWorktreePath(job.id, runId) + let sshSetup = false if (repo.repoUrl && isSSHUrl(repo.repoUrl)) { await this.gitAuthService.setupSSHForRepoUrl(repo.repoUrl, this.db) @@ -56,6 +87,10 @@ export class ScheduleWorktreeManager { try { const env = await this.buildGitEnv(repo, sshSetup, true) + if (shared && await this.isUsableWorktree(worktreePath, env)) { + return { directory: worktreePath, worktreePath, runBranch } + } + await executeCommand(['git', '-C', repo.fullPath, 'fetch', '--prune', 'origin'], { env }).catch(() => {}) const base = job.branch?.trim() || (await resolveDefaultBranch(repo.fullPath, env)) @@ -64,9 +99,10 @@ export class ScheduleWorktreeManager { throw new Error(`Base branch "${base}" was not found in this repository. Choose an existing branch in the schedule settings.`) } - const runBranch = `schedule/${job.id}/run-${runId}` + if (existsSync(worktreePath)) { + await removeWorktree(repo.fullPath, worktreePath, env) + } - const worktreePath = path.join(getScheduleWorktreesPath(), `job-${job.id}-run-${runId}`) mkdirSyncSafe(path.dirname(worktreePath)) await createWorktreeSafely(repo.fullPath, worktreePath, runBranch, env, baseRef) @@ -82,15 +118,21 @@ export class ScheduleWorktreeManager { } } + /** + * Commits a finished run's changes to its branch. A `worktree` run's worktree is then + * removed, along with its branch when nothing was committed. Kept and shared worktrees + * stay on disk on their branch so the work can be continued. + */ async finalize( repo: Repo, - job: { id: number; name: string; prompt: string }, + job: { id: number; name: string; prompt: string; workspaceMode: ScheduleWorkspaceMode }, run: { id: number; worktreePath: string | null; runBranch: string | null; triggerSource: string }, ): Promise<{ commitHash: string | null }> { if (!run.worktreePath) { return { commitHash: null } } + const retain = isSharedWorktreePath(run.worktreePath) || job.workspaceMode === 'kept-worktree' let sshSetup = false let env: Record | undefined let commitHash: string | null = null @@ -103,33 +145,31 @@ export class ScheduleWorktreeManager { env = await this.buildGitEnv(repo, sshSetup, false) - const status = await executeCommand(['git', '-C', run.worktreePath, 'status', '--porcelain'], { env }).catch(() => '') - - if (status.trim()) { - await executeCommand(['git', '-C', run.worktreePath, 'add', '-A'], { env }) - - const title = `Scheduled run: ${job.name} (run #${run.id})` - const promptSummary = job.prompt.length > 200 ? `${job.prompt.slice(0, 200)}...` : job.prompt - const body = `Trigger: ${run.triggerSource}\nPrompt: ${promptSummary}` - await executeCommand(['git', '-C', run.worktreePath, 'commit', '-m', title, '-m', body], { env }) + const promptSummary = job.prompt.length > 200 ? `${job.prompt.slice(0, 200)}...` : job.prompt + commitHash = await this.commitPendingChanges( + run.worktreePath, + env, + `Scheduled run: ${job.name} (run #${run.id})`, + `Trigger: ${run.triggerSource}\nPrompt: ${promptSummary}`, + ) - commitHash = (await executeCommand(['git', '-C', run.worktreePath, 'rev-parse', 'HEAD'], { env })).trim() + if (!retain) { + await executeCommand(['git', '-C', run.worktreePath, 'checkout', '--detach'], { env }).catch(() => {}) } - // Detach HEAD to protect the run branch from accidental pushes - await executeCommand(['git', '-C', run.worktreePath, 'checkout', '--detach'], { env }).catch(() => {}) - return { commitHash } } catch (error) { logger.error(`Failed to finalize schedule run ${run.id} in worktree ${run.worktreePath}:`, error) throw error } finally { - await removeWorktree(repo.fullPath, run.worktreePath, env).catch((error) => { - logger.error(`Failed to remove worktree ${run.worktreePath}:`, error) - }) + if (!retain) { + await removeWorktree(repo.fullPath, run.worktreePath, env).catch((error) => { + logger.error(`Failed to remove worktree ${run.worktreePath}:`, error) + }) - if (run.runBranch && !commitHash) { - await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', run.runBranch], env ? { env } : undefined).catch(() => undefined) + if (run.runBranch && !commitHash) { + await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', run.runBranch], env ? { env } : undefined).catch(() => undefined) + } } if (sshSetup) { @@ -138,33 +178,102 @@ export class ScheduleWorktreeManager { } } + /** + * Removes a kept or shared worktree. Uncommitted changes are committed to its branch + * first and the branch is kept, so no work is lost; if that commit fails the worktree + * is left in place. + */ + async releaseWorktree(repo: Repo, job: { name: string }, worktreePath: string): Promise { + const env = await this.buildGitEnv(repo, false, true) + + if (existsSync(worktreePath)) { + await this.commitPendingChanges(worktreePath, env, `Schedule worktree removed: ${job.name}`, 'Uncommitted changes saved before the worktree was removed.') + } + + await removeWorktree(repo.fullPath, worktreePath, env) + } + + /** + * Lists the schedule worktree directories on disk, optionally for a single job. + */ + listWorktrees(jobId?: number): ScheduleWorktreeEntry[] { + let names: string[] + try { + names = readdirSync(getScheduleWorktreesPath()) + } catch { + return [] + } + + return names.flatMap((name) => { + const parsed = parseScheduleWorktreeName(name) + if (!parsed || (jobId !== undefined && parsed.jobId !== jobId)) return [] + return [{ + jobId: parsed.jobId, + runId: parsed.runId, + worktreePath: path.join(getScheduleWorktreesPath(), name), + branch: parsed.runId === null ? getSharedScheduleBranch(parsed.jobId) : getRunScheduleBranch(parsed.jobId, parsed.runId), + }] + }) + } + /** * Removes leftover worktrees and deletes the run branches for a set of - * finished runs. Used when clearing run history. Branch and worktree removal - * are local git operations, so no SSH setup is needed; failures are swallowed - * per artifact so one bad entry does not block the rest. + * finished runs. Used when clearing run history. The job's shared worktree and + * branch are never touched, since they belong to the schedule rather than a run. + * Branch and worktree removal are local git operations, so no SSH setup is needed; + * failures are swallowed per artifact so one bad entry does not block the rest. */ async pruneRunArtifacts( repo: Repo, + jobId: number, artifacts: { runBranch: string | null; worktreePath: string | null }[], ): Promise { - if (artifacts.length === 0) return + const sharedPath = getSharedScheduleWorktreePath(jobId) + const sharedBranch = getSharedScheduleBranch(jobId) + const worktreePaths = artifacts + .map((a) => a.worktreePath) + .filter((p): p is string => p !== null && p !== sharedPath) + const branches = artifacts + .map((a) => a.runBranch) + .filter((b): b is string => b !== null && b.length > 0 && b !== sharedBranch) + + if (worktreePaths.length === 0 && branches.length === 0) return const env = await this.buildGitEnv(repo, false, true) await Promise.all( - artifacts.map(async (artifact) => { - if (!artifact.worktreePath) return - await removeWorktree(repo.fullPath, artifact.worktreePath, env).catch(() => undefined) - }), + worktreePaths.map((worktreePath) => removeWorktree(repo.fullPath, worktreePath, env).catch(() => undefined)), ) - const branches = artifacts.map((a) => a.runBranch).filter((b): b is string => b !== null && b.length > 0) if (branches.length > 0) { await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', ...branches], { env }).catch(() => {}) } } + private async commitPendingChanges( + worktreePath: string, + env: Record, + title: string, + body: string, + ): Promise { + const status = await executeCommand(['git', '-C', worktreePath, 'status', '--porcelain'], { env }).catch(() => '') + if (!status.trim()) return null + + await executeCommand(['git', '-C', worktreePath, 'add', '-A'], { env }) + await executeCommand(['git', '-C', worktreePath, 'commit', '-m', title, '-m', body], { env }) + return (await executeCommand(['git', '-C', worktreePath, 'rev-parse', 'HEAD'], { env })).trim() + } + + private async isUsableWorktree(worktreePath: string, env: Record): Promise { + if (!existsSync(path.join(worktreePath, '.git'))) return false + try { + await executeCommand(['git', '-C', worktreePath, 'rev-parse', '--is-inside-work-tree'], { env, silent: true }) + return true + } catch { + return false + } + } + /** * Resolves a user-supplied base branch name to a verified git ref, preferring * the remote-tracking branch for freshness. Returns null when neither the diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 12ab0952f..9b35022e5 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs' +import path from 'path' import type { Database } from 'bun:sqlite' import { Cron } from 'croner' import { @@ -5,6 +7,7 @@ import { type ScheduleJob, type ScheduleRun, type ScheduleRunTriggerSource, + type ScheduleWorktree, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' import { mcpStatusByName, openCodeLocation } from '@opencode-manager/shared/opencode' @@ -13,6 +16,7 @@ import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' import { cleanupOrphanedSchedules, + clearScheduleRunWorktreePath, createScheduleJob, createScheduleRun, deleteScheduleJob, @@ -53,6 +57,7 @@ import type { Repo } from '../types/repo' import { sseAggregator, type ScheduledSessionRef } from './sse-aggregator' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' +import { canonicalPathSync } from '../utils/fs-safe' import { buildAssistantRepo } from './assistant-mode' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' @@ -319,8 +324,72 @@ export class ScheduleService { return listEnabledScheduleJobs(this.db) } - listAllJobsWithRepos(): ScheduleJobWithRepo[] { - return listAllScheduleJobsWithRepos(this.db) + listAllJobsWithRepos(): Array { + const runningPaths = this.listRunningWorktreePaths() + const counts = new Map() + for (const entry of this.worktreeManager.listWorktrees()) { + if (runningPaths.has(entry.worktreePath)) continue + counts.set(entry.jobId, (counts.get(entry.jobId) ?? 0) + 1) + } + return listAllScheduleJobsWithRepos(this.db).map((job) => ({ ...job, retainedWorktreeCount: counts.get(job.id) ?? 0 })) + } + + /** + * Lists the worktrees of a schedule that still exist on disk: kept run worktrees, + * the shared worktree, and the worktree of a run in progress. + */ + listWorktrees(repoId: number, jobId: number): ScheduleWorktree[] { + this.assertJob(repoId, jobId) + const runningPaths = this.listRunningWorktreePaths() + const jobRunning = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, repoId, jobId) !== null + + return this.worktreeManager.listWorktrees(jobId) + .map((entry) => ({ + worktreePath: entry.worktreePath, + branch: entry.branch, + runId: entry.runId, + inUse: runningPaths.has(entry.worktreePath) || (entry.runId === null && jobRunning), + })) + .sort((left, right) => (right.runId ?? Number.MAX_SAFE_INTEGER) - (left.runId ?? Number.MAX_SAFE_INTEGER)) + } + + /** + * Removes one of a schedule's worktrees, or every worktree not in use when no path is + * given. Pending changes are committed to each worktree's branch first and branches are + * kept, so a shared schedule continues from its branch on the next run. + */ + async removeWorktrees(repoId: number, jobId: number, worktreePath?: string): Promise<{ removed: number }> { + const repo = this.assertRepo(repoId) + const job = this.assertJob(repoId, jobId) + const worktrees = this.listWorktrees(repoId, jobId) + + let targets = worktrees.filter((worktree) => !worktree.inUse) + if (worktreePath !== undefined) { + const requestedPath = canonicalPathSync(path.resolve(worktreePath)) + const target = worktrees.find((worktree) => canonicalPathSync(worktree.worktreePath) === requestedPath) + if (!target) { + throw new ScheduleServiceError('Worktree not found for this schedule', 404) + } + if (target.inUse) { + throw new ScheduleServiceError('This worktree is in use by a running run. Cancel the run first.', 409) + } + targets = [target] + } + + for (const target of targets) { + try { + await this.worktreeManager.releaseWorktree(repo, job, target.worktreePath) + } catch (error) { + throw new ScheduleServiceError(`Failed to remove worktree ${target.worktreePath}: ${getErrorMessage(error)}`, 500) + } + clearScheduleRunWorktreePath(this.db, repoId, jobId, target.worktreePath) + } + + return { removed: targets.length } + } + + private listRunningWorktreePaths(): Set { + return new Set(listRunningScheduleRuns(this.db).flatMap((run) => (run.worktreePath ? [run.worktreePath] : []))) } listAllRuns(options: ListAllRunsOptions = {}): ScheduleRunWithContext[] { @@ -406,7 +475,7 @@ export class ScheduleService { return job } - deleteJob(repoId: number, jobId: number): void { + async deleteJob(repoId: number, jobId: number): Promise { this.assertRepo(repoId) this.assertJob(repoId, jobId) @@ -419,6 +488,8 @@ export class ScheduleService { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } + await this.removeWorktrees(repoId, jobId) + const deleted = deleteScheduleJob(this.db, repoId, jobId) if (!deleted) { throw new ScheduleServiceError('Schedule not found', 404) @@ -483,7 +554,7 @@ export class ScheduleService { return { cleared: 0 } } - await this.worktreeManager.pruneRunArtifacts(repo, removable) + await this.worktreeManager.pruneRunArtifacts(repo, jobId, removable) const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id)) return { cleared } } @@ -501,7 +572,7 @@ export class ScheduleService { throw new ScheduleServiceError('Cannot delete a run while it is in progress. Cancel it first.', 409) } - await this.worktreeManager.pruneRunArtifacts(repo, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }]) + await this.worktreeManager.pruneRunArtifacts(repo, jobId, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }]) const deleted = deleteScheduleRunById(this.db, repoId, jobId, runId) if (!deleted) { throw new ScheduleServiceError('Run not found', 404) @@ -1109,17 +1180,19 @@ export class ScheduleService { try { const fresh = getScheduleRunById(this.db, repoId, jobId, runId) if (!fresh?.worktreePath) return + const worktreePath = fresh.worktreePath + const remainingWorktreePath = () => (existsSync(worktreePath) ? worktreePath : null) try { const { commitHash } = await this.worktreeManager.finalize(repo, job, { id: runId, - worktreePath: fresh.worktreePath, + worktreePath, runBranch: fresh.runBranch, triggerSource: fresh.triggerSource, }) - updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: null, commitHash }) + updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath(), commitHash }) } catch (error) { logger.error(`Failed to finalize worktree for run ${runId}:`, error) - updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: null }) + updateScheduleRunWorktree(this.db, repoId, jobId, runId, { worktreePath: remainingWorktreePath() }) } } finally { ScheduleService.activeTeardowns.delete(key) diff --git a/backend/test/db/migration-runner.test.ts b/backend/test/db/migration-runner.test.ts index 643cebc57..9051ff50c 100644 --- a/backend/test/db/migration-runner.test.ts +++ b/backend/test/db/migration-runner.test.ts @@ -1,53 +1,123 @@ import { Database } from 'bun:sqlite' import { afterEach, describe, expect, it, vi } from 'vitest' import { migrate, type Migration } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' import { logger } from '../../src/utils/logger' -function makeMigration(version: number, name: string, up: () => void): Migration { - return { version, name, up, down: () => {} } +function makeMigration(id: string, up: () => void = vi.fn(), legacy?: Migration['legacy']): Migration { + return { id, legacy, up, down: () => {} } } -describe('migrate - version/name mismatch guard', () => { +function createLegacyTable(db: Database, rows: Array<[number, string]>): void { + db.run('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at INTEGER NOT NULL)') + rows.forEach(([version, name]) => { + db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, 0)').run(version, name) + }) +} + +function appliedIds(db: Database): string[] { + return (db.prepare('SELECT id FROM applied_migrations ORDER BY id').all() as Array<{ id: string }>).map((row) => row.id) +} + +describe('migrate', () => { afterEach(() => { vi.restoreAllMocks() }) - it('warns and skips when an applied version was recorded under a different name', () => { + it('applies pending migrations in list order and records them by id', () => { const db = new Database(':memory:') - db.run('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at INTEGER NOT NULL)') - db.run("INSERT INTO schema_migrations (version, name, applied_at) VALUES (15, 'repos-add-name', 0)") + const order: string[] = [] - const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + migrate(db, [ + makeMigration('202610061345-second', () => order.push('second')), + makeMigration('202610051200-first', () => order.push('first')), + ]) + + expect(order).toEqual(['second', 'first']) + expect(appliedIds(db)).toEqual(['202610051200-first', '202610061345-second']) + }) + + it('does not run a migration twice', () => { + const db = new Database(':memory:') const up = vi.fn() - migrate(db, [makeMigration(15, 'schedule-worktree-isolation', up)]) + migrate(db, [makeMigration('202610061345-once', up)]) + migrate(db, [makeMigration('202610061345-once', up)]) - expect(up).not.toHaveBeenCalled() - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Migration version 15 is recorded as "repos-add-name" but the code defines "schedule-worktree-isolation"'), - ) + expect(up).toHaveBeenCalledTimes(1) }) - it('does not warn when recorded names match', () => { + it('runs a migration whose old version number another branch already used', () => { const db = new Database(':memory:') - db.run('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at INTEGER NOT NULL)') - db.run("INSERT INTO schema_migrations (version, name, applied_at) VALUES (15, 'schedule-worktree-isolation', 0)") + createLegacyTable(db, [[28, 'remote-devices']]) + const up = vi.fn() + + migrate(db, [makeMigration('202610061345-schedule-workspace-mode', up)]) + expect(up).toHaveBeenCalledTimes(1) + }) + + it('adopts numbered migrations recorded in the old version-keyed table', () => { + const db = new Database(':memory:') + createLegacyTable(db, [[15, 'schedule-worktree-isolation']]) const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const up = vi.fn() - migrate(db, [makeMigration(15, 'schedule-worktree-isolation', vi.fn())]) + migrate(db, [makeMigration('015-schedule-worktree-isolation', up, { version: 15, name: 'schedule-worktree-isolation' })]) + expect(up).not.toHaveBeenCalled() + expect(appliedIds(db)).toEqual(['015-schedule-worktree-isolation']) expect(warnSpy).not.toHaveBeenCalled() }) - it('applies pending migrations and records them', () => { + it('warns and skips when a legacy version was recorded under a different name', () => { const db = new Database(':memory:') + createLegacyTable(db, [[15, 'repos-add-name']]) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) const up = vi.fn() - migrate(db, [makeMigration(1, 'base', up)]) + migrate(db, [makeMigration('015-schedule-worktree-isolation', up, { version: 15, name: 'schedule-worktree-isolation' })]) - expect(up).toHaveBeenCalledTimes(1) - const row = db.prepare('SELECT version, name FROM schema_migrations WHERE version = 1').get() as { version: number; name: string } - expect(row).toEqual({ version: 1, name: 'base' }) + expect(up).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Migration version 15 is recorded as "repos-add-name" but the code defines "schedule-worktree-isolation"'), + ) + }) + + it('rejects duplicate ids before touching the database', () => { + const db = new Database(':memory:') + const up = vi.fn() + + expect(() => migrate(db, [makeMigration('202610061345-a', up), makeMigration('202610061345-a', up)])).toThrow('Duplicate migration id') + expect(up).not.toHaveBeenCalled() + }) + + it('rolls back and stops when a migration fails', () => { + const db = new Database(':memory:') + vi.spyOn(logger, 'error').mockImplementation(() => {}) + const later = vi.fn() + + expect(() => migrate(db, [ + makeMigration('202610061345-broken', () => { + db.run('CREATE TABLE partial (id INTEGER)') + throw new Error('boom') + }), + makeMigration('202610061346-later', later), + ])).toThrow('boom') + + expect(later).not.toHaveBeenCalled() + expect(appliedIds(db)).toEqual([]) + expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'partial'").get()).toBeFalsy() + }) +}) + +describe('allMigrations', () => { + it('uses timestamp ids for every migration added after the numbered ones', () => { + const unnumbered = allMigrations.filter((migration) => !migration.legacy) + + expect(unnumbered.length).toBeGreaterThan(0) + unnumbered.forEach((migration) => { + expect(migration.id).toMatch(/^\d{12}-[a-z0-9]+(?:-[a-z0-9]+)*$/) + }) }) }) diff --git a/backend/test/db/opencode-config-migration.test.ts b/backend/test/db/opencode-config-migration.test.ts index 373c5e0a0..194dfca78 100644 --- a/backend/test/db/opencode-config-migration.test.ts +++ b/backend/test/db/opencode-config-migration.test.ts @@ -22,7 +22,7 @@ const originalHome = process.env.HOME const originalImportConfigPath = process.env.OPENCODE_IMPORT_CONFIG_PATH function migrateToV18(db: Database): void { - migrate(db, allMigrations.filter(migration => migration.version < 19)) + migrate(db, allMigrations.filter(migration => (migration.legacy?.version ?? Infinity) < 19)) } function insertConfig(db: Database, name: string, content: string, isDefault: boolean): void { diff --git a/backend/test/db/opencode-model-state-migration.test.ts b/backend/test/db/opencode-model-state-migration.test.ts index 2bcf24b2c..d6f2023ff 100644 --- a/backend/test/db/opencode-model-state-migration.test.ts +++ b/backend/test/db/opencode-model-state-migration.test.ts @@ -23,7 +23,7 @@ interface ModelStateRow { } function migrateToV19(db: Database): void { - migrate(db, allMigrations.filter(migration => migration.version < 20)) + migrate(db, allMigrations.filter(migration => (migration.legacy?.version ?? Infinity) < 20)) } function insertRow(db: Database, state: ModelStateRow, updatedAt: number): void { diff --git a/backend/test/db/schedules.permission.test.ts b/backend/test/db/schedules.permission.test.ts index 7f102710a..3bb6e6d7e 100644 --- a/backend/test/db/schedules.permission.test.ts +++ b/backend/test/db/schedules.permission.test.ts @@ -35,6 +35,7 @@ describe('schedule permission config persistence', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now() + 3600000, ...overrides, }) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 95da50b35..f56c3011f 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -116,6 +116,7 @@ describe('schedule database queries', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), }) @@ -135,6 +136,7 @@ describe('schedule database queries', () => { null, null, null, + 'worktree', expect.any(Number), expect.any(Number), null, @@ -174,6 +176,7 @@ describe('schedule database queries', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: null, }) @@ -192,6 +195,7 @@ describe('schedule database queries', () => { null, null, null, + 'worktree', expect.any(Number), null, 42, @@ -668,19 +672,12 @@ describe('schedule database queries', () => { ]) }) - it('listActiveScheduleRunWorktreePaths returns only non-null worktree paths', () => { - const stmt = { - all: vi.fn().mockReturnValue([ - { worktree_path: '/wt/2' }, - { worktree_path: '/wt/9' }, - ]), - } + it('getScheduleJobRepoId returns the owning repo or null for an unknown job', () => { + const stmt = { get: vi.fn().mockReturnValueOnce({ repo_id: 42 }).mockReturnValueOnce(undefined) } mockDb.prepare.mockReturnValue(stmt) - const paths = schedulesDb.listActiveScheduleRunWorktreePaths(mockDb) - - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT worktree_path FROM schedule_runs WHERE worktree_path IS NOT NULL') - expect(paths).toEqual(['/wt/2', '/wt/9']) + expect(schedulesDb.getScheduleJobRepoId(mockDb, 7)).toBe(42) + expect(schedulesDb.getScheduleJobRepoId(mockDb, 8)).toBeNull() }) it('deleteScheduleRunById deletes a single run row', () => { diff --git a/backend/test/db/schema.test.ts b/backend/test/db/schema.test.ts index 9e471b942..1054a972e 100644 --- a/backend/test/db/schema.test.ts +++ b/backend/test/db/schema.test.ts @@ -21,9 +21,9 @@ describe('initializeDatabase', () => { it('creates the database file, applies migrations, and seeds defaults', async () => { const db = initializeDatabase(dbPath) - const migrations = db.prepare('SELECT name FROM schema_migrations ORDER BY version ASC').all() as Array<{ name: string }> - expect(migrations.map(migration => migration.name)).toContain('base-schema') - expect(migrations.map(migration => migration.name)).toContain('drop-opencode-configs') + const migrations = db.prepare('SELECT id AS name FROM applied_migrations').all() as Array<{ name: string }> + expect(migrations.map(migration => migration.name)).toContain('001-base-schema') + expect(migrations.map(migration => migration.name)).toContain('019-drop-opencode-configs') const preferences = db.prepare('SELECT preferences FROM user_preferences WHERE user_id = ?').get('default') as { preferences: string } expect(preferences.preferences).toBe('{}') diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts index b28c2def7..dc42d4994 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -26,7 +26,7 @@ describe('internal-schedules routes', () => { db = new Database(':memory:') migrate(db, allMigrations) const openCodeClient = createOpenCodeClient() - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager + const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }), listWorktrees: () => [] } as unknown as ScheduleWorktreeManager scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) notificationService = new NotificationService(db) settingsService = new SettingsService(db) diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index bb9342f4b..bbb780455 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -110,7 +110,7 @@ const mockTerminalService = { function createTestRoutes(openCodeClient: ReturnType = createStubOpenCodeClient()): ReturnType { const projectConfigService = new ProjectConfigService(mockDb, createGitService(mockGitAuthService), mockGitAuthService) - const repoWorkspaces = new RepoWorkspaceService(mockDb, openCodeClient, mockGitAuthService, projectConfigService, mockTerminalService) + const repoWorkspaces = new RepoWorkspaceService(mockDb, openCodeClient, mockGitAuthService, projectConfigService, mockTerminalService, mockScheduleService) return createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, openCodeClient, mockTerminalService, projectConfigService, repoWorkspaces) } @@ -505,6 +505,39 @@ describe('Repo Routes', () => { expect(await res.json()).toEqual({ directory: '/tmp/wrk-test', worktreeSetup: { status: 'none' } }) }) + it('passes a requested worktree name to OpenCode', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, fullPath: '/tmp/repos/test-repo' })) + const client = createStubOpenCodeClient() + const create = vi.fn(async () => ({ directory: '/tmp/feature-login' })) + client.api.worktree.create = create as never + + const res = await createTestRoutes(client).request('/1/workspaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: ' feature-login ' }), + }) + + expect(res.status).toBe(200) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ name: 'feature-login' })) + }) + + it('rejects a worktree name that is not a single folder name', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, fullPath: '/tmp/repos/test-repo' })) + const client = createStubOpenCodeClient() + const create = vi.fn(async () => ({ directory: '/tmp/x' })) + client.api.worktree.create = create as never + + const res = await createTestRoutes(client).request('/1/workspaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '../escape' }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Directory name cannot contain dot-dot path segments' }) + expect(create).not.toHaveBeenCalled() + }) + it('returns 200 with a failed worktree setup when the setup terminal cannot start', async () => { vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, fullPath: '/tmp/repos/test-repo' })) vi.mocked(db.getRepoSetting).mockImplementation((_database, _repoId, key) => @@ -880,7 +913,7 @@ describe('Repo Routes', () => { vi.mocked(repoService.deleteRepoFiles).mockResolvedValue(undefined) vi.mocked(repoService.getSiblingRepos).mockResolvedValue([ { ...createMockRepo({ id: 2, fullPath: '/tmp/repos/manager-worktree', isWorktree: true }), currentBranch: undefined }, - { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeStrategy: 'git' }, + { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeStrategy: 'git', worktreeSource: 'opencode' }, ]) const app = createTestRoutes() diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index 0e7ace3ea..cd58e27f5 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -683,7 +683,7 @@ describe('assistant-mode end-to-end', () => { const token = getOrCreateInternalToken(db) - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager + const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }), listWorktrees: () => [] } as unknown as ScheduleWorktreeManager const scheduleService = new ScheduleService(db, createOpenCodeClient(), stubWorktreeManager) const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts index f3a45adfa..bc63233f1 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -43,6 +43,7 @@ function scheduleJobInput(name: string, branch: string): ScheduleJobPersistenceI skillMetadata: null, permissionConfig: null, mcpServers: [], + workspaceMode: 'worktree', branch, nextRunAt: null, } diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index d9768327a..4e287a4e3 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:https' import { tmpdir } from 'node:os' import path from 'node:path' @@ -16,7 +16,6 @@ import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' -type SiblingRepo = Repo & { currentBranch: string | undefined; worktreeStrategy?: string } const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'repo-git-')) process.env.WORKSPACE_PATH = workspaceRoot @@ -923,7 +922,7 @@ describe('repo service real git', () => { expect(siblings.every((repo) => repo.currentBranch === 'main')).toBe(true) }) - it('adds filtered workspace siblings from the OpenCode client', async () => { + it('lists OpenCode, schedule, and plain git worktrees as worktree siblings', async () => { const { getSiblingRepos } = await import('../../src/services/repo') const origin = path.join(workspaceRoot, uniqueName('sibling-ws-origin.git')) const work = path.join(workspaceRoot, uniqueName('sibling-ws-work')) @@ -953,15 +952,27 @@ describe('repo service real git', () => { const projectId = (await resolveOpenCodeProjectId(repoA))! const extraDir = path.join(workspaceRoot, uniqueName('sibling-ws-extra')) const duplicateDir = path.join(workspaceRoot, uniqueName('sibling-ws-duplicate')) - const activeWorktree = path.join(workspaceRoot, uniqueName('sibling-ws-active')) - const activeWorkspace = path.join(workspaceRoot, uniqueName('sibling-ws-active-dir')) + const unownedDir = path.join(workspaceRoot, uniqueName('sibling-ws-unowned')) mkdirSync(extraDir, { recursive: true }) mkdirSync(duplicateDir, { recursive: true }) - mkdirSync(activeWorktree, { recursive: true }) - mkdirSync(activeWorkspace, { recursive: true }) - const scheduleDir = path.join(getScheduleWorktreesPath(), uniqueName('sibling-ws-schedule')) - db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(1, a.id, 'manual', 'running', Date.now(), Date.now(), activeWorktree) - db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(1, a.id, 'manual', 'running', Date.now(), Date.now(), activeWorkspace) + mkdirSync(unownedDir, { recursive: true }) + + const jobId = Number(db.prepare('INSERT INTO schedule_jobs (repo_id, name, prompt, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(a.id, 'nightly', 'check', Date.now(), Date.now()).lastInsertRowid) + const keptDir = path.join(getScheduleWorktreesPath(), `job-${jobId}-run-3`) + const sharedDir = path.join(getScheduleWorktreesPath(), `job-${jobId}-shared`) + const orphanDir = path.join(getScheduleWorktreesPath(), 'job-999999-run-1') + const manualDir = path.join(workspaceRoot, uniqueName('sibling-ws-manual')) + mkdirSync(getScheduleWorktreesPath(), { recursive: true }) + git(['worktree', 'add', '-b', `schedule/${jobId}/run-3`, keptDir], repoA) + git(['worktree', 'add', '-b', `schedule/${jobId}/shared`, sharedDir], repoA) + git(['worktree', 'add', '-b', 'schedule/999999/run-1', orphanDir], repoA) + git(['worktree', 'add', '-b', 'manual-work', manualDir], repoA) + db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(jobId, a.id, 'manual', 'running', Date.now(), Date.now(), sharedDir) + const keptPath = realpathSync(keptDir) + const sharedPath = realpathSync(sharedDir) + const orphanPath = realpathSync(orphanDir) + const manualPath = realpathSync(manualDir) + const client = { api: { location: { @@ -974,22 +985,25 @@ describe('repo service real git', () => { { directory: duplicateDir, strategy: 'git' }, { directory: repoA, strategy: 'git' }, { directory: getReposPath(), strategy: 'git' }, - { directory: scheduleDir, strategy: 'git' }, - { directory: activeWorktree, strategy: 'git' }, - { directory: activeWorkspace, strategy: 'git' }, + { directory: unownedDir }, ], }, }, } as unknown as OpenCodeClient - const siblings = await getSiblingRepos(db, a.id, {}, client) as SiblingRepo[] + const siblings = await getSiblingRepos(db, a.id, {}, client) const worktreeSiblings = siblings.filter((repo) => isWorktreeSibling(repo)) + const findSibling = (directory: string) => worktreeSiblings.find((repo) => repo.fullPath === directory) - expect(worktreeSiblings.map((repo) => repo.fullPath)).toEqual([extraDir, duplicateDir]) - expect(worktreeSiblings.every((repo) => repo.id === -1)).toBe(true) - expect(worktreeSiblings.every((repo) => repo.localPath === path.basename(repo.fullPath))).toBe(true) - expect(worktreeSiblings.every((repo) => repo.worktreeStrategy === 'git')).toBe(true) - expect(worktreeSiblings.every((repo) => repo.branch === undefined && repo.currentBranch === undefined)).toBe(true) + expect(worktreeSiblings.map((repo) => repo.fullPath).sort()).toEqual([extraDir, duplicateDir, keptPath, sharedPath, orphanPath, manualPath].sort()) + expect(siblings.some((repo) => repo.fullPath === unownedDir && repo.worktreeSource === undefined)).toBe(true) + expect(worktreeSiblings.every((repo) => repo.id === -1 && repo.localPath === path.basename(repo.fullPath))).toBe(true) + expect(findSibling(extraDir)).toMatchObject({ worktreeSource: 'opencode', worktreeStrategy: 'git', branch: undefined }) + expect(findSibling(keptPath)).toMatchObject({ worktreeSource: 'schedule', branch: `schedule/${jobId}/run-3`, schedule: { repoId: a.id, jobId, runId: 3, inUse: false } }) + expect(findSibling(sharedPath)).toMatchObject({ worktreeSource: 'schedule', schedule: { repoId: a.id, jobId, runId: null, inUse: true } }) + expect(findSibling(orphanPath)).toMatchObject({ worktreeSource: 'git', branch: 'schedule/999999/run-1' }) + expect(findSibling(orphanPath)?.schedule).toBeUndefined() + expect(findSibling(manualPath)).toMatchObject({ worktreeSource: 'git', currentBranch: 'manual-work' }) }) it('returns repo siblings when the OpenCode client fails', async () => { @@ -1021,7 +1035,7 @@ describe('repo service real git', () => { }, } as unknown as OpenCodeClient - const siblings = await getSiblingRepos(db, a.id, {}, client) as SiblingRepo[] + const siblings = await getSiblingRepos(db, a.id, {}, client) expect(siblings.some((repo) => repo.id === a.id)).toBe(true) expect(siblings.some((repo) => isWorktreeSibling(repo))).toBe(false) diff --git a/backend/test/services/repo-working-directory.test.ts b/backend/test/services/repo-working-directory.test.ts index 0d0554717..9d0a005dd 100644 --- a/backend/test/services/repo-working-directory.test.ts +++ b/backend/test/services/repo-working-directory.test.ts @@ -145,7 +145,7 @@ describe('getSiblingRepos branch resolution', () => { expect(siblings).toHaveLength(1) expect(siblings[0]?.currentBranch).toBeUndefined() - expect(executeCommand).not.toHaveBeenCalled() + expect(executeCommand.mock.calls.map(([args]) => args)).toEqual([['git', '-C', repo.fullPath, 'worktree', 'list', '--porcelain']]) }) it('resolves the branch when includeBranch is true', async () => { diff --git a/backend/test/services/repo-workspace.test.ts b/backend/test/services/repo-workspace.test.ts index ba42b8468..e445b9ec4 100644 --- a/backend/test/services/repo-workspace.test.ts +++ b/backend/test/services/repo-workspace.test.ts @@ -69,6 +69,7 @@ function createService(overrides: { } as unknown as OpenCodeClient const projectConfigService = { runWorktreeSetupForRepo } as unknown as ProjectConfigService const terminalService = { removeAll } as unknown as TerminalService + const removeScheduleWorktrees = vi.fn(async () => ({ removed: 1 })) return { service: new RepoWorkspaceService( @@ -77,7 +78,9 @@ function createService(overrides: { gitAuthService, projectConfigService, terminalService, + { removeWorktrees: removeScheduleWorktrees }, ), + removeScheduleWorktrees, worktreeCreate, worktreeRemove, runWorktreeSetupForRepo, @@ -128,7 +131,7 @@ describe('RepoWorkspaceService', () => { it('throws a 400 when the directory is not a worktree sibling and touches nothing', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeStrategy: 'git' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) const error = await service.remove(REPO, '/worktrees/unknown').catch((caught: unknown) => caught) @@ -142,7 +145,7 @@ describe('RepoWorkspaceService', () => { it('removes terminals for the matched sibling before removing the worktree', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await service.remove(REPO, '/worktrees/feature-x') @@ -161,7 +164,7 @@ describe('RepoWorkspaceService', () => { const removeAll = vi.fn(async () => { throw new Error('pty cleanup failed') }) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await expect(service.remove(REPO, '/worktrees/feature-x')).resolves.toBeUndefined() @@ -173,14 +176,54 @@ describe('RepoWorkspaceService', () => { force: true, }) }) + it('removes a schedule worktree through its schedule instead of OpenCode', async () => { + const worktreeRemove = vi.fn(async () => undefined) + mocks.getSiblingRepos.mockResolvedValue([{ + fullPath: '/schedule-worktrees/job-7-shared', + worktreeSource: 'schedule', + schedule: { repoId: 3, jobId: 7, runId: null, inUse: false }, + }]) + const { service, removeScheduleWorktrees } = createService({ worktreeRemove }) + + await service.remove(REPO, '/schedule-worktrees/job-7-shared') + + expect(removeScheduleWorktrees).toHaveBeenCalledWith(3, 7, '/schedule-worktrees/job-7-shared') + expect(worktreeRemove).not.toHaveBeenCalled() + }) + + it('refuses to remove a schedule worktree a running run is using', async () => { + const removeAll = vi.fn(async () => undefined) + mocks.getSiblingRepos.mockResolvedValue([{ + fullPath: '/schedule-worktrees/job-7-run-2', + worktreeSource: 'schedule', + schedule: { repoId: 1, jobId: 7, runId: 2, inUse: true }, + }]) + const { service, removeScheduleWorktrees } = createService({ removeAll }) + + await expect(service.remove(REPO, '/schedule-worktrees/job-7-run-2')).rejects.toThrow('in use by a running scheduled run') + expect(removeAll).not.toHaveBeenCalled() + expect(removeScheduleWorktrees).not.toHaveBeenCalled() + }) + + it('reports a plain git worktree that git refuses to remove', async () => { + const worktreeRemove = vi.fn(async () => undefined) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/manual', worktreeSource: 'git' }]) + const { service } = createService({ worktreeRemove }) + + const error = await service.remove(REPO, '/worktrees/manual').catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(RepoWorkspaceError) + expect((error as Error).message).toMatch(/^Could not remove worktree/) + expect(worktreeRemove).not.toHaveBeenCalled() + }) }) describe('removeRepoTerminals', () => { it('removes terminals for the repo directory and each worktree sibling', async () => { const removeAll = vi.fn(async () => undefined) mocks.getSiblingRepos.mockResolvedValue([ - { fullPath: '/worktrees/a', worktreeStrategy: 'git' }, - { fullPath: '/worktrees/b', worktreeStrategy: 'git' }, + { fullPath: '/worktrees/a', worktreeStrategy: 'git', worktreeSource: 'opencode' }, + { fullPath: '/worktrees/b', worktreeStrategy: 'git', worktreeSource: 'opencode' }, { fullPath: '/repos/manager-worktree', isWorktree: true }, ]) const { service } = createService({ removeAll }) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 77a2a977e..879224b44 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -1,10 +1,11 @@ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' -import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import { getReposPath } from '@opencode-manager/shared/config/env' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' +import type { RepoSibling } from '../../src/services/repo' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' @@ -30,6 +31,18 @@ const symlink = vi.fn() const readlink = vi.fn() const mkdirSafe = vi.fn() +const missingDirectories = new Set() + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: (target: string) => ( + target.startsWith('/worktrees/') ? !missingDirectories.has(target) : actual.existsSync(target) + ), + } +}) + vi.mock('fs/promises', () => ({ default: { lstat, @@ -447,7 +460,7 @@ describe('repo service', () => { }) describe('getSiblingRepos worktree API', () => { - type SiblingRepo = Repo & { currentBranch: string | undefined; worktreeStrategy?: string } + type SiblingRepo = RepoSibling let db: Database @@ -496,6 +509,20 @@ describe('getSiblingRepos worktree API', () => { afterEach(() => { db.close() + missingDirectories.clear() + }) + + it('skips worktrees OpenCode still records but that no longer exist on disk', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + missingDirectories.add('/worktrees/removed-run') + const client = createClient([ + { directory: '/worktrees/removed-run', strategy: 'git' }, + { directory: '/worktrees/feature-x', strategy: 'git' }, + ]) + + const siblings = await getSiblingRepos(db, 1, {}, client) + + expect(siblings.filter((sibling) => sibling.id === -1).map((sibling) => sibling.fullPath)).toEqual(['/worktrees/feature-x']) }) it('maps worktree entries to worktree siblings', async () => { @@ -515,17 +542,15 @@ describe('getSiblingRepos worktree API', () => { expect(siblings[1]?.currentBranch).toBeUndefined() }) - it('excludes the target repo, repos root, schedule root, known siblings, and main checkouts', async () => { + it('excludes the target repo, repos root, known siblings, and main checkouts', async () => { const { getSiblingRepos } = await import('../../src/services/repo') const repoA = path.join(getReposPath(), 'repo-a') const repoB = path.join(getReposPath(), 'repo-b') - const scheduleDir = path.join(getScheduleWorktreesPath(), 'run-1') listRepos.mockReturnValue([createRepoRow(1, 'repo-a'), createRepoRow(2, 'repo-b')]) isGitMainCheckout.mockImplementation(async (directory: string) => directory === '/worktrees/main-checkout') const client = createClient([ { directory: repoA, strategy: 'git' }, { directory: getReposPath(), strategy: 'git' }, - { directory: scheduleDir, strategy: 'git' }, { directory: repoB, strategy: 'git' }, { directory: '/worktrees/main-checkout', strategy: 'git' }, { directory: '/worktrees/feature-x', strategy: 'git' }, @@ -537,7 +562,7 @@ describe('getSiblingRepos worktree API', () => { expect(siblings.filter((sibling) => sibling.id === -1).map((sibling) => sibling.fullPath)).toEqual(['/worktrees/feature-x']) }) - it('excludes an active schedule run worktree path', async () => { + it('marks an active schedule run worktree as an in-use schedule worktree', async () => { const { getSiblingRepos } = await import('../../src/services/repo') const activePath = '/worktrees/active-run' db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(1, 1, 'manual', 'running', Date.now(), Date.now(), activePath) @@ -548,8 +573,9 @@ describe('getSiblingRepos worktree API', () => { const siblings = await getSiblingRepos(db, 1, {}, client) as SiblingRepo[] - expect(siblings).toHaveLength(2) - expect(siblings[1]?.fullPath).toBe('/worktrees/feature-x') + expect(siblings).toHaveLength(3) + expect(siblings[1]).toMatchObject({ fullPath: activePath, worktreeSource: 'schedule', schedule: { repoId: 1, jobId: 1, runId: 1, inUse: true } }) + expect(siblings[2]).toMatchObject({ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }) }) it('returns only repo siblings when the location lookup rejects', async () => { diff --git a/backend/test/services/schedule-config.test.ts b/backend/test/services/schedule-config.test.ts index d8000b677..e57ec6c8e 100644 --- a/backend/test/services/schedule-config.test.ts +++ b/backend/test/services/schedule-config.test.ts @@ -36,6 +36,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: currentDate + 60 * 60_000, }) }) @@ -76,6 +77,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -108,6 +110,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -143,6 +146,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: 'feature/foo', + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -174,6 +178,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -205,6 +210,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: 'feature/foo', + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -236,6 +242,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: null, lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -280,6 +287,7 @@ describe('schedule-config', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: null, lastRunAt: null, createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), diff --git a/backend/test/services/schedule-worktree.test.ts b/backend/test/services/schedule-worktree.test.ts index b44e5349a..e0fa55b8c 100644 --- a/backend/test/services/schedule-worktree.test.ts +++ b/backend/test/services/schedule-worktree.test.ts @@ -118,7 +118,7 @@ describe('ScheduleWorktreeManager', () => { it('prepare creates a worktree with the correct branch name and returns context', async () => { const manager = await createManager() const repo = testRepo() - const job = { id: 10, branch: null } + const job = { id: 10, branch: null, workspaceMode: 'worktree' as const } const runId = 1 const ctx = await manager.prepare(repo, job, runId) @@ -144,7 +144,7 @@ describe('ScheduleWorktreeManager', () => { it('prepare returns null for a non-git directory', async () => { const manager = await createManager() const repo = testRepo({ fullPath: nonGitDir }) - const job = { id: 12, branch: null } + const job = { id: 12, branch: null, workspaceMode: 'worktree' as const } const ctx = await manager.prepare(repo, job, 1) expect(ctx).toBeNull() @@ -153,7 +153,7 @@ describe('ScheduleWorktreeManager', () => { it('prepare returns null for the assistant repo', async () => { const manager = await createManager() const repo = testRepo({ id: 0 }) - const job = { id: 13, branch: null } + const job = { id: 13, branch: null, workspaceMode: 'worktree' as const } const ctx = await manager.prepare(repo, job, 1) expect(ctx).toBeNull() @@ -162,7 +162,7 @@ describe('ScheduleWorktreeManager', () => { it('finalize returns null commit when no changes exist and removes the worktree', async () => { const manager = await createManager() const repo = testRepo() - const job = { id: 10, branch: null, name: 'No-change job' } + const job = { id: 10, branch: null, name: 'No-change job', workspaceMode: 'worktree' as const } const runId = 100 // Prepare a worktree first @@ -174,7 +174,7 @@ describe('ScheduleWorktreeManager', () => { // Finalize without making any changes const result = await manager.finalize( repo, - { id: 10, name: 'No-change job', prompt: '' }, + { id: 10, name: 'No-change job', prompt: '', workspaceMode: 'worktree' as const }, { id: runId, worktreePath, runBranch: ctx!.runBranch, triggerSource: 'manual' }, ) @@ -186,7 +186,7 @@ describe('ScheduleWorktreeManager', () => { it('finalize commits changes and returns the commit hash, then removes the worktree', async () => { const manager = await createManager() const repo = testRepo() - const job = { id: 10, branch: null, name: 'Change job' } + const job = { id: 10, branch: null, name: 'Change job', workspaceMode: 'worktree' as const } const runId = 200 // Prepare a worktree @@ -201,7 +201,7 @@ describe('ScheduleWorktreeManager', () => { // Finalize - should commit and clean up const result = await manager.finalize( repo, - { id: 10, name: 'Change job', prompt: 'Generate a changelog' }, + { id: 10, name: 'Change job', prompt: 'Generate a changelog', workspaceMode: 'worktree' as const }, { id: runId, worktreePath, runBranch: ctx!.runBranch, triggerSource: 'schedule' }, ) @@ -226,7 +226,7 @@ describe('ScheduleWorktreeManager', () => { const result = await manager.finalize( repo, - { id: 10, name: 'Job', prompt: '' }, + { id: 10, name: 'Job', prompt: '', workspaceMode: 'worktree' as const }, { id: 1, worktreePath: null, runBranch: null, triggerSource: 'manual' }, ) @@ -238,7 +238,7 @@ describe('ScheduleWorktreeManager', () => { const repo = testRepo() // A clearly nonexistent ref (avoids case-insensitive filesystem false matches // that would let a typo like "Main" resolve to "main" locally). - const job = { id: 21, branch: 'does-not-exist-branch' } + const job = { id: 21, branch: 'does-not-exist-branch', workspaceMode: 'worktree' as const } await expect(manager.prepare(repo, job, 1)).rejects.toThrow( /Base branch "does-not-exist-branch" was not found/, @@ -251,7 +251,7 @@ describe('ScheduleWorktreeManager', () => { it('prepare respects the branch override', async () => { const manager = await createManager() const repo = testRepo() - const job = { id: 20, branch: 'dev' } + const job = { id: 20, branch: 'dev', workspaceMode: 'worktree' as const } const runId = 1 const ctx = await manager.prepare(repo, job, runId) @@ -277,7 +277,7 @@ describe('ScheduleWorktreeManager', () => { it('pruneRunArtifacts removes the worktree directory and deletes the run branch', async () => { const manager = await createManager() const repo = testRepo() - const job = { id: 90, branch: null } + const job = { id: 90, branch: null, workspaceMode: 'worktree' as const } const runId = 900 const ctx = await manager.prepare(repo, job, runId) @@ -287,7 +287,7 @@ describe('ScheduleWorktreeManager', () => { const runBranch = ctx!.runBranch expect(existsSync(worktreePath)).toBe(true) - await manager.pruneRunArtifacts(repo, [{ runBranch, worktreePath }]) + await manager.pruneRunArtifacts(repo, 90, [{ runBranch, worktreePath }]) expect(existsSync(worktreePath)).toBe(false) const branchList = execSync(`git -C "${baseRepoPath}" branch --list "${runBranch}"`, { @@ -295,4 +295,63 @@ describe('ScheduleWorktreeManager', () => { }).trim() expect(branchList).toBe('') }) + + it('prepare returns null in repo mode so the run works in the checkout', async () => { + const manager = await createManager() + const ctx = await manager.prepare(testRepo(), { id: 30, branch: null, workspaceMode: 'repo' }, 1) + + expect(ctx).toBeNull() + expect(manager.listWorktrees(30)).toEqual([]) + }) + + it('finalize commits but keeps the worktree on its branch in kept-worktree mode', async () => { + const manager = await createManager() + const repo = testRepo() + const job = { id: 31, branch: null, name: 'Kept job', prompt: 'keep', workspaceMode: 'kept-worktree' as const } + + const ctx = await manager.prepare(repo, job, 1) + writeFileSync(path.join(ctx!.worktreePath, 'kept.md'), 'kept') + + const result = await manager.finalize(repo, job, { id: 1, worktreePath: ctx!.worktreePath, runBranch: ctx!.runBranch, triggerSource: 'manual' }) + + expect(result.commitHash).toMatch(/^[0-9a-f]{40}$/) + expect(existsSync(ctx!.worktreePath)).toBe(true) + expect(execSync(`git -C "${ctx!.worktreePath}" rev-parse --abbrev-ref HEAD`, { encoding: 'utf-8' }).trim()).toBe('schedule/31/run-1') + expect(manager.listWorktrees(31)).toEqual([ + { jobId: 31, runId: 1, worktreePath: ctx!.worktreePath, branch: 'schedule/31/run-1' }, + ]) + + await manager.releaseWorktree(repo, job, ctx!.worktreePath) + }) + + it('reuses one shared worktree across runs and continues its branch after release', async () => { + const manager = await createManager() + const repo = testRepo() + const job = { id: 32, branch: null, name: 'Shared job', prompt: 'share', workspaceMode: 'shared-worktree' as const } + const sharedPath = path.join(scheduleWorktreesRoot, 'job-32-shared') + + const first = await manager.prepare(repo, job, 1) + expect(first).toEqual({ directory: sharedPath, worktreePath: sharedPath, runBranch: 'schedule/32/shared' }) + writeFileSync(path.join(sharedPath, 'first.md'), 'first') + await manager.finalize(repo, job, { id: 1, worktreePath: sharedPath, runBranch: 'schedule/32/shared', triggerSource: 'manual' }) + expect(existsSync(sharedPath)).toBe(true) + + writeFileSync(path.join(sharedPath, 'uncommitted.md'), 'pending') + const second = await manager.prepare(repo, job, 2) + expect(second!.worktreePath).toBe(sharedPath) + expect(existsSync(path.join(sharedPath, 'uncommitted.md'))).toBe(true) + + await manager.releaseWorktree(repo, job, sharedPath) + expect(existsSync(sharedPath)).toBe(false) + + const third = await manager.prepare(repo, job, 3) + expect(existsSync(path.join(third!.worktreePath, 'first.md'))).toBe(true) + expect(existsSync(path.join(third!.worktreePath, 'uncommitted.md'))).toBe(true) + + await manager.pruneRunArtifacts(repo, 32, [{ runBranch: 'schedule/32/shared', worktreePath: sharedPath }]) + expect(existsSync(sharedPath)).toBe(true) + expect(execSync(`git -C "${baseRepoPath}" branch --list schedule/32/shared`, { encoding: 'utf-8' }).trim()).not.toBe('') + + await manager.releaseWorktree(repo, job, sharedPath) + }) }) diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index b3c344c4d..12342e5b9 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -123,6 +123,7 @@ const baseJob: ScheduleJob = { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 98abb0c0d..039eba0d9 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ getScheduleRunById: vi.fn(), listEnabledScheduleJobs: vi.fn(), listRunningScheduleRuns: vi.fn(), + clearScheduleRunWorktreePath: vi.fn(), listScheduleJobIdsByRepo: vi.fn(), listScheduleJobsByRepo: vi.fn(), listScheduleRunsByJob: vi.fn(), @@ -35,6 +36,8 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn().mockResolvedValue(null), finalize: vi.fn().mockResolvedValue({ commitHash: null }), pruneRunArtifacts: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockReturnValue([]), + releaseWorktree: vi.fn().mockResolvedValue(undefined), }, })) @@ -55,6 +58,7 @@ vi.mock('../../src/db/schedules', () => ({ getScheduleRunById: mocks.getScheduleRunById, listEnabledScheduleJobs: mocks.listEnabledScheduleJobs, listRunningScheduleRuns: mocks.listRunningScheduleRuns, + clearScheduleRunWorktreePath: mocks.clearScheduleRunWorktreePath, listScheduleJobIdsByRepo: mocks.listScheduleJobIdsByRepo, listScheduleJobsByRepo: mocks.listScheduleJobsByRepo, listScheduleRunsByJob: mocks.listScheduleRunsByJob, @@ -146,6 +150,7 @@ const job: ScheduleJob = { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), @@ -910,31 +915,31 @@ describe('ScheduleService', () => { expect(stub.api.agent.list).toHaveBeenCalledTimes(1) }) - it('throws when deleting or loading missing records', () => { + it('throws when deleting or loading missing records', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) mocks.deleteScheduleJob.mockReturnValue(false) mocks.getScheduleRunById.mockReturnValue(null) - expect(() => service.deleteJob(42, 7)).toThrow('Schedule not found') + await expect(service.deleteJob(42, 7)).rejects.toThrow('Schedule not found') expect(() => service.getRun(42, 7, 5)).toThrow('Run not found') }) - it('blocks deleteJob when a running run exists in activeRuns', () => { + it('blocks deleteJob when a running run exists in activeRuns', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) - expect(() => service.deleteJob(42, 7)).toThrow('Cannot delete a schedule while it is running. Cancel the run first.') + await expect(service.deleteJob(42, 7)).rejects.toThrow('Cannot delete a schedule while it is running. Cancel the run first.') }) - it('blocks deleteJob when a running run exists in the database', () => { + it('blocks deleteJob when a running run exists in the database', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) mocks.getRunningScheduleRunByJob.mockReturnValue({ ...baseRun, status: 'running' }) - expect(() => service.deleteJob(42, 7)).toThrow('Cannot delete a schedule while it is running. Cancel the run first.') + await expect(service.deleteJob(42, 7)).rejects.toThrow('Cannot delete a schedule while it is running. Cancel the run first.') }) it('blocks prepareRepoDelete when a running run exists in activeRuns', () => { @@ -961,14 +966,14 @@ describe('ScheduleService', () => { expect(onJobChange).not.toHaveBeenCalled() }) - it('deleteJob succeeds when no runs are active', () => { + it('deleteJob succeeds when no runs are active', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) mocks.deleteScheduleJob.mockReturnValue(true) const onJobChange = vi.fn() service.setJobChangeHandler(onJobChange) - service.deleteJob(42, 7) + await service.deleteJob(42, 7) expect(mocks.deleteScheduleJob).toHaveBeenCalledWith(expect.anything(), 42, 7) expect(onJobChange).toHaveBeenCalledWith(null, 7) @@ -2153,6 +2158,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2191,6 +2197,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2227,6 +2234,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2262,6 +2270,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2297,6 +2306,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2346,7 +2356,7 @@ describe('ScheduleService run history cleanup', () => { const result = await makeService().clearRunHistory(42, 7) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, [ + expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, 7, [ { id: 3, status: 'completed', runBranch: 'schedule/7/run-3', worktreePath: null }, { id: 1, status: 'failed', runBranch: null, worktreePath: null }, ]) @@ -2372,12 +2382,45 @@ describe('ScheduleService run history cleanup', () => { await makeService().deleteRun(42, 7, 5) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, [ + expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, 7, [ { runBranch: 'schedule/7/run-5', worktreePath: '/wt/5' }, ]) expect(mocks.deleteScheduleRunById).toHaveBeenCalledWith({}, 42, 7, 5) }) + it('listWorktrees marks the shared worktree and running run worktrees as in use', () => { + mocks.listRunningScheduleRuns.mockReturnValue([{ ...baseRun, status: 'running', worktreePath: '/wt/job-7-run-9' }]) + mocks.getRunningScheduleRunByJob.mockReturnValue({ ...baseRun, status: 'running' }) + mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ + { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, + { jobId: 7, runId: null, worktreePath: '/wt/job-7-shared', branch: 'schedule/7/shared' }, + { jobId: 7, runId: 9, worktreePath: '/wt/job-7-run-9', branch: 'schedule/7/run-9' }, + ]) + + expect(makeService().listWorktrees(42, 7)).toEqual([ + { worktreePath: '/wt/job-7-shared', branch: 'schedule/7/shared', runId: null, inUse: true }, + { worktreePath: '/wt/job-7-run-9', branch: 'schedule/7/run-9', runId: 9, inUse: true }, + { worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3', runId: 3, inUse: false }, + ]) + }) + + it('removeWorktrees releases idle worktrees and clears run references', async () => { + mocks.listRunningScheduleRuns.mockReturnValue([{ ...baseRun, status: 'running', worktreePath: '/wt/job-7-run-9' }]) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ + { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, + { jobId: 7, runId: 9, worktreePath: '/wt/job-7-run-9', branch: 'schedule/7/run-9' }, + ]) + + await expect(makeService().removeWorktrees(42, 7)).resolves.toEqual({ removed: 1 }) + expect(mocks.stubWorktreeManager.releaseWorktree).toHaveBeenCalledTimes(1) + expect(mocks.stubWorktreeManager.releaseWorktree).toHaveBeenCalledWith(repo, job, '/wt/job-7-run-3') + expect(mocks.clearScheduleRunWorktreePath).toHaveBeenCalledWith({}, 42, 7, '/wt/job-7-run-3') + + await expect(makeService().removeWorktrees(42, 7, '/wt/job-7-run-9')).rejects.toThrow('in use by a running run') + await expect(makeService().removeWorktrees(42, 7, '/etc')).rejects.toThrow('Worktree not found') + }) + it('deleteRun refuses to delete a run in progress', async () => { mocks.getScheduleRunById.mockReturnValue({ ...baseRun, id: 5, status: 'running' }) diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 3cb89bf63..66f97fe4f 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -2,7 +2,8 @@ import type { Repo } from './types' import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './fetchWrapper' import { API_BASE_URL } from '@/config' import { saveFile } from '@/lib/download' -import type { DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest, DeleteRepoRequest, DeleteRepoResult, RepoGitIdentity, WorktreeSetupResult } from '@opencode-manager/shared/types' +import type { CreateRepoWorkspaceRequest, DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest, DeleteRepoRequest, DeleteRepoResult, RepoGitIdentity, WorktreeSetupResult } from '@opencode-manager/shared/types' +import type { RepoWorktreeSchedule, RepoWorktreeSource } from '@opencode-manager/shared/utils' export interface CreateRepoOptions { repoUrl?: string @@ -40,11 +41,21 @@ export async function getRepo(id: number): Promise { export type RepoSibling = Repo & { currentBranch?: string + worktreeSource?: RepoWorktreeSource worktreeStrategy?: string + schedule?: RepoWorktreeSchedule } export function workspaceLabel(workspace: RepoSibling): string { - return workspace.currentBranch || workspace.branch || workspace.localPath || 'workspace' + return workspace.currentBranch || workspace.branch || workspace.localPath || 'worktree' +} + +export function worktreeSourceLabel(worktree: RepoSibling): string | null { + if (worktree.schedule) { + return worktree.schedule.runId === null ? 'Schedule · shared' : `Schedule · run #${worktree.schedule.runId}` + } + if (worktree.worktreeSource === 'git') return 'Git' + return worktree.worktreeSource === 'opencode' ? 'OpenCode' : null } export interface RepoWorktree { @@ -63,9 +74,11 @@ export async function deleteRepoWorkspace(repoId: number, directory: string): Pr }) } -export async function createRepoWorkspace(repoId: number): Promise { +export async function createRepoWorkspace(repoId: number, request: CreateRepoWorkspaceRequest = {}): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/workspaces`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), }) } diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 71157b443..f834cd98a 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -5,6 +5,7 @@ import type { ScheduleJob, ScheduleRun, ScheduleRunStatus, + ScheduleWorktree, UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' @@ -22,6 +23,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoPath: string repoUrl: string lastRun: ScheduleRunSummary | null + retainedWorktreeCount: number } export interface ScheduleRunWithContext extends ScheduleRun { @@ -162,6 +164,18 @@ export async function clearRepoScheduleRuns(repoId: number, jobId: number): Prom }) } +export async function listScheduleWorktrees(repoId: number, jobId: number): Promise<{ worktrees: ScheduleWorktree[] }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/worktrees`) +} + +export async function removeScheduleWorktrees(repoId: number, jobId: number, worktreePath?: string): Promise<{ removed: number }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/worktrees`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(worktreePath ? { worktreePath } : {}), + }) +} + export async function deleteRepoScheduleRun(repoId: number, jobId: number, runId: number): Promise { return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs/${runId}`, { method: 'DELETE', diff --git a/frontend/src/components/repo/WorkspaceManager.tsx b/frontend/src/components/repo/WorkspaceManager.tsx deleted file mode 100644 index c786956a3..000000000 --- a/frontend/src/components/repo/WorkspaceManager.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { DeleteDialog } from '@/components/ui/delete-dialog' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { GitBranch, Plus, Search, Trash2 } from 'lucide-react' -import { workspaceLabel, type RepoSibling } from '@/api/repos' - -interface WorkspaceManagerProps { - open: boolean - onOpenChange: (open: boolean) => void - workspaces: RepoSibling[] - onDelete: (directories: string[]) => void - activeWorkspaceDirectory?: string - onActiveWorkspaceChange: (directory: string) => void - onCreateWorkspace: () => void - isDeleting?: boolean -} - -export function WorkspaceManager({ - open, - onOpenChange, - workspaces, - onDelete, - activeWorkspaceDirectory, - onActiveWorkspaceChange, - onCreateWorkspace, - isDeleting = false, -}: WorkspaceManagerProps) { - const [selected, setSelected] = useState>(new Set()) - const [confirmOpen, setConfirmOpen] = useState(false) - const [manageMode, setManageMode] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - - useEffect(() => { - if (!open) { - setSearchQuery('') - } - }, [open]) - - const filteredWorkspaces = useMemo(() => { - const query = searchQuery.trim().toLowerCase() - if (!query) return workspaces - - return workspaces.filter((workspace) => { - const haystack = [ - workspaceLabel(workspace), - workspace.fullPath, - workspace.worktreeStrategy, - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - return haystack.includes(query) - }) - }, [searchQuery, workspaces]) - - const selectableDirectories = useMemo( - () => filteredWorkspaces.map((workspace) => workspace.fullPath).filter((directory): directory is string => !!directory), - [filteredWorkspaces], - ) - - const allSelected = selectableDirectories.length > 0 && selectableDirectories.every((directory) => selected.has(directory)) - const selectedCount = selected.size - - const toggle = (directory: string, checked: boolean) => { - setSelected((prev) => { - const next = new Set(prev) - if (checked) { - next.add(directory) - } else { - next.delete(directory) - } - return next - }) - } - - const toggleAll = () => { - setSelected(allSelected ? new Set() : new Set(selectableDirectories)) - } - - const handleConfirm = () => { - onDelete(Array.from(selected)) - setSelected(new Set()) - setManageMode(false) - setConfirmOpen(false) - } - - return ( - <> - - - - Workspaces - Choose where new sessions should start. - -
-
- {manageMode ? ( -
- - - {selectedCount > 0 ? `${selectedCount} selected` : 'Select all'} - -
- ) : ( - - {activeWorkspaceDirectory ? 'Selected workspace' : 'Select a workspace'} - - )} -
- - {manageMode ? ( - <> - - - - ) : ( - - )} -
-
- -
- - setSearchQuery(event.target.value)} - placeholder="Search workspaces..." - className="h-10 w-full rounded-md border border-border bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:border-primary" - /> -
- -
- {filteredWorkspaces.map((workspace) => { - if (!workspace.fullPath) return null - const directory = workspace.fullPath - const isChecked = selected.has(directory) - const isActive = directory === activeWorkspaceDirectory - const label = workspaceLabel(workspace) - const rowClassName = isActive - ? 'border-primary/50 bg-primary/10 text-foreground' - : 'border-border bg-muted/30 hover:bg-muted/50' - if (!manageMode) { - return ( - - ) - } - return ( -
- toggle(directory, checked === true)} - aria-label={`Select workspace ${directory}`} - /> - - {label} - - {directory} - -
- ) - })} - {filteredWorkspaces.length === 0 && ( -
- No workspaces found -
- )} -
-
-
-
- - setConfirmOpen(false)} - title={selectedCount === 1 ? 'Delete Workspace' : 'Delete Workspaces'} - description={ - selectedCount === 1 - ? 'Are you sure you want to delete this OpenCode workspace? This removes the workspace and its sessions in OpenCode.' - : `Are you sure you want to delete ${selectedCount} OpenCode workspaces? This removes the workspaces and their sessions in OpenCode.` - } - isDeleting={isDeleting} - /> - - ) -} diff --git a/frontend/src/components/repo/WorktreeSessionGroups.test.tsx b/frontend/src/components/repo/WorktreeSessionGroups.test.tsx new file mode 100644 index 000000000..0c5612c70 --- /dev/null +++ b/frontend/src/components/repo/WorktreeSessionGroups.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WorktreeSessionGroups } from './WorktreeSessionGroups' +import type { RepoSibling } from '@/api/repos' +import type { Session } from '@/api/types' + +const makeWorktree = (overrides: Partial & Pick): RepoSibling => ({ + id: -1, + localPath: 'x', + fullPath: overrides.fullPath, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + isWorktree: true, + currentBranch: overrides.currentBranch, + worktreeSource: overrides.worktreeSource, + worktreeStrategy: 'git', + ...overrides, +}) + +const featureWorktree = makeWorktree({ + fullPath: '/w/feature', + currentBranch: 'feature/auth', + worktreeSource: 'opencode', +}) + +const sharedScheduleWorktree = makeWorktree({ + fullPath: '/s/job-14-shared', + currentBranch: 'schedule/14/shared', + worktreeSource: 'schedule', + schedule: { repoId: 1, jobId: 14, runId: null, inUse: true }, +}) + +const runScheduleWorktree = makeWorktree({ + fullPath: '/s/job-14-run-340', + currentBranch: 'schedule/14/run-340', + worktreeSource: 'schedule', + schedule: { repoId: 1, jobId: 14, runId: 340, inUse: false }, +}) + +const manualWorktree = makeWorktree({ + fullPath: '/w/manual', + currentBranch: 'manual', + worktreeSource: 'git', +}) + +const allWorktrees = [featureWorktree, sharedScheduleWorktree, runScheduleWorktree, manualWorktree] + +const makeSession = (id: string, title: string, directory: string): Session => ({ + id, + title, + parentID: undefined, + location: { directory }, + time: { created: 0, updated: 0 }, +} as unknown as Session) + +const featureSession = makeSession('ses_1', 'Auth work', '/w/feature') + +const onNewSession = vi.fn() +const onOpenTerminal = vi.fn() +const onCreateWorktree = vi.fn() +const onDelete = vi.fn() + +beforeEach(() => { + localStorage.clear() + onNewSession.mockReset() + onOpenTerminal.mockReset() + onCreateWorktree.mockReset() + onDelete.mockReset() +}) + +function renderGroups(overrides: { + worktrees?: RepoSibling[] + sessions?: Session[] + searchQuery?: string +} = {}) { + return render( +
{session.title}
} + onNewSession={onNewSession} + onOpenTerminal={onOpenTerminal} + onCreateWorktree={onCreateWorktree} + onDelete={onDelete} + />, + ) +} + +describe('WorktreeSessionGroups', () => { + it('groups sessions under their worktree with owner badges, schedule header and in-use pill', async () => { + renderGroups() + + expect(screen.getByText('feature/auth')).toBeInTheDocument() + expect(screen.getByText('OpenCode')).toBeInTheDocument() + expect(screen.getByText('Auth work')).toBeInTheDocument() + expect(screen.getByText('1 session')).toBeInTheDocument() + + expect(screen.getByText('Dependency audit')).toBeInTheDocument() + expect(screen.getByText('Schedule · shared')).toBeInTheDocument() + expect(screen.getByText('Schedule · run #340')).toBeInTheDocument() + expect(screen.getByText('in use')).toBeInTheDocument() + + expect(screen.getByText('Git')).toBeInTheDocument() + expect(screen.getByText('No sessions in manual · Start one')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /No sessions in manual/ })) + expect(onNewSession).toHaveBeenCalledWith('/w/manual') + + await userEvent.click(screen.getByRole('button', { name: 'Open terminal in manual' })) + expect(onOpenTerminal).toHaveBeenCalledWith('/w/manual') + }) + + it('disables delete for the in-use worktree and deletes the run worktree after confirmation', async () => { + renderGroups() + + expect(screen.getByRole('button', { name: 'Delete worktree schedule/14/shared' })).toBeDisabled() + const runDelete = screen.getByRole('button', { name: 'Delete worktree schedule/14/run-340' }) + expect(runDelete).toBeEnabled() + + await userEvent.click(runDelete) + const dialog = screen.getByRole('dialog', { name: 'Delete Worktree' }) + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledWith(['/s/job-14-run-340']) + }) + + it('cleans up only the non-in-use worktrees of a schedule after confirmation', async () => { + renderGroups() + + await userEvent.click(screen.getByRole('button', { name: 'Clean up' })) + const dialog = screen.getByRole('dialog', { name: 'Delete Worktree' }) + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledWith(['/s/job-14-run-340']) + }) + + it('cleans up worktrees of a schedule that the search is hiding', async () => { + renderGroups({ sessions: [makeSession('ses_2', 'Audit run', '/s/job-14-shared')], searchQuery: 'audit' }) + + expect(screen.queryByText('schedule/14/run-340')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: 'Clean up' })) + const dialog = screen.getByRole('dialog', { name: 'Delete Worktree' }) + await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledWith(['/s/job-14-run-340']) + }) + + it('filters to only the git worktree when the Git owner chip is selected', async () => { + renderGroups() + + await userEvent.click(screen.getByRole('button', { name: 'Git 1' })) + + expect(screen.getByText('manual')).toBeInTheDocument() + expect(screen.queryByText('feature/auth')).not.toBeInTheDocument() + expect(screen.queryByText('Dependency audit')).not.toBeInTheDocument() + }) + + it('collapses a worktree and persists the collapsed state across remount', async () => { + const { unmount } = renderGroups() + expect(screen.getByText('Auth work')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { expanded: true, name: /feature\/auth/ })) + expect(screen.queryByText('Auth work')).not.toBeInTheDocument() + + unmount() + renderGroups() + expect(screen.queryByText('Auth work')).not.toBeInTheDocument() + }) + + it('hides empty worktrees while searching and renders the empty state when there are none', async () => { + const { unmount } = renderGroups({ searchQuery: 'auth' }) + + expect(screen.getByText('feature/auth')).toBeInTheDocument() + expect(screen.queryByText('manual')).not.toBeInTheDocument() + expect(screen.queryByText('Dependency audit')).not.toBeInTheDocument() + + unmount() + renderGroups({ worktrees: [], sessions: [] }) + + expect(screen.getByText('No worktrees yet')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'New worktree' })) + expect(onCreateWorktree).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/repo/WorktreeSessionGroups.tsx b/frontend/src/components/repo/WorktreeSessionGroups.tsx new file mode 100644 index 000000000..14727ee6d --- /dev/null +++ b/frontend/src/components/repo/WorktreeSessionGroups.tsx @@ -0,0 +1,359 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { CalendarClock, ChevronRight, Layers, Plus, SquareTerminal, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { workspaceLabel, worktreeSourceLabel, type RepoSibling } from '@/api/repos' +import type { Session } from '@/api/types' +import type { RepoWorktreeSource } from '@opencode-manager/shared/utils' + +type OwnerFilter = 'all' | RepoWorktreeSource + +interface WorktreeSessionGroupsProps { + repoId: number + worktrees: RepoSibling[] + sessions: Session[] + searchQuery: string + scheduleNames: Record + renderSessionCard: (session: Session) => ReactNode + onNewSession: (directory: string) => void + onOpenTerminal: (directory: string) => void + onCreateWorktree: () => void + onDelete: (directories: string[]) => void + isDeleting?: boolean +} + +interface WorktreeGroup { + worktree: RepoSibling + directory: string + sessions: Session[] + lastActive: number +} + +type GroupEntry = + | { kind: 'worktree'; key: string; group: WorktreeGroup; lastActive: number } + | { kind: 'schedule'; key: string; jobId: number; groups: WorktreeGroup[]; lastActive: number } + +const OWNER_FILTERS: Array<{ value: OwnerFilter; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'opencode', label: 'OpenCode' }, + { value: 'schedule', label: 'Schedules' }, + { value: 'git', label: 'Git' }, +] + +const OWNER_BADGE_CLASS: Record = { + opencode: 'border-primary/40 bg-primary/15 text-primary', + schedule: 'border-highlight/40 bg-highlight/15 text-highlight', + git: 'border-border bg-muted text-muted-foreground', +} + +const WORKTREE_REMOVAL_NOTE = 'OpenCode worktrees are removed with their sessions. Schedule worktrees have pending changes committed to their branch first. Other git worktrees are only removed when they have no uncommitted changes.' + +/** + * The Worktrees tab body: every worktree of the repo as a collapsible section of its sessions, + * with a schedule's worktrees nested under that schedule so kept runs stay visible as they pile up. + */ +export function WorktreeSessionGroups({ + repoId, + worktrees, + sessions, + searchQuery, + scheduleNames, + renderSessionCard, + onNewSession, + onOpenTerminal, + onCreateWorktree, + onDelete, + isDeleting = false, +}: WorktreeSessionGroupsProps) { + const [ownerFilter, setOwnerFilter] = useState('all') + const [collapsed, setCollapsed] = useCollapsedGroups(repoId) + const [pendingDelete, setPendingDelete] = useState<{ directories: string[]; label: string } | null>(null) + const isSearching = searchQuery.trim().length > 0 + + const ownerCounts = useMemo(() => { + const counts: Record = { all: worktrees.length, opencode: 0, schedule: 0, git: 0 } + worktrees.forEach((worktree) => { + if (worktree.worktreeSource) counts[worktree.worktreeSource] += 1 + }) + return counts + }, [worktrees]) + + const entries = useMemo( + () => buildGroupEntries(worktrees, sessions, ownerFilter, isSearching), + [worktrees, sessions, ownerFilter, isSearching], + ) + + const toggle = (key: string) => { + setCollapsed((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const allKeys = entries.flatMap((entry) => ( + entry.kind === 'schedule' ? [entry.key, ...entry.groups.map((group) => groupKey(group.directory))] : [entry.key] + )) + const allCollapsed = allKeys.length > 0 && allKeys.every((key) => collapsed.has(key)) + + if (worktrees.length === 0) { + return ( +
+

No worktrees yet

+

Create a worktree to work on a branch without touching the main checkout.

+ +
+ ) + } + + const renderWorktree = (group: WorktreeGroup, nested: boolean) => { + const key = groupKey(group.directory) + const isOpen = !collapsed.has(key) + const label = workspaceLabel(group.worktree) + const source = group.worktree.worktreeSource + const sourceLabel = worktreeSourceLabel(group.worktree) + const isInUse = group.worktree.schedule?.inUse === true + return ( +
+
+ + + + +
+ {isOpen && ( +
+ {group.sessions.length > 0 ? group.sessions.map(renderSessionCard) : ( + + )} +
+ )} +
+ ) + } + + return ( + <> +
+ {OWNER_FILTERS.map((filter) => ( + + ))} +
+ + +
+
+ + {entries.length === 0 ? ( +
+ {isSearching ? 'No sessions found' : 'No worktrees match this filter'} +
+ ) : entries.map((entry) => { + if (entry.kind === 'worktree') return renderWorktree(entry.group, false) + const isOpen = !collapsed.has(entry.key) + const name = scheduleNames[entry.jobId] ?? `Schedule #${entry.jobId}` + const removable = worktrees + .filter((worktree) => worktree.schedule?.jobId === entry.jobId && !worktree.schedule.inUse) + .map((worktree) => worktree.fullPath) + return ( +
+
+ + +
+ {isOpen && entry.groups.map((group) => renderWorktree(group, true))} +
+ ) + })} + + { if (!open) setPendingDelete(null) }} + onCancel={() => setPendingDelete(null)} + onConfirm={() => { + if (pendingDelete) onDelete(pendingDelete.directories) + setPendingDelete(null) + }} + title={pendingDelete && pendingDelete.directories.length > 1 ? 'Delete Worktrees' : 'Delete Worktree'} + description={pendingDelete && pendingDelete.directories.length > 1 + ? `Delete ${pendingDelete.directories.length} worktrees? ${WORKTREE_REMOVAL_NOTE}` + : `Delete this worktree? ${WORKTREE_REMOVAL_NOTE}`} + itemName={pendingDelete?.label} + isDeleting={isDeleting} + /> + + ) +} + +function groupKey(directory: string): string { + return `worktree:${directory}` +} + +function buildGroupEntries( + worktrees: RepoSibling[], + sessions: Session[], + ownerFilter: OwnerFilter, + hideEmpty: boolean, +): GroupEntry[] { + const sessionsByDirectory = new Map() + sessions.forEach((session) => { + const list = sessionsByDirectory.get(session.location.directory) ?? [] + list.push(session) + sessionsByDirectory.set(session.location.directory, list) + }) + + const groups = worktrees + .filter((worktree) => ownerFilter === 'all' || worktree.worktreeSource === ownerFilter) + .map((worktree): WorktreeGroup => { + const groupSessions = sessionsByDirectory.get(worktree.fullPath) ?? [] + return { + worktree, + directory: worktree.fullPath, + sessions: groupSessions, + lastActive: groupSessions[0]?.time.updated ?? 0, + } + }) + .filter((group) => !hideEmpty || group.sessions.length > 0) + + const scheduleEntries = new Map>() + const entries: GroupEntry[] = [] + groups.forEach((group) => { + const jobId = group.worktree.schedule?.jobId + if (jobId === undefined) { + entries.push({ kind: 'worktree', key: groupKey(group.directory), group, lastActive: group.lastActive }) + return + } + const existing = scheduleEntries.get(jobId) + if (existing) { + existing.groups.push(group) + existing.lastActive = Math.max(existing.lastActive, group.lastActive) + return + } + const entry: Extract = { kind: 'schedule', key: `schedule:${jobId}`, jobId, groups: [group], lastActive: group.lastActive } + scheduleEntries.set(jobId, entry) + entries.push(entry) + }) + + scheduleEntries.forEach((entry) => entry.groups.sort((a, b) => b.lastActive - a.lastActive)) + return entries.sort((a, b) => b.lastActive - a.lastActive) +} + +function useCollapsedGroups(repoId: number) { + const storageKey = `oc:repo:${repoId}:worktree-groups:collapsed` + const [collapsed, setCollapsed] = useState>(() => readCollapsed(storageKey)) + + useEffect(() => { + try { + localStorage.setItem(storageKey, JSON.stringify(Array.from(collapsed))) + } catch { + return + } + }, [storageKey, collapsed]) + + return [collapsed, setCollapsed] as const +} + +function readCollapsed(storageKey: string): Set { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]') + return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []) + } catch { + return new Set() + } +} diff --git a/frontend/src/components/repo/WorktreeTabs.test.tsx b/frontend/src/components/repo/WorktreeTabs.test.tsx index 9754cc44d..d5a374067 100644 --- a/frontend/src/components/repo/WorktreeTabs.test.tsx +++ b/frontend/src/components/repo/WorktreeTabs.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { WorktreeTabs } from './WorktreeTabs' import type { RepoSibling } from '@/api/repos' @@ -15,6 +15,7 @@ const makeWorkspaceSibling = (branch: string): RepoSibling => ({ currentBranch: branch, branch, worktreeStrategy: 'git', + worktreeSource: 'opencode', }) const onValueChange = vi.fn() @@ -30,18 +31,18 @@ describe('WorktreeTabs', () => { ) expect(screen.getAllByRole('tab')).toHaveLength(1) expect(screen.getByText('main')).toBeInTheDocument() - expect(screen.getByText('Workspace')).toBeInTheDocument() + expect(screen.getByText('Worktree')).toBeInTheDocument() }) - it('renders Repo and Workspaces tabs when at least one workspace exists', () => { + it('renders Repo and Worktrees tabs when at least one workspace exists', () => { const workspaces = [makeWorkspaceSibling('feature-a')] render() const tabs = screen.getAllByRole('tab') expect(tabs).toHaveLength(2) expect(screen.getByText('main')).toBeInTheDocument() - expect(screen.getByText('Workspaces')).toBeInTheDocument() - expect(screen.getByText('(1)')).toBeInTheDocument() + expect(screen.getByText('Worktrees')).toBeInTheDocument() + expect(within(tabs[1]).getByText('1')).toBeInTheDocument() }) it('marks the active tab', () => { @@ -69,6 +70,6 @@ describe('WorktreeTabs', () => { ] render() - expect(screen.getByText('(3)')).toBeInTheDocument() + expect(within(screen.getAllByRole('tab')[1]).getByText('3')).toBeInTheDocument() }) }) diff --git a/frontend/src/components/repo/WorktreeTabs.tsx b/frontend/src/components/repo/WorktreeTabs.tsx index eb369c04e..06a520df7 100644 --- a/frontend/src/components/repo/WorktreeTabs.tsx +++ b/frontend/src/components/repo/WorktreeTabs.tsx @@ -8,9 +8,7 @@ interface WorktreeTabsProps { value: WorktreeTabValue onValueChange: (value: WorktreeTabValue) => void baseLabel: string - activeWorkspaceLabel?: string onCreateWorkspace?: () => void - onWorkspaceMenu?: () => void } export function WorktreeTabs({ @@ -18,40 +16,37 @@ export function WorktreeTabs({ value, onValueChange, baseLabel, - activeWorkspaceLabel, onCreateWorkspace, - onWorkspaceMenu, }: WorktreeTabsProps) { const hasWorkspaces = workspaces.length > 0 - const workspaceLabel = value === 'workspaces' && activeWorkspaceLabel ? activeWorkspaceLabel : 'Workspaces' const tabClassName = - 'group min-w-0 flex-1 gap-1.5 px-2 sm:flex-none sm:px-3 data-[state=active]:border data-[state=active]:border-primary/50 data-[state=active]:bg-primary/10 data-[state=inactive]:hover:bg-accent data-[state=inactive]:hover:text-foreground' + 'group min-w-0 flex-1 gap-1.5 rounded-t-md rounded-b-none border border-transparent border-b-0 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground data-[state=active]:-mb-px data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none sm:flex-none' const activeLabelClassName = 'group-data-[state=active]:text-highlight' return ( -
+
onValueChange(next as WorktreeTabValue)} className="min-w-0"> - + {baseLabel} {hasWorkspaces ? ( - <> - - - {workspaceLabel} - ({workspaces.length}) - - + + + Worktrees + + {workspaces.length} + + ) : ( )} diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index 2767d68c9..f44266f23 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -5,6 +5,9 @@ import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { TabsContent } from '@/components/ui/tabs' +import { RadioOptionGroup } from '@/components/ui/radio-option-group' +import { workspaceModeOptions } from '@/components/schedules/schedule-utils' +import type { ScheduleWorkspaceMode } from '@opencode-manager/shared/types' import { Info } from 'lucide-react' type GeneralTabProps = { @@ -23,6 +26,9 @@ type GeneralTabProps = { branch: string onBranchChange: (value: string) => void branchRepoId: number | undefined + workspaceMode: ScheduleWorkspaceMode + onWorkspaceModeChange: (value: ScheduleWorkspaceMode) => void + showWorkspaceMode: boolean showRepoSelector?: boolean isEditing: boolean repoId?: number @@ -64,6 +70,9 @@ export function GeneralTab({ branch, onBranchChange, branchRepoId, + workspaceMode, + onWorkspaceModeChange, + showWorkspaceMode, showRepoSelector, isEditing, repoId, @@ -152,22 +161,35 @@ export function GeneralTab({
-
-
-
- - +
+ {showWorkspaceMode && ( +
+

Workspace

+
- -
+ )} + {workspaceMode !== 'repo' && ( +
+
+ + +
+ +
+ )}
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index d87b707ef..f7d27e1f4 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -6,9 +6,12 @@ import { formatScheduleSummary, formatTimestamp, getJobStatusTone, + formatWorkspaceMode, hasSkillMetadata, } from '@/components/schedules/schedule-utils' -import { Bot, CalendarClock, Clock3, History, Loader2, Pencil, Play, Sparkles, Trash2 } from 'lucide-react' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' +import { useRemoveScheduleWorktrees, useScheduleWorktrees } from '@/hooks/useSchedules' +import { Bot, CalendarClock, Clock3, GitBranch, History, Loader2, Pencil, Play, Sparkles, Trash2 } from 'lucide-react' import { useScheduleModels } from '@/hooks/useScheduleModels' import { resolveScheduleModel } from '@/lib/schedules/schedule-model' @@ -37,6 +40,9 @@ export function JobDetailTab({ }: JobDetailTabProps) { const { availableModelKeys, configDefaultModel } = useScheduleModels(Boolean(selectedJob)) const resolvedModel = resolveScheduleModel(selectedJob?.model, availableModelKeys, configDefaultModel) + const { data: worktrees = [] } = useScheduleWorktrees(selectedJob?.repoId, selectedJob?.id ?? null) + const removeWorktrees = useRemoveScheduleWorktrees() + const idleWorktreeCount = worktrees.filter((worktree) => !worktree.inUse).length if (!selectedJob) { return ( @@ -109,6 +115,49 @@ export function JobDetailTab({
{JSON.stringify(selectedJob.skillMetadata, null, 2)}
)} + + {worktrees.length > 0 && ( +
+
+
+

Worktrees ({worktrees.length})

+

Worktrees this schedule has on disk. Removing one commits any pending changes to its branch first and keeps the branch.

+
+ +
+
    + {worktrees.map((worktree) => ( +
  • +
    +

    {worktree.branch}

    +

    + {worktree.runId === null ? 'Shared worktree' : `Run #${worktree.runId}`} + {worktree.inUse ? ' · in use' : ''} · {worktree.worktreePath} +

    +
    + +
  • + ))} +
+
+ )}
@@ -131,6 +180,12 @@ export function JobDetailTab({

Model

{resolvedModel ?? 'Workspace default'}

+ {selectedJob.repoId !== ASSISTANT_REPO_ID && ( +
+

Workspace

+

{formatWorkspaceMode(selectedJob.workspaceMode)}

+
+ )}

Created

{formatTimestamp(selectedJob.createdAt)}

diff --git a/frontend/src/components/schedules/RunDetailPanel.test.tsx b/frontend/src/components/schedules/RunDetailPanel.test.tsx index 2982fc180..9f8fe5626 100644 --- a/frontend/src/components/schedules/RunDetailPanel.test.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.test.tsx @@ -267,6 +267,15 @@ describe('RunDetailPanel open session', () => { expect(apiMocks.createSessionWithContext).not.toHaveBeenCalled() }) + it('opens the original session for a finished run whose worktree was kept', async () => { + renderPanel({ ...run, status: 'completed', sessionId: 'ses_run', worktreePath: '/abs/worktrees/job-1-run-1', runBranch: 'schedule/1/run-1' }) + + await clickOpenSession() + + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/repos/5/sessions/ses_run')) + expect(apiMocks.createSessionWithContext).not.toHaveBeenCalled() + }) + it('opens a new repo session seeded with the full output and branch for a finished worktree run', async () => { apiMocks.createSessionWithContext.mockResolvedValue({ id: 'ses_new' }) renderPanel({ diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 1bbfdb8af..0ef87e448 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -71,7 +71,7 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel const { sessionId } = activeRun - const opensNewRepoSession = Boolean(activeRun.runBranch) && activeRun.status !== 'running' + const opensNewRepoSession = Boolean(activeRun.runBranch) && !activeRun.worktreePath && activeRun.status !== 'running' const handleOpenSession = async () => { if (!opensNewRepoSession) { diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 7ae7ec97c..675337998 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import type { CreateScheduleJobRequest, PromptTemplate, ScheduleJob, ScheduleMcpServer } from '@opencode-manager/shared/types' +import type { CreateScheduleJobRequest, PromptTemplate, ScheduleJob, ScheduleMcpServer, ScheduleWorkspaceMode } from '@opencode-manager/shared/types' import { useScheduleModels } from '@/hooks/useScheduleModels' import { providerModelRef } from '@/api/providers' import { resolveScheduleModel } from '@/lib/schedules/schedule-model' @@ -71,6 +71,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const initialSkillNotesRef = useRef(undefined) const [mcpServers, setMcpServers] = useState([]) const [branch, setBranch] = useState('') + const [workspaceMode, setWorkspaceMode] = useState('worktree') const [allowExternalDirectory, setAllowExternalDirectory] = useState(false) const [allowQuestions, setAllowQuestions] = useState(false) const [bashDenyPatterns, setBashDenyPatterns] = useState([...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) @@ -204,6 +205,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, initialSkillNotesRef.current = initialSkillNotes setMcpServers(job?.mcpServers ?? []) setBranch(job?.branch ?? '') + setWorkspaceMode(job?.workspaceMode ?? 'worktree') setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false) setAllowQuestions(job?.permissionConfig?.allowQuestions ?? false) setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) @@ -252,6 +254,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, model: resolvedModel ?? undefined, prompt: prompt.trim(), branch: branch.trim() || null, + workspaceMode, mcpServers, permissionConfig: { allowExternalDirectory, @@ -331,6 +334,9 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, branch={branch} onBranchChange={setBranch} branchRepoId={open && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} + workspaceMode={workspaceMode} + onWorkspaceModeChange={setWorkspaceMode} + showWorkspaceMode={effectiveRepoId !== ASSISTANT_REPO_ID} showRepoSelector={showRepoSelector} isEditing={!!job} repoId={selectedRepoId} diff --git a/frontend/src/components/schedules/ScheduleJobsTable.tsx b/frontend/src/components/schedules/ScheduleJobsTable.tsx index b430c9a7f..108329cea 100644 --- a/frontend/src/components/schedules/ScheduleJobsTable.tsx +++ b/frontend/src/components/schedules/ScheduleJobsTable.tsx @@ -184,6 +184,14 @@ export function ScheduleJobsTable({ > {job.name} + {job.retainedWorktreeCount > 0 && ( + + {job.retainedWorktreeCount} worktree{job.retainedWorktreeCount === 1 ? '' : 's'} + + )} {job.description && {job.description}} {mobileDetail} diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index e1b2b36ea..450eb3193 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -1,6 +1,7 @@ import cronstrue from 'cronstrue' import { formatDistanceToNow } from 'date-fns' -import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRun, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' +import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRun, ScheduleWorkspaceMode, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' +import type { RadioOption } from '@/components/ui/radio-option-group' export const intervalOptions = [ { label: '15m', value: 15 }, @@ -39,6 +40,33 @@ export const weekdayOptions = [ export type SchedulePreset = typeof schedulePresetOptions[number]['value'] +export const workspaceModeOptions: Array> = [ + { + value: 'worktree', + label: 'Fresh worktree, cleaned up', + description: 'Each run gets its own worktree. Changes are committed to a run branch and the worktree is removed when the run ends.', + }, + { + value: 'kept-worktree', + label: 'Fresh worktree, kept', + description: 'Each run gets its own worktree, which stays on disk afterwards so you can continue in it. Kept worktrees are listed on the job details.', + }, + { + value: 'shared-worktree', + label: 'Shared worktree', + description: 'Every run reuses one worktree and branch, so work builds up from run to run.', + }, + { + value: 'repo', + label: 'Repository checkout', + description: 'Runs directly in the repository working directory. Nothing is isolated, committed, or cleaned up.', + }, +] + +export function formatWorkspaceMode(mode: ScheduleWorkspaceMode): string { + return workspaceModeOptions.find((option) => option.value === mode)?.label ?? mode +} + export function getLocalTimeZone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' @@ -351,3 +379,5 @@ export function matchesScheduleJobSearch(job: ScheduleJob & { repoName?: string if (!term) return true return [job.name, job.description, job.repoName].some((field) => field?.toLowerCase().includes(term)) } + +export const DELETE_SCHEDULE_DESCRIPTION = 'This removes the job definition and all recorded run history for it. Any kept or shared worktrees are removed after their pending changes are committed to their branches; the branches are kept.' diff --git a/frontend/src/components/session/SessionList.test.tsx b/frontend/src/components/session/SessionList.test.tsx index c4d30799a..f2d20999a 100644 --- a/frontend/src/components/session/SessionList.test.tsx +++ b/frontend/src/components/session/SessionList.test.tsx @@ -244,6 +244,23 @@ describe('SessionList', () => { expect(screen.getByText('Click here to start a new session')).toBeTruthy() }) + it('hands an empty list to a custom session renderer instead of the create-session card', () => { + sessionsData.splice(0, sessionsData.length) + const renderSessions = vi.fn(() =>

Custom layout

) + + render( + , + ) + + expect(screen.getByText('Custom layout')).toBeTruthy() + expect(screen.queryByText('No sessions yet')).toBeNull() + expect(renderSessions).toHaveBeenCalledWith(expect.objectContaining({ sessions: [], searchQuery: '' })) + }) + it('shows loading state instead of create-session card when the first page is empty but more pages are pending', () => { sessionsData.splice(0, sessionsData.length) hasNextPageRef.current = true diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 76ac6c2cf..07f04bfe9 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useMemo, useEffect, useRef } from "react"; +import { useCallback, useState, useMemo, useEffect, useRef, type ReactNode } from "react"; import { useSessionsAcrossDirectories, useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; import type { DeleteSessionTarget } from "@/hooks/useOpenCode"; import type { Session } from "@/api/types"; @@ -19,6 +19,17 @@ interface SessionListProps { directoryLabels?: Record; activeSessionID?: string; onSelectSession: (sessionID: string) => void; + renderSessions?: (args: SessionListRenderArgs) => ReactNode; +} + +/** + * Lets a caller lay out the loaded root sessions itself, for example grouped by worktree. + * Search, paging, pinning, selection and delete stay owned by SessionList. + */ +export interface SessionListRenderArgs { + sessions: Session[]; + searchQuery: string; + renderSessionCard: (session: Session) => ReactNode; } export const SessionList = ({ @@ -28,6 +39,7 @@ export const SessionList = ({ directoryLabels, activeSessionID, onSelectSession, + renderSessions, }: SessionListProps) => { const directoriesList = useMemo(() => { const source = directories && directories.length > 0 ? directories : directory ? [directory] : []; @@ -118,7 +130,7 @@ export const SessionList = ({ if (hasNextPage || isFetchingNextPage) { return
Loading sessions...
; } - if (!searchQuery.trim()) { + if (!searchQuery.trim() && !renderSessions) { return (
- {filteredSessions.length === 0 && !isFetchingNextPage ? ( + {renderSessions ? ( + renderSessions({ + sessions: filteredSessions, + searchQuery, + renderSessionCard: (session) => renderSessionCard(session, pinnedKeys.has(getSessionSelectionKey(session))), + }) + ) : filteredSessions.length === 0 && !isFetchingNextPage ? (
No sessions found
diff --git a/frontend/src/components/terminal/TerminalPanel.tsx b/frontend/src/components/terminal/TerminalPanel.tsx index 16b08dfbd..8f402259f 100644 --- a/frontend/src/components/terminal/TerminalPanel.tsx +++ b/frontend/src/components/terminal/TerminalPanel.tsx @@ -28,7 +28,7 @@ export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPa const isMobile = useMobile() const { searchParams, updateParams } = useUrlParams() const openPreview = useOpenPreview() - const { data, isLoading, isSuccess, isFetching, refetch } = useTerminals(repoId, directory, { + const { data, isLoading, isSuccess, isFetching, refetch, error } = useTerminals(repoId, directory, { enabled: isOpen && !!directory, refetchInterval: isOpen ? 5000 : false, }) @@ -186,7 +186,7 @@ export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPa
{terminals.length === 0 ? (
- {isLoading || isCreating ? 'Starting terminal...' : 'No terminals'} + {error && !isCreating ? `Could not load terminals: ${error.message}` : isLoading || isCreating ? 'Starting terminal...' : 'No terminals'}
) : ( { - if (!directory) { + const targetDirectory = data.directory ?? directory; + if (!targetDirectory) { throw new Error('A directory is required to create a session'); } - return createSession({ directory, ...data }); + return createSession({ ...data, directory: targetDirectory }); }, onSuccess: (session) => { invalidateSessionListCaches(queryClient); diff --git a/frontend/src/hooks/useOpenTerminal.ts b/frontend/src/hooks/useOpenTerminal.ts index 93e39e203..d1ec9f056 100644 --- a/frontend/src/hooks/useOpenTerminal.ts +++ b/frontend/src/hooks/useOpenTerminal.ts @@ -3,21 +3,29 @@ import { openDialogParam, useDialogParam } from './useDialogParam' import { useUrlParams } from './useUrlParams' const TERMINAL_DIALOG = 'terminal' -const TERMINAL_DIALOG_PARAMS = ['terminal'] as const +const TERMINAL_DIALOG_PARAMS = ['terminal', 'terminalDirectory'] as const export function useTerminalDialogParam(): [boolean, (open: boolean) => void] { return useDialogParam(TERMINAL_DIALOG, TERMINAL_DIALOG_PARAMS) } +/** + * The directory the terminal dialog was opened for, such as a repo worktree; null means the repo itself. + */ +export function useTerminalDirectoryParam(): string | null { + const { searchParams } = useUrlParams() + return searchParams.get('terminalDirectory') +} + export function useOpenTerminal(): ( - terminalId: string, + terminalId: string | null, extraParams?: Record, ) => void { const { updateParams } = useUrlParams() return useCallback( - (terminalId: string, extraParams?: Record) => { - openDialogParam(updateParams, TERMINAL_DIALOG, { terminal: terminalId, ...extraParams }) + (terminalId: string | null, extraParams?: Record) => { + openDialogParam(updateParams, TERMINAL_DIALOG, { ...(terminalId ? { terminal: terminalId } : {}), ...extraParams }) }, [updateParams], ) diff --git a/frontend/src/hooks/useRepoSiblings.ts b/frontend/src/hooks/useRepoSiblings.ts index be1c17f98..f47361b71 100644 --- a/frontend/src/hooks/useRepoSiblings.ts +++ b/frontend/src/hooks/useRepoSiblings.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createRepoWorkspace, deleteRepoWorkspace, getRepoSiblings, type RepoSibling } from '@/api/repos' import { showToast } from '@/lib/toast' +import type { CreateRepoWorkspaceRequest } from '@opencode-manager/shared/types' export function repoSiblingsQueryKey(repoId: number | undefined) { return ['repo', 'siblings', repoId] as const @@ -24,22 +25,25 @@ export function useDeleteRepoWorkspaces(repoId: number | undefined) { const results = await Promise.allSettled( directories.map((directory) => deleteRepoWorkspace(repoId, directory)), ) - const failed = results.filter((result) => result.status === 'rejected').length - return { total: directories.length, failed } + const failures = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])) + return { total: directories.length, failed: failures.length, firstError: failures[0] instanceof Error ? failures[0].message : null } }, - onSuccess: ({ total, failed }) => { + onSuccess: ({ total, failed, firstError }) => { queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(repoId) }) + queryClient.invalidateQueries({ queryKey: ['schedule-worktrees'] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) const deleted = total - failed + const reason = firstError ? `: ${firstError}` : '' if (failed === 0) { - showToast.success(deleted === 1 ? 'Workspace deleted' : `${deleted} workspaces deleted`) + showToast.success(deleted === 1 ? 'Worktree deleted' : `${deleted} worktrees deleted`) } else if (deleted === 0) { - showToast.error('Failed to delete workspaces') + showToast.error(`Failed to delete worktrees${reason}`) } else { - showToast.error(`Deleted ${deleted}, failed ${failed}`) + showToast.error(`Deleted ${deleted}, failed ${failed}${reason}`) } }, onError: () => { - showToast.error('Failed to delete workspaces') + showToast.error('Failed to delete worktrees') }, }) } @@ -48,16 +52,16 @@ export function useCreateRepoWorkspace(repoId: number | undefined) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async () => { + mutationFn: async (request: CreateRepoWorkspaceRequest = {}) => { if (!repoId) throw new Error('Repo id is required') - return createRepoWorkspace(repoId) + return createRepoWorkspace(repoId, request) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(repoId) }) - showToast.success('Workspace created') + showToast.success('Worktree created') }, - onError: () => { - showToast.error('Failed to create workspace') + onError: (error) => { + showToast.error(`Failed to create worktree: ${error.message}`) }, }) } diff --git a/frontend/src/hooks/useSchedules.ts b/frontend/src/hooks/useSchedules.ts index 0102ee4e3..70957e40f 100644 --- a/frontend/src/hooks/useSchedules.ts +++ b/frontend/src/hooks/useSchedules.ts @@ -11,9 +11,11 @@ import { listAllScheduleRuns, listAllSchedules, listRepoScheduleRuns, + listScheduleWorktrees, listUnreadScheduleRuns, markAllScheduleRunsViewed, markScheduleRunViewed, + removeScheduleWorktrees, runRepoSchedule, updateRepoSchedule, } from '@/api/schedules' @@ -228,6 +230,7 @@ export function useClearRepoScheduleRuns() { onSuccess: (result, variables) => { queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId, variables.jobId] }) queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['schedule-worktrees', variables.repoId, variables.jobId] }) showToast.success(result.cleared > 0 ? `Cleared ${result.cleared} run${result.cleared === 1 ? '' : 's'}` : 'No runs to clear') }, onError: (error: unknown) => { @@ -246,6 +249,7 @@ export function useDeleteRepoScheduleRun() { onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId, variables.jobId] }) queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['schedule-worktrees', variables.repoId, variables.jobId] }) showToast.success('Run deleted') }, onError: (error: unknown) => { @@ -253,3 +257,35 @@ export function useDeleteRepoScheduleRun() { }, }) } + +export function useScheduleWorktrees(repoId: number | undefined, jobId: number | null) { + return useQuery({ + queryKey: ['schedule-worktrees', repoId, jobId], + queryFn: async () => { + const response = await listScheduleWorktrees(repoId!, jobId!) + return response.worktrees + }, + enabled: repoId !== undefined && jobId !== null, + refetchInterval: jobId !== null ? 10000 : false, + }) +} + +export function useRemoveScheduleWorktrees() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ repoId, jobId, worktreePath }: { repoId: number; jobId: number; worktreePath?: string }) => { + return removeScheduleWorktrees(repoId, jobId, worktreePath) + }, + onSuccess: (result, variables) => { + queryClient.invalidateQueries({ queryKey: ['schedule-worktrees', variables.repoId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + queryClient.invalidateQueries({ queryKey: ['repo', 'siblings'] }) + showToast.success(`Removed ${result.removed} worktree${result.removed === 1 ? '' : 's'}`) + }, + onError: (error: unknown) => { + showToast.error(`Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 26ce632ab..9969cbb2f 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -4,7 +4,7 @@ import { useAllSchedules, useAllScheduleRuns, useCancelRepoScheduleRun, useUnrea import { useDeleteRepoSchedule, useRunRepoSchedule, useUpdateRepoSchedule, useCreateRepoSchedule } from '@/hooks/useSchedules' import { ScheduleJobDialog, PromptsTab, ScheduleJobsTable, ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable } from '@/components/schedules' import type { CreateScheduleJobRequest } from '@opencode-manager/shared/types' -import { matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' +import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' import { Header } from '@/components/ui/header' import { Button } from '@/components/ui/button' import { ScheduleReportsBell } from '@/components/notifications/ScheduleReportsBell' @@ -779,7 +779,7 @@ export function GlobalSchedules() { onConfirm={handleDelete} onCancel={closeDialog} title="Delete Schedule" - description="This removes the job definition and all recorded run history for it." + description={DELETE_SCHEDULE_DESCRIPTION} isDeleting={deleteMutation.isPending} />
diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index d031a2ab3..9ebd3be20 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, type FormEvent } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { getRepo, workspaceLabel } from "@/api/repos"; -import { SessionList } from "@/components/session/SessionList"; +import { getRepo } from "@/api/repos"; +import { SessionList, type SessionListRenderArgs } from "@/components/session/SessionList"; import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; import { Header } from "@/components/ui/header"; import { RepoMcpDialog } from "@/components/repo/RepoMcpDialog"; @@ -18,11 +18,14 @@ import { useRepoActivity } from "@/hooks/useRepoActivity"; import { useCreateRepoWorkspace, useDeleteRepoWorkspaces, useRepoSiblings } from "@/hooks/useRepoSiblings"; import { useSSE } from "@/hooks/useSSE"; import { useDialogParam } from "@/hooks/useDialogParam"; -import { useOpenTerminal, useTerminalDialogParam } from "@/hooks/useOpenTerminal"; +import { useOpenTerminal, useTerminalDialogParam, useTerminalDirectoryParam } from "@/hooks/useOpenTerminal"; import { useWorktreeTab } from "@/hooks/useWorktreeTab"; import { WorktreeTabs } from "@/components/repo/WorktreeTabs"; -import { WorkspaceManager } from "@/components/repo/WorkspaceManager"; +import { WorktreeSessionGroups } from "@/components/repo/WorktreeSessionGroups"; +import { useAllSchedules } from "@/hooks/useSchedules"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { GitBranch, Plus, Loader2, Layers, Columns3 } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -30,7 +33,7 @@ import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; import { getRepoDisplayName } from "@/lib/utils"; import { notifyWorktreeSetup } from "@/lib/worktreeSetup"; -import { isWorktreeSibling } from "@opencode-manager/shared/utils"; +import { getRepoDirectoryNameError, isWorktreeSibling } from "@opencode-manager/shared/utils"; export function RepoDetail() { const { id } = useParams<{ id: string }>(); @@ -46,10 +49,9 @@ export function RepoDetail() { const [resetPermissionsOpen, setResetPermissionsOpen] = useDialogParam('resetPermissions'); const [multiRunOpen, setMultiRunOpen] = useDialogParam('multiRun'); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false); - const [workspaceSelectorOpen, setWorkspaceSelectorOpen] = useState(false); - const [activeWorkspaceDirectory, setActiveWorkspaceDirectory] = useState(); const { activeTab, setActiveTab } = useWorktreeTab(); const openTerminal = useOpenTerminal(); + const terminalDirectory = useTerminalDirectoryParam(); const { data: repo, isLoading: repoLoading } = useQuery({ queryKey: ["repo", repoId], @@ -73,6 +75,15 @@ export function RepoDetail() { [workspaceSiblings], ); + const { data: allSchedules } = useAllSchedules(); + const scheduleNames = useMemo(() => { + const names: Record = {}; + (allSchedules ?? []).forEach((job) => { + if (job.repoId === repoId) names[job.id] = job.name; + }); + return names; + }, [allSchedules, repoId]); + const baseDirectory = repo?.fullPath; const subscriptionDirectories = useMemo(() => { const set = new Set(); @@ -81,89 +92,62 @@ export function RepoDetail() { return Array.from(set); }, [baseDirectory, workspaceDirectories]); - useEffect(() => { - if (workspaceDirectories.length === 0) { - setActiveWorkspaceDirectory(undefined); - return; - } - - setActiveWorkspaceDirectory((current) => ( - current && workspaceDirectories.includes(current) ? current : workspaceDirectories[0] - )); - }, [workspaceDirectories]); - - const workspaceComposerDirectory = activeWorkspaceDirectory ?? workspaceDirectories[0]; - const sessionListDirectories = activeTab === 'workspaces' ? workspaceDirectories : (baseDirectory ? [baseDirectory] : []); - const composerDirectory = activeTab === 'workspaces' ? workspaceComposerDirectory : baseDirectory; - - const directoryLabels = useMemo(() => { - const labels: Record = {}; - workspaceSiblings.forEach((sibling) => { - if (sibling.fullPath) { - labels[sibling.fullPath] = workspaceLabel(sibling); - } - }); - return labels; - }, [workspaceSiblings]); - - const activeWorkspaceLabel = activeWorkspaceDirectory ? directoryLabels[activeWorkspaceDirectory] : undefined; + const showWorktrees = activeTab === 'workspaces'; + const sessionListDirectories = showWorktrees ? workspaceDirectories : (baseDirectory ? [baseDirectory] : []); useSSE(subscriptionDirectories); const sessionUrl = useCallback( - (sessionId: string) => { + (sessionId: string, inWorktree: boolean) => { const base = `/repos/${repoId}/sessions/${sessionId}`; - return activeTab === 'workspaces' ? `${base}?repoTab=workspaces` : base; + return inWorktree ? `${base}?repoTab=workspaces` : base; }, - [repoId, activeTab], + [repoId], ); - const createSessionMutation = useCreateSession(composerDirectory, (session) => { - navigate(sessionUrl(session.id)); - }); + const createSessionMutation = useCreateSession(baseDirectory); - const handleCreateSession = async (options?: { - agentSlug?: string; - promptSlug?: string; - }) => { - if (activeTab === 'workspaces' && !workspaceComposerDirectory) { - setCreateWorkspaceOpen(true); - return; - } - - await createSessionMutation.mutateAsync({ - agent: options?.agentSlug, + const handleCreateSession = (directory = baseDirectory) => { + createSessionMutation.mutate({ directory }, { + onSuccess: (session) => navigate(sessionUrl(session.id, directory !== baseDirectory)), }); }; - const handleCreateWorkspace = async () => { - const workspace = await createWorkspace.mutateAsync(); - if (workspace.directory) { - setActiveWorkspaceDirectory(workspace.directory); - } - setCreateWorkspaceOpen(false); - notifyWorktreeSetup(workspace.worktreeSetup); - if (workspace.worktreeSetup?.status === 'started') { - const terminalId = workspace.worktreeSetup.terminal.id; - openTerminal(terminalId, { repoTab: 'workspaces' }); - return; - } - setActiveTab('workspaces'); - }; - - const handleOpenWorkspaceSelector = () => { - if (workspaceSiblings.length === 0) { - setCreateWorkspaceOpen(true); - return; - } - setActiveTab('workspaces'); - setWorkspaceSelectorOpen(true); + const handleCreateWorkspace = (name: string) => { + createWorkspace.mutate(name ? { name } : {}, { + onSuccess: (workspace) => { + setCreateWorkspaceOpen(false); + notifyWorktreeSetup(workspace.worktreeSetup); + if (workspace.worktreeSetup?.status === 'started') { + const terminalId = workspace.worktreeSetup.terminal.id; + openTerminal(terminalId, { repoTab: 'workspaces', ...(workspace.directory ? { terminalDirectory: workspace.directory } : {}) }); + return; + } + setActiveTab('workspaces'); + }, + }); }; const handleSelectSession = (sessionId: string) => { - navigate(sessionUrl(sessionId)); + navigate(sessionUrl(sessionId, showWorktrees)); }; + const renderWorktreeGroups = ({ sessions, searchQuery, renderSessionCard }: SessionListRenderArgs) => ( + openTerminal(null, { repoTab: "workspaces", terminalDirectory: directory })} + onCreateWorktree={() => setCreateWorkspaceOpen(true)} + onDelete={(directories) => deleteWorkspaces.mutate(directories)} + isDeleting={deleteWorkspaces.isPending} + /> + ); + if (repoLoading) { return (
@@ -218,7 +202,7 @@ export function RepoDetail() {
- +
- - + + + + + ); diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 018214243..c1349d3d7 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -17,7 +17,7 @@ import { useRepoActivity } from '@/hooks/useRepoActivity' import { useScheduleTarget } from '@/hooks/useScheduleTarget' import { useScheduleUrlState } from '@/hooks/useScheduleUrlState' import { ScheduleJobDialog, ScheduleJobsTable, ScheduleListToolbar, JobDetailTab, RunHistoryTab, ScheduleTabMenu } from '@/components/schedules' -import { matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' +import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' import type { ScheduleJobWithRepo } from '@/api/schedules' import { Header } from '@/components/ui/header' import { Button } from '@/components/ui/button' @@ -370,7 +370,7 @@ export function Schedules() { onConfirm={handleDelete} onCancel={() => closeDialog()} title="Delete Schedule" - description="This removes the job definition and all recorded run history for it." + description={DELETE_SCHEDULE_DESCRIPTION} isDeleting={deleteMutation.isPending} /> diff --git a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx index a37dd83c8..46c84bec6 100644 --- a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx +++ b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx @@ -14,7 +14,8 @@ const mocks = vi.hoisted(() => ({ useRepoActivity: vi.fn(), useSSE: vi.fn(), useCreateSession: vi.fn(), - createWorkspaceMutateAsync: vi.fn(), + createWorkspaceMutate: vi.fn(), + createWorkspaceResult: vi.fn(), showToastInfo: vi.fn(), showToastWarning: vi.fn(), })) @@ -63,7 +64,7 @@ vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: () vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsDialog: () => null })) vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsDialog: () => null })) vi.mock('@/components/source-control', () => ({ SourceControlPanel: () => null })) -vi.mock('@/components/repo/WorkspaceManager', () => ({ WorkspaceManager: () => null })) +vi.mock('@/hooks/useSchedules', () => ({ useAllSchedules: () => ({ data: [] }) })) vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDialog: () => null })) vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null })) @@ -106,12 +107,15 @@ async function startWorkspaceCreation() { const user = userEvent.setup() renderRepoDetail() await user.click(await screen.findByRole('button', { name: 'New workspace' })) - await user.click(await screen.findByRole('button', { name: 'Create Workspace' })) + await user.click(await screen.findByRole('button', { name: 'Create Worktree' })) } describe('RepoDetail worktree setup', () => { beforeEach(() => { vi.clearAllMocks() + mocks.createWorkspaceMutate.mockImplementation((_request: unknown, options: { onSuccess: (value: unknown) => void }) => { + options.onSuccess(mocks.createWorkspaceResult()) + }) mocks.getRepo.mockResolvedValue(baseRepo) mocks.useRepoSiblings.mockReturnValue({ data: siblingsData }) mocks.useDeleteRepoWorkspaces.mockReturnValue({ mutate: vi.fn(), isPending: false }) @@ -120,10 +124,10 @@ describe('RepoDetail worktree setup', () => { it('switches to the new workspace terminal when setup starts from the repo tab', async () => { mocks.useCreateRepoWorkspace.mockReturnValue({ - mutateAsync: mocks.createWorkspaceMutateAsync, + mutate: mocks.createWorkspaceMutate, isPending: false, }) - mocks.createWorkspaceMutateAsync.mockResolvedValue({ + mocks.createWorkspaceResult.mockReturnValue({ directory: newWorkspaceDirectory, worktreeSetup: { status: 'started', @@ -154,10 +158,10 @@ describe('RepoDetail worktree setup', () => { it('stays on the workspace tab without a terminal when setup does not start', async () => { mocks.useCreateRepoWorkspace.mockReturnValue({ - mutateAsync: mocks.createWorkspaceMutateAsync, + mutate: mocks.createWorkspaceMutate, isPending: false, }) - mocks.createWorkspaceMutateAsync.mockResolvedValue({ + mocks.createWorkspaceResult.mockReturnValue({ directory: newWorkspaceDirectory, worktreeSetup: { status: 'none' }, }) @@ -168,9 +172,25 @@ describe('RepoDetail worktree setup', () => { expect(screen.getByTestId('location')).toHaveTextContent('repoTab=workspaces'), ) expect(screen.getByTestId('location')).not.toHaveTextContent('dialog=terminal') - expect(screen.getByTestId('terminal-panel')).toHaveAttribute( - 'data-directory', - newWorkspaceDirectory, - ) + expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-open', 'false') + }) + + it('sends the typed worktree name and blocks names that are not a single folder', async () => { + mocks.useCreateRepoWorkspace.mockReturnValue({ mutate: mocks.createWorkspaceMutate, isPending: false }) + mocks.createWorkspaceResult.mockReturnValue({ directory: newWorkspaceDirectory, worktreeSetup: { status: 'none' } }) + const user = userEvent.setup() + renderRepoDetail() + + await user.click(await screen.findByRole('button', { name: 'New workspace' })) + const nameInput = await screen.findByLabelText('Name') + await user.type(nameInput, '../escape') + expect(screen.getByRole('button', { name: 'Create Worktree' })).toBeDisabled() + expect(screen.getByText('Directory name cannot contain dot-dot path segments')).toBeInTheDocument() + + await user.clear(nameInput) + await user.type(nameInput, 'feature-login') + await user.click(screen.getByRole('button', { name: 'Create Worktree' })) + + expect(mocks.createWorkspaceMutate).toHaveBeenCalledWith({ name: 'feature-login' }, expect.anything()) }) }) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 836ba3cf9..0f1fd2e8f 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { getRepoDirectoryNameError } from '../utils/repo' export const RepoStatusSchema = z.enum(['cloning', 'ready', 'error']) @@ -54,6 +55,13 @@ export const UpdateRepoRequestSchema = z.object({ name: z.string().trim().max(100).nullable(), }) +export const CreateRepoWorkspaceRequestSchema = z.object({ + name: z.string().trim().optional().superRefine((name, ctx) => { + const error = name ? getRepoDirectoryNameError(name) : null + if (error) ctx.addIssue({ code: 'custom', message: error }) + }), +}) + export const DeleteRepoRequestSchema = z.object({ deleteBranch: z.enum(['none', 'local', 'local-and-remote']).default('none'), }) diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index 37be72454..d2a0878a8 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -9,6 +9,32 @@ export type ScheduleRunStatus = z.infer export const ScheduleModeSchema = z.enum(['interval', 'cron']) export type ScheduleMode = z.infer +/** + * Where a scheduled run executes. `worktree` gives every run a fresh worktree that is + * removed when the run ends, `kept-worktree` keeps each run's worktree afterwards, + * `shared-worktree` reuses one worktree and branch for every run of the schedule, and + * `repo` runs directly in the repository checkout without isolation. + */ +export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree', 'repo']) +export type ScheduleWorkspaceMode = z.infer + +/** + * A schedule worktree that still exists on disk. `runId` is null for the schedule's + * shared worktree, and `inUse` is true while a running run is working in it. + */ +export const ScheduleWorktreeSchema = z.object({ + worktreePath: z.string(), + branch: z.string(), + runId: z.number().nullable(), + inUse: z.boolean(), +}) +export type ScheduleWorktree = z.infer + +export const RemoveScheduleWorktreesRequestSchema = z.object({ + worktreePath: z.string().min(1).optional(), +}) +export type RemoveScheduleWorktreesRequest = z.infer + export const ScheduleSkillMetadataSchema = z.object({ skillSlugs: z.array(z.string().min(1).max(100)).default([]), notes: z.string().max(2000).optional(), @@ -153,6 +179,7 @@ export const ScheduleJobSchema = z.object({ permissionConfig: SchedulePermissionConfigSchema.nullable(), mcpServers: ScheduleMcpServersSchema, branch: z.string().nullable(), + workspaceMode: ScheduleWorkspaceModeSchema, createdAt: z.number(), updatedAt: z.number(), lastRunAt: z.number().nullable(), @@ -192,6 +219,7 @@ const ScheduleJobBaseRequestSchema = z.object({ permissionConfig: SchedulePermissionConfigSchema.nullable().optional(), mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), + workspaceMode: ScheduleWorkspaceModeSchema.optional(), }) export const CreateScheduleJobRequestSchema = z.discriminatedUnion('scheduleMode', [ @@ -222,6 +250,7 @@ export const UpdateScheduleJobRequestSchema = z.object({ permissionConfig: SchedulePermissionConfigSchema.nullable().optional(), mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), + workspaceMode: ScheduleWorkspaceModeSchema.optional(), }) export type UpdateScheduleJobRequest = z.infer diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 689fbf483..5375c0dfe 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -18,6 +18,7 @@ import { DiscoverReposRequestSchema, DiscoverReposResponseSchema, UpdateRepoRequestSchema, + CreateRepoWorkspaceRequestSchema, DeleteRepoRequestSchema, DeleteRepoResultSchema, RepoStatusSchema, @@ -89,6 +90,7 @@ export type DiscoverReposRequest = z.infer export type DiscoverReposResponse = z.infer export type RepoStatus = z.infer export type UpdateRepoRequest = z.infer +export type CreateRepoWorkspaceRequest = z.infer export type DeleteRepoRequest = z.infer export type DeleteRepoResult = z.infer export type AssistantModeStatus = z.infer @@ -152,6 +154,9 @@ export type { export type { ScheduleMode, + ScheduleWorkspaceMode, + ScheduleWorktree, + RemoveScheduleWorktreesRequest, SchedulePermissionConfig, ScheduleRunTriggerSource, ScheduleRunStatus, diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index 50d1ad979..662ed20b8 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -87,8 +87,24 @@ export function getBranchNameError(name: string): string | null { return null } -export function isWorktreeSibling(sibling: { worktreeStrategy?: string }): boolean { - return sibling.worktreeStrategy !== undefined +/** + * Who owns a repo worktree: OpenCode, a schedule, or nobody (a plain git worktree). + */ +export type RepoWorktreeSource = 'opencode' | 'schedule' | 'git' + +/** + * The schedule a worktree belongs to. `runId` is null for the schedule's shared worktree, + * and `inUse` is true while a running run works in it. + */ +export interface RepoWorktreeSchedule { + repoId: number + jobId: number + runId: number | null + inUse: boolean +} + +export function isWorktreeSibling(sibling: { worktreeSource?: RepoWorktreeSource }): boolean { + return sibling.worktreeSource !== undefined } export function getRepoBaseDirectoryName(repo: { localPath: string; branch?: string; isWorktree?: boolean }): string { From 26ff08d8919af1b0521c092ab3d36502e07b01a8 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:02:29 -0400 Subject: [PATCH 2/4] feat(schedules): require worktrees and choose commit or discard on removal Drop the repo workspace mode so every scheduled run executes in a worktree. Clearing run history or deleting a run now asks whether to commit pending changes and keep the run branches, or force-remove the worktrees and delete the branches. Sibling repo listings carry the schedule name, and the repo detail workspace view subscribes to schedule worktree sessions only while expanded. Also unify schedule worktree path/branch helpers, refuse a run branch already checked out elsewhere, and clean up terminals when a worktree is removed. --- backend/src/db/schedules.ts | 20 +- backend/src/index.ts | 7 + backend/src/routes/repos.test.ts | 64 ++++--- backend/src/routes/repos.ts | 2 +- backend/src/routes/schedules.ts | 16 +- backend/src/routes/settings.test.ts | 36 +--- backend/src/services/assistant-mode.ts | 2 +- .../services/opencode-manager-tool-plugin.ts | 1 + backend/src/services/repo-workspace.ts | 21 ++- backend/src/services/repo.ts | 56 ++---- .../src/services/schedule-worktree-paths.ts | 58 ++++-- backend/src/services/schedule-worktree.ts | 117 ++++++++---- backend/src/services/schedules.ts | 97 +++++++--- backend/test/db/schedules.test.ts | 18 +- backend/test/index.test.ts | 1 + backend/test/routes/repos.test.ts | 2 +- backend/test/routes/schedules.test.ts | 40 ++++ .../opencode-manager-tool-plugin.test.ts | 15 ++ backend/test/services/repo-git.test.ts | 43 ++++- backend/test/services/repo-workspace.test.ts | 20 +- backend/test/services/repo.test.ts | 7 +- .../test/services/schedule-worktree.test.ts | 82 ++++++++- backend/test/services/schedules.test.ts | 134 ++++++++++++-- frontend/src/api/repos.ts | 14 +- frontend/src/api/schedules.ts | 16 +- .../repo/WorktreeSessionGroups.test.tsx | 29 ++- .../components/repo/WorktreeSessionGroups.tsx | 89 +++++++-- .../src/components/repo/WorktreeTabs.test.tsx | 1 - .../src/components/schedules/GeneralTab.tsx | 30 ++- .../src/components/schedules/JobDetailTab.tsx | 3 +- .../ScheduleRunRemovalDialog.test.tsx | 47 +++++ .../schedules/ScheduleRunRemovalDialog.tsx | 100 ++++++++++ frontend/src/components/schedules/index.ts | 1 + .../components/schedules/schedule-utils.ts | 5 - .../src/components/session/SessionCard.tsx | 8 - .../src/components/session/SessionList.tsx | 3 - frontend/src/hooks/useSchedules.ts | 12 +- .../src/lib/schedules/schedule-worktree.ts | 3 + frontend/src/pages/RepoDetail.tsx | 57 ++++-- frontend/src/pages/Schedules.tsx | 60 +++--- .../RepoDetail.schedule-sessions.test.tsx | 171 ++++++++++++++++++ .../RepoDetail.worktree-setup.test.tsx | 1 - .../src/pages/__tests__/Schedules.test.tsx | 4 + shared/src/schemas/schedule.ts | 15 +- shared/src/types/index.ts | 1 + shared/src/utils/repo.ts | 15 +- 46 files changed, 1170 insertions(+), 374 deletions(-) create mode 100644 frontend/src/components/schedules/ScheduleRunRemovalDialog.test.tsx create mode 100644 frontend/src/components/schedules/ScheduleRunRemovalDialog.tsx create mode 100644 frontend/src/lib/schedules/schedule-worktree.ts create mode 100644 frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index d04968fbd..db9f11f36 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -5,6 +5,7 @@ import { SchedulePermissionConfigSchema, ScheduleRunSchema, ScheduleSkillMetadataSchema, + ScheduleWorkspaceModeSchema, type ScheduleJob, type ScheduleMcpServer, type ScheduleMode, @@ -102,6 +103,11 @@ function parseMcpServers(raw: string | null): ScheduleMcpServer[] { } } +function normalizeWorkspaceMode(raw: string | null): ScheduleWorkspaceMode { + const result = ScheduleWorkspaceModeSchema.safeParse(raw) + return result.success ? result.data : 'worktree' +} + function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { return ScheduleJobSchema.parse({ id: row.id, @@ -120,7 +126,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { permissionConfig: parsePermissionConfig(row.permission_config), mcpServers: parseMcpServers(row.mcp_servers), branch: row.branch, - workspaceMode: row.workspace_mode ?? 'worktree', + workspaceMode: normalizeWorkspaceMode(row.workspace_mode), createdAt: row.created_at, updatedAt: row.updated_at, lastRunAt: row.last_run_at, @@ -304,9 +310,15 @@ export function listScheduleRunArtifactsByJob(db: Database, repoId: number, jobI })) } -export function getScheduleJobRepoId(db: Database, jobId: number): number | null { - const row = db.prepare('SELECT repo_id FROM schedule_jobs WHERE id = ?').get(jobId) as { repo_id: number } | undefined - return row?.repo_id ?? null +export interface ScheduleJobWorktreeOwner { + id: number + repoId: number + name: string +} + +export function listScheduleJobWorktreeOwners(db: Database): ScheduleJobWorktreeOwner[] { + const rows = db.prepare('SELECT id, repo_id, name FROM schedule_jobs').all() as Array<{ id: number; repo_id: number; name: string }> + return rows.map((row) => ({ id: row.id, repoId: row.repo_id, name: row.name })) } export function clearScheduleRunWorktreePath(db: Database, repoId: number, jobId: number, worktreePath: string): number { diff --git a/backend/src/index.ts b/backend/src/index.ts index d771af97f..4f0efb137 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -245,6 +245,13 @@ const terminalService = new TerminalService( const projectConfigService = new ProjectConfigService(db, createGitService(gitAuthService), gitAuthService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) +scheduleService.setWorktreeRemovedHandler(async (directory) => { + try { + await terminalService.removeAll(directory) + } catch (error) { + logger.warn(`Failed to remove terminals for schedule worktree ${directory}:`, error) + } +}) const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index d85e8e4a6..28b996191 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -18,6 +18,17 @@ import path from 'path' import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' +const tempDirs: string[] = [] + +function createTempDir(name?: string): string { + const root = mkdtempSync(path.join(tmpdir(), 'repos-test-')) + tempDirs.push(root) + if (!name) return root + const dir = path.join(root, name) + mkdirSync(dir, { recursive: true }) + return dir +} + beforeEach(() => { mock.module('../services/project-id-resolver', () => ({ resolveProjectId: (() => null) as any, @@ -27,6 +38,9 @@ beforeEach(() => { afterEach(() => { mock.restore() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } }) const stubGitAuthService = { @@ -50,7 +64,7 @@ function createTestApp( listSchedules: () => [], updateSchedule: () => {}, deleteSchedule: () => {}, - prepareRepoDelete: () => {}, + prepareRepoDelete: async () => {}, } as any const projectConfigService = new ProjectConfigService(db, createGitService(stubGitAuthService), stubGitAuthService) const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, stubGitAuthService, projectConfigService, terminalService, scheduleService) @@ -111,22 +125,23 @@ describe('GET /api/repos/:id/siblings', () => { })) createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const worktreeDir = createTempDir('plugin-workspace') const client = createStubOpenCodeClient() client.api.worktree.list = mock(async () => ([{ - directory: '/tmp/plugin-workspace', + directory: worktreeDir, strategy: 'plugin-strategy', }])) as any app = createTestApp(db, client) const res = await app.request('/repos/1/siblings') expect(res.status).toBe(200) - const data = await res.json() as Array<{ id: number; fullPath?: string; localPath?: string; worktreeStrategy?: string; currentBranch?: string }> + const data = await res.json() as Array<{ id: number; fullPath?: string; localPath?: string; worktreeSource?: string; currentBranch?: string }> expect(data).toHaveLength(2) expect(data[1]).toMatchObject({ id: -1, - fullPath: '/tmp/plugin-workspace', + fullPath: worktreeDir, localPath: 'plugin-workspace', - worktreeStrategy: 'plugin-strategy', + worktreeSource: 'opencode', }) expect(data[1]?.currentBranch).toBeUndefined() }) @@ -138,17 +153,18 @@ describe('GET /api/repos/:id/siblings', () => { })) createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const worktreeDir = createTempDir('duplicate-workspace') const client = createStubOpenCodeClient() client.api.worktree.list = mock(async () => ([ - { directory: '/tmp/duplicate-workspace', strategy: 'git' }, - { directory: '/tmp/duplicate-workspace/', strategy: 'git' }, + { directory: worktreeDir, strategy: 'git' }, + { directory: `${worktreeDir}/`, strategy: 'git' }, ])) as any app = createTestApp(db, client) const res = await app.request('/repos/1/siblings') expect(res.status).toBe(200) - const data = await res.json() as Array<{ worktreeStrategy?: string }> - expect(data.filter((entry) => entry.worktreeStrategy !== undefined)).toHaveLength(1) + const data = await res.json() as Array<{ worktreeSource?: string }> + expect(data.filter((entry) => entry.worktreeSource !== undefined)).toHaveLength(1) }) it('excludes a worktree pointing at the repo directory so it cannot be deleted', async () => { @@ -168,31 +184,33 @@ describe('GET /api/repos/:id/siblings', () => { const res = await app.request('/repos/1/siblings') expect(res.status).toBe(200) - const data = await res.json() as Array<{ id: number; worktreeStrategy?: string }> + const data = await res.json() as Array<{ id: number; worktreeSource?: string }> expect(data).toHaveLength(1) - expect(data.some((d) => d.worktreeStrategy !== undefined)).toBe(false) + expect(data.some((d) => d.worktreeSource !== undefined)).toBe(false) }) it('excludes a worktree that is a git main checkout so the main repo cannot be deleted', async () => { + const mainRepoDir = createTempDir('main-repo') + const featureDir = createTempDir('worktrees/feature-x') mock.module('../services/project-id-resolver', () => ({ resolveProjectId: (() => Promise.resolve('commit-A')) as any, isGitMainCheckout: ((dir: string) => - Promise.resolve(dir === '/Users/dev/main-repo')) as any, + Promise.resolve(dir === mainRepoDir)) as any, })) createRepo(db, { localPath: 'repo-wt', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) const client = createStubOpenCodeClient() client.api.worktree.list = mock(async () => ([ - { directory: '/Users/dev/main-repo', strategy: 'git' }, - { directory: '/Users/dev/worktrees/feature-x', strategy: 'git' }, + { directory: mainRepoDir, strategy: 'git' }, + { directory: featureDir, strategy: 'git' }, ])) as any app = createTestApp(db, client) const res = await app.request('/repos/1/siblings') expect(res.status).toBe(200) - const data = await res.json() as Array<{ worktreeStrategy?: string; fullPath?: string }> - expect(data.some((d) => d.fullPath === '/Users/dev/main-repo')).toBe(false) - expect(data.some((d) => d.fullPath === '/Users/dev/worktrees/feature-x')).toBe(true) + const data = await res.json() as Array<{ worktreeSource?: string; fullPath?: string }> + expect(data.some((d) => d.fullPath === mainRepoDir)).toBe(false) + expect(data.some((d) => d.fullPath === featureDir)).toBe(true) }) it('excludes repos with non-matching projectID', async () => { @@ -283,21 +301,22 @@ describe('DELETE /api/repos/:id/workspaces', () => { })) createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const worktreeDir = createTempDir('worktree-one') const remove = mock(async () => undefined) const client = createStubOpenCodeClient() - client.api.worktree.list = mock(async () => ([{ directory: '/tmp/worktree-one', strategy: 'git' }])) as any + client.api.worktree.list = mock(async () => ([{ directory: worktreeDir, strategy: 'git' }])) as any client.api.worktree.remove = remove as any const app = createTestApp(db, client) const res = await app.request('/repos/1/workspaces', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ directory: '/tmp/worktree-one' }), + body: JSON.stringify({ directory: worktreeDir }), }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true }) - expect(remove).toHaveBeenCalledWith({ projectID: 'commit-A', directory: '/tmp/worktree-one', force: true }) + expect(remove).toHaveBeenCalledWith({ projectID: 'commit-A', directory: worktreeDir, force: true }) }) it('returns 400 for a non-numeric repo id', async () => { @@ -401,8 +420,9 @@ describe('DELETE /api/repos/:id/workspaces', () => { })) createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const worktreeDir = createTempDir('worktree-one') const client = createStubOpenCodeClient() - client.api.worktree.list = mock(async () => ([{ directory: '/tmp/worktree-one', strategy: 'git' }])) as any + client.api.worktree.list = mock(async () => ([{ directory: worktreeDir, strategy: 'git' }])) as any client.api.worktree.remove = mock(async () => { throw Object.assign(new Error('cannot remove'), { name: 'WorktreeError', data: { message: 'cannot remove' } }) }) as any @@ -411,7 +431,7 @@ describe('DELETE /api/repos/:id/workspaces', () => { const res = await app.request('/repos/1/workspaces', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ directory: '/tmp/worktree-one' }), + body: JSON.stringify({ directory: worktreeDir }), }) expect(res.status).toBe(409) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 929c78213..96f295a42 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -404,7 +404,7 @@ app.get('/', async (c) => { } const removeWorktree = async () => { - scheduleService.prepareRepoDelete(id) + await scheduleService.prepareRepoDelete(id) await repoWorkspaces.removeRepoTerminals(repo) await repoService.deleteRepoFiles(database, id) } diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index 8c8d347d2..20f7bc7a3 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -2,8 +2,10 @@ import { Hono } from 'hono' import { CreateScheduleJobRequestSchema, RemoveScheduleWorktreesRequestSchema, + ScheduleRunWorktreesModeSchema, UpdateScheduleJobRequestSchema, } from '@opencode-manager/shared/schemas' +import type { ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' import { ScheduleService, ScheduleServiceError } from '../services/schedules' import { parseId, handleServiceError } from '../utils/route-helpers' @@ -37,6 +39,14 @@ function parseRunIdFilter(value: string | undefined): number | undefined { return parsed } +function parseWorktreesMode(value: string | undefined): ScheduleRunWorktreesMode { + const result = ScheduleRunWorktreesModeSchema.safeParse(value) + if (!result.success) { + throw new ScheduleServiceError('Invalid worktrees mode', 400) + } + return result.data +} + export function createScheduleRoutes(scheduleService: ScheduleService) { const app = new Hono() @@ -236,7 +246,8 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { try { const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) - const result = await scheduleService.clearRunHistory(repoId, jobId) + const worktrees = parseWorktreesMode(c.req.query('worktrees')) + const result = await scheduleService.clearRunHistory(repoId, jobId, worktrees) return c.json(result) } catch (error) { return handleServiceError(c, error, 'Failed to clear schedule run history', ScheduleServiceError) @@ -248,7 +259,8 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) - await scheduleService.deleteRun(repoId, jobId, runId) + const worktrees = parseWorktreesMode(c.req.query('worktrees')) + await scheduleService.deleteRun(repoId, jobId, runId, worktrees) return c.json({ success: true }) } catch (error) { return handleServiceError(c, error, 'Failed to delete schedule run', ScheduleServiceError) diff --git a/backend/src/routes/settings.test.ts b/backend/src/routes/settings.test.ts index 361137184..149feabb0 100644 --- a/backend/src/routes/settings.test.ts +++ b/backend/src/routes/settings.test.ts @@ -19,36 +19,19 @@ interface TestUserPreferenceRow { updated_at: number } -interface TestMigrationRow { - version: number - name: string - applied_at: number -} - interface StatementResult { - get?: (..._params: unknown[]) => TestUserPreferenceRow | TestMigrationRow | { count: number } | { name: string } | { user_id: string; preferences: string } | undefined + get?: (..._params: unknown[]) => TestUserPreferenceRow | { count: number } | { name: string } | { user_id: string; preferences: string } | undefined run?: (..._params: unknown[]) => { changes: number } all?: () => Array } class InMemoryDatabase { private userPreferences = new Map() - private schemaMigrations = new Map() private normalizeSql(sql: string): string { return sql.trim().toLowerCase().replace(/\s+/g, ' ') } - private getMigrationRows(): TestMigrationRow[] { - return [...this.schemaMigrations.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([version, value]) => ({ - version, - name: value.name, - applied_at: value.applied_at, - })) - } - private setUserPreference(userId: string, preferences: string, updatedAt: number): void { this.userPreferences.set(userId, { preferences, updated_at: updatedAt }) } @@ -56,22 +39,6 @@ class InMemoryDatabase { private createStatement(sql: string): StatementResult { const normalizedSql = this.normalizeSql(sql) - if (normalizedSql === 'select version from schema_migrations order by version') { - return { - all: () => this.getMigrationRows(), - } - } - - if (normalizedSql.startsWith('insert into schema_migrations')) { - return { - run: (...params: unknown[]) => { - const [version, name, appliedAt] = params as [number, string, number] - this.schemaMigrations.set(version, { name, applied_at: appliedAt }) - return { changes: 1 } - }, - } - } - if (normalizedSql === 'select preferences, updated_at from user_preferences where user_id = ?') { return { get: (...params: unknown[]) => { @@ -172,7 +139,6 @@ class InMemoryDatabase { close() { this.userPreferences.clear() - this.schemaMigrations.clear() } } diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 4391a2914..dfd0c08ce 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -400,7 +400,7 @@ Create a new schedule job. Body matches \`CreateScheduleJobRequest\` schema (discriminated union with \`scheduleMode: 'interval' | 'cron'\`). -Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fresh worktree removed after each run), \`kept-worktree\` (a fresh worktree kept after each run), \`shared-worktree\` (one worktree and branch reused by every run), or \`repo\` (the repository checkout itself). Kept and shared worktrees are listed with \`GET /repos/:repoId/schedules/:jobId/worktrees\` and removed with \`DELETE /repos/:repoId/schedules/:jobId/worktrees\` (optional body \`{ "worktreePath": "..." }\`; omit it to remove every idle worktree). Pending changes are committed to the worktree's branch before removal. +Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fresh worktree removed after each run), \`kept-worktree\` (a fresh worktree kept after each run), or \`shared-worktree\` (one worktree and branch reused by every run). Kept and shared worktrees are listed with \`GET /repos/:repoId/schedules/:jobId/worktrees\`. Removing schedule worktrees is done by the user in the Manager UI. \`\`\`json { diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index 786357de9..cb9990ea9 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -29,6 +29,7 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'GET /repos/*/schedules/*', 'PATCH /repos/*/schedules/*', 'DELETE /repos/*/schedules/*', + 'GET /repos/*/schedules/*/worktrees', 'POST /repos/*/schedules/*/run', 'GET /repos/*/schedules/*/runs', 'DELETE /repos/*/schedules/*/runs', diff --git a/backend/src/services/repo-workspace.ts b/backend/src/services/repo-workspace.ts index b09ea17a6..4a393b764 100644 --- a/backend/src/services/repo-workspace.ts +++ b/backend/src/services/repo-workspace.ts @@ -7,8 +7,7 @@ import type { OpenCodeClient } from './opencode/client' import type { ProjectConfigService } from './project-config' import type { TerminalService } from './terminal' import type { ScheduleService } from './schedules' -import { findSiblingByDirectory, getSiblingRepos, RepoWorkspaceError, resolveRepoProjectId } from './repo' -import { executeCommand } from '../utils/process' +import { findSiblingByDirectory, getSiblingRepos, removeWorktree, RepoWorkspaceError, resolveRepoProjectId } from './repo' import { getErrorMessage } from '../utils/error-utils' /** Single owner of repo worktree lifecycle side effects: worktree setup on create, and terminal cleanup plus owner-specific removal on remove. */ @@ -35,26 +34,28 @@ export class RepoWorkspaceService { /** * Removes a worktree of the repo the way its owner expects: OpenCode worktrees through - * OpenCode, schedule worktrees through the schedule (committing pending changes first), - * and plain git worktrees with `git worktree remove`, which refuses uncommitted changes. + * OpenCode, schedule worktrees through the schedule (which commits pending changes and + * removes their terminals first), and plain git worktrees with `git worktree remove`, + * which refuses uncommitted changes. */ async remove(repo: Repo, directory: string): Promise { const worktree = findSiblingByDirectory(await this.listWorktreeSiblings(repo.id), directory) if (!worktree) throw new RepoWorkspaceError('Not a deletable worktree of this repo', 400) if (worktree.schedule?.inUse) throw new RepoWorkspaceError('This worktree is in use by a running scheduled run. Cancel the run first.', 400) - await this.removeTerminals(worktree.fullPath) - if (worktree.schedule) { await this.scheduleWorktrees.removeWorktrees(worktree.schedule.repoId, worktree.schedule.jobId, worktree.fullPath) return } + await this.removeTerminals(worktree.fullPath) + if (worktree.worktreeSource === 'git') { - await executeCommand(['git', '-C', repo.fullPath, 'worktree', 'remove', worktree.fullPath], { env: this.gitAuthService.getGitEnvironment(true) }) - .catch((error: unknown) => { - throw new RepoWorkspaceError(`Could not remove worktree: ${getErrorMessage(error)}`, 400) - }) + try { + await removeWorktree(repo.fullPath, worktree.fullPath, this.gitAuthService.getGitEnvironment(true), { force: false }) + } catch (error) { + throw new RepoWorkspaceError(`Could not remove worktree: ${getErrorMessage(error)}`, 400) + } return } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 6b2092658..4b8ba817f 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -6,8 +6,8 @@ import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updat import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' -import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID, type RepoWorktreeSchedule, type RepoWorktreeSource } from '@opencode-manager/shared/utils' +import { getReposPath } from '@opencode-manager/shared/config/env' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN, ASSISTANT_REPO_ID, type RepoSibling, type RepoWorktreeSource } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -16,8 +16,7 @@ import { getErrorMessage } from '../utils/error-utils' import { sseAggregator } from './sse-aggregator' import { resolveProjectId, isGitMainCheckout } from './project-id-resolver' import { listRepos } from '../db/queries' -import { getScheduleJobRepoId, listRunningScheduleRuns } from '../db/schedules' -import { parseScheduleWorktreeName } from './schedule-worktree-paths' +import { createScheduleWorktreeDescriber } from './schedule-worktree-paths' import { SettingsService } from './settings' import { buildAssistantRepo } from './assistant-mode' import type { OpenCodeClient } from './opencode/client' @@ -1020,7 +1019,18 @@ export async function resolveDefaultBranch(repoPath: string, env: Record 'main') } -export async function removeWorktree(baseRepoPath: string, worktreePath: string, env?: Record): Promise { +export async function removeWorktree( + baseRepoPath: string, + worktreePath: string, + env?: Record, + options: { force?: boolean } = {}, +): Promise { + if (options.force === false) { + await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'remove', worktreePath], env ? { env } : undefined) + await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'prune'], env ? { env } : undefined).catch(() => {}) + return + } + try { await executeCommand(['git', '-C', baseRepoPath, 'worktree', 'remove', '--force', worktreePath], env ? { env } : undefined) } catch { @@ -1229,13 +1239,6 @@ export async function resolveRepoWorkingDirectory( return findSiblingByDirectory(await loadSiblings(), directory)?.fullPath ?? null } -export type RepoSibling = Repo & { - currentBranch: string | undefined - worktreeSource?: RepoWorktreeSource - worktreeStrategy?: string - schedule?: RepoWorktreeSchedule -} - export async function getSiblingRepos( database: Database, repoId: number, @@ -1330,7 +1333,6 @@ export async function getSiblingRepos( isLocal: true, currentBranch: candidate.branch, worktreeSource, - worktreeStrategy: candidate.strategy, ...(schedule ? { schedule } : {}), } }) @@ -1371,34 +1373,6 @@ export async function listGitWorktrees(repoPath: string, env: Record RepoWorktreeSchedule | undefined { - const scheduleRoot = canonicalPathSync(path.resolve(getScheduleWorktreesPath())) - const runningRuns = listRunningScheduleRuns(database) - const runningByDirectory = new Map(runningRuns.flatMap((run) => ( - run.worktreePath ? [[canonicalPathSync(path.resolve(run.worktreePath)), run] as const] : [] - ))) - - return (directory) => { - const running = runningByDirectory.get(directory) - const parsed = path.dirname(directory) === scheduleRoot ? parseScheduleWorktreeName(path.basename(directory)) : null - const jobId = parsed?.jobId ?? running?.jobId - if (jobId === undefined) return undefined - const repoId = running?.repoId ?? getScheduleJobRepoId(database, jobId) - if (repoId === null) return undefined - return { - repoId, - jobId, - runId: parsed ? parsed.runId : running?.id ?? null, - inUse: running !== undefined || (parsed?.runId === null && runningRuns.some((run) => run.jobId === jobId)), - } - } -} - export class RepoWorkspaceError extends Error { readonly status: 400 diff --git a/backend/src/services/schedule-worktree-paths.ts b/backend/src/services/schedule-worktree-paths.ts index 2aeda228b..7f149ce95 100644 --- a/backend/src/services/schedule-worktree-paths.ts +++ b/backend/src/services/schedule-worktree-paths.ts @@ -1,22 +1,18 @@ import path from 'path' +import type { Database } from 'bun:sqlite' import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import type { RepoWorktreeSchedule } from '@opencode-manager/shared/utils' +import { listRunningScheduleRuns, listScheduleJobWorktreeOwners } from '../db/schedules' +import { canonicalPathSync } from '../utils/fs-safe' const SCHEDULE_WORKTREE_DIRECTORY = /^job-(\d+)-(?:run-(\d+)|shared)$/ -export function getSharedScheduleWorktreePath(jobId: number): string { - return path.join(getScheduleWorktreesPath(), `job-${jobId}-shared`) +export function getScheduleWorktreePath(jobId: number, runId: number | null): string { + return path.join(getScheduleWorktreesPath(), runId === null ? `job-${jobId}-shared` : `job-${jobId}-run-${runId}`) } -export function getRunScheduleWorktreePath(jobId: number, runId: number): string { - return path.join(getScheduleWorktreesPath(), `job-${jobId}-run-${runId}`) -} - -export function getSharedScheduleBranch(jobId: number): string { - return `schedule/${jobId}/shared` -} - -export function getRunScheduleBranch(jobId: number, runId: number): string { - return `schedule/${jobId}/run-${runId}` +export function getScheduleWorktreeBranch(jobId: number, runId: number | null): string { + return runId === null ? `schedule/${jobId}/shared` : `schedule/${jobId}/run-${runId}` } /** @@ -28,3 +24,41 @@ export function parseScheduleWorktreeName(name: string): { jobId: number; runId: if (!match) return null return { jobId: Number(match[1]), runId: match[2] ? Number(match[2]) : null } } + +/** + * Whether a path names a schedule's shared worktree, as opposed to one of its run worktrees. + */ +export function isSharedScheduleWorktreePath(worktreePath: string): boolean { + return parseScheduleWorktreeName(path.basename(worktreePath))?.runId === null +} + +/** + * Builds a lookup that says which schedule, if any, owns a canonical worktree directory: + * either a directory named for a schedule under the schedule worktree root, or the + * worktree a running run works in. Directories of deleted schedules are not owned. + */ +export function createScheduleWorktreeDescriber(database: Database): (directory: string) => RepoWorktreeSchedule | undefined { + const scheduleRoot = canonicalPathSync(path.resolve(getScheduleWorktreesPath())) + const jobsById = new Map(listScheduleJobWorktreeOwners(database).map((job) => [job.id, job] as const)) + const runningRuns = listRunningScheduleRuns(database) + const runningByDirectory = new Map(runningRuns.flatMap((run) => ( + run.worktreePath ? [[canonicalPathSync(path.resolve(run.worktreePath)), run] as const] : [] + ))) + + return (directory) => { + const canonicalDirectory = canonicalPathSync(path.resolve(directory)) + const running = runningByDirectory.get(canonicalDirectory) + const parsed = path.dirname(canonicalDirectory) === scheduleRoot ? parseScheduleWorktreeName(path.basename(canonicalDirectory)) : null + const jobId = parsed?.jobId ?? running?.jobId + if (jobId === undefined) return undefined + const job = jobsById.get(jobId) + if (!job) return undefined + return { + repoId: job.repoId, + jobId, + runId: parsed ? parsed.runId : (running?.id ?? null), + inUse: running !== undefined || (parsed?.runId === null && runningRuns.some((run) => run.jobId === jobId)), + name: job.name, + } + } +} diff --git a/backend/src/services/schedule-worktree.ts b/backend/src/services/schedule-worktree.ts index aa56c624d..118d50add 100644 --- a/backend/src/services/schedule-worktree.ts +++ b/backend/src/services/schedule-worktree.ts @@ -3,19 +3,18 @@ import path from 'path' import type { Database } from 'bun:sqlite' import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' -import type { ScheduleWorkspaceMode } from '@opencode-manager/shared/types' +import type { ScheduleRunWorktreesMode, ScheduleWorkspaceMode } from '@opencode-manager/shared/types' import type { Repo } from '../types/repo' import type { GitAuthService } from './git-auth' import { isSSHUrl } from '@opencode-manager/shared/utils' import { executeCommand } from '../utils/process' -import { resolveDefaultBranch, createWorktreeSafely, removeWorktree } from './repo' +import { resolveDefaultBranch, createWorktreeSafely, listGitWorktrees, removeWorktree } from './repo' import { logger } from '../utils/logger' -import { mkdirSyncSafe } from '../utils/fs-safe' +import { canonicalPathSync, mkdirSyncSafe } from '../utils/fs-safe' import { - getRunScheduleBranch, - getRunScheduleWorktreePath, - getSharedScheduleBranch, - getSharedScheduleWorktreePath, + getScheduleWorktreeBranch, + getScheduleWorktreePath, + isSharedScheduleWorktreePath, parseScheduleWorktreeName, } from './schedule-worktree-paths' @@ -35,10 +34,6 @@ export interface ScheduleWorktreeEntry { branch: string } -function isSharedWorktreePath(worktreePath: string): boolean { - return path.basename(worktreePath).endsWith('-shared') -} - /** * Build repo-context environment variables used by the GIT_ASKPASS handler * to resolve repo-specific credentials. Mirrors GitService.getEnvironmentForRepo. @@ -57,16 +52,17 @@ export class ScheduleWorktreeManager { ) {} /** - * Prepares the directory a run works in. Returns null when the run works in the - * repository checkout itself. A shared worktree that already exists is reused as-is; - * otherwise a worktree is created, continuing the shared branch when it already exists. + * Prepares the directory a run works in. Returns null when there is no worktree to + * prepare (the assistant repo or a directory that is not a git checkout). A shared + * worktree that already exists and is clean is reused as-is; otherwise a worktree is + * created, continuing the shared branch when it already exists. */ async prepare( repo: Repo, job: { id: number; branch: string | null; workspaceMode: ScheduleWorkspaceMode }, runId: number, ): Promise { - if (repo.id === ASSISTANT_REPO_ID || job.workspaceMode === 'repo') return null + if (repo.id === ASSISTANT_REPO_ID) return null try { await executeCommand(['git', '-C', repo.fullPath, 'rev-parse', '--is-inside-work-tree'], { silent: true }) @@ -75,8 +71,9 @@ export class ScheduleWorktreeManager { } const shared = job.workspaceMode === 'shared-worktree' - const runBranch = shared ? getSharedScheduleBranch(job.id) : getRunScheduleBranch(job.id, runId) - const worktreePath = shared ? getSharedScheduleWorktreePath(job.id) : getRunScheduleWorktreePath(job.id, runId) + const worktreeRunId = shared ? null : runId + const runBranch = getScheduleWorktreeBranch(job.id, worktreeRunId) + const worktreePath = getScheduleWorktreePath(job.id, worktreeRunId) let sshSetup = false if (repo.repoUrl && isSSHUrl(repo.repoUrl)) { @@ -88,6 +85,10 @@ export class ScheduleWorktreeManager { const env = await this.buildGitEnv(repo, sshSetup, true) if (shared && await this.isUsableWorktree(worktreePath, env)) { + const status = await executeCommand(['git', '-C', worktreePath, 'status', '--porcelain'], { env }).catch(() => '') + if (status.trim()) { + throw new Error(`Shared worktree ${worktreePath} has uncommitted changes made outside a scheduled run. Commit or discard them before the next run.`) + } return { directory: worktreePath, worktreePath, runBranch } } @@ -103,6 +104,8 @@ export class ScheduleWorktreeManager { await removeWorktree(repo.fullPath, worktreePath, env) } + await this.assertRunBranchAvailable(repo.fullPath, runBranch, worktreePath, env) + mkdirSyncSafe(path.dirname(worktreePath)) await createWorktreeSafely(repo.fullPath, worktreePath, runBranch, env, baseRef) @@ -132,7 +135,7 @@ export class ScheduleWorktreeManager { return { commitHash: null } } - const retain = isSharedWorktreePath(run.worktreePath) || job.workspaceMode === 'kept-worktree' + const retain = isSharedScheduleWorktreePath(run.worktreePath) || job.workspaceMode === 'kept-worktree' let sshSetup = false let env: Record | undefined let commitHash: string | null = null @@ -211,7 +214,7 @@ export class ScheduleWorktreeManager { jobId: parsed.jobId, runId: parsed.runId, worktreePath: path.join(getScheduleWorktreesPath(), name), - branch: parsed.runId === null ? getSharedScheduleBranch(parsed.jobId) : getRunScheduleBranch(parsed.jobId, parsed.runId), + branch: getScheduleWorktreeBranch(parsed.jobId, parsed.runId), }] }) } @@ -220,34 +223,62 @@ export class ScheduleWorktreeManager { * Removes leftover worktrees and deletes the run branches for a set of * finished runs. Used when clearing run history. The job's shared worktree and * branch are never touched, since they belong to the schedule rather than a run. - * Branch and worktree removal are local git operations, so no SSH setup is needed; - * failures are swallowed per artifact so one bad entry does not block the rest. + * In `commit` mode a run worktree still on disk is released through + * `releaseWorktree`, so its pending changes are committed and its branch kept; + * branches whose worktree is already gone are deleted. In `discard` mode the + * worktrees are force-removed and every run branch deleted. Branch and worktree + * removal are local git operations, so no SSH setup is needed; failures are + * swallowed per artifact so one bad entry does not block the rest. */ async pruneRunArtifacts( repo: Repo, - jobId: number, + job: { id: number; name: string }, artifacts: { runBranch: string | null; worktreePath: string | null }[], + mode: ScheduleRunWorktreesMode = 'commit', ): Promise { - const sharedPath = getSharedScheduleWorktreePath(jobId) - const sharedBranch = getSharedScheduleBranch(jobId) - const worktreePaths = artifacts - .map((a) => a.worktreePath) - .filter((p): p is string => p !== null && p !== sharedPath) + const sharedPath = getScheduleWorktreePath(job.id, null) + const sharedBranch = getScheduleWorktreeBranch(job.id, null) + const runWorktrees = artifacts + .map((artifact) => artifact.worktreePath) + .filter((worktreePath): worktreePath is string => worktreePath !== null && worktreePath !== sharedPath) const branches = artifacts - .map((a) => a.runBranch) - .filter((b): b is string => b !== null && b.length > 0 && b !== sharedBranch) + .map((artifact) => artifact.runBranch) + .filter((branch): branch is string => branch !== null && branch.length > 0 && branch !== sharedBranch) - if (worktreePaths.length === 0 && branches.length === 0) return + if (runWorktrees.length === 0 && branches.length === 0) return const env = await this.buildGitEnv(repo, false, true) - await Promise.all( - worktreePaths.map((worktreePath) => removeWorktree(repo.fullPath, worktreePath, env).catch(() => undefined)), - ) - - if (branches.length > 0) { - await executeCommand(['git', '-C', repo.fullPath, 'branch', '-D', ...branches], { env }).catch(() => {}) + if (mode === 'discard') { + await Promise.all(runWorktrees.map((worktreePath) => removeWorktree(repo.fullPath, worktreePath, env).catch(() => undefined))) + await this.deleteBranches(repo.fullPath, branches, env) + return } + + const branchByWorktree = new Map(artifacts.flatMap((artifact) => ( + artifact.worktreePath !== null && artifact.runBranch !== null + ? [[artifact.worktreePath, artifact.runBranch] as const] + : [] + ))) + const retainedBranches = new Set() + + await Promise.all(runWorktrees.map(async (worktreePath) => { + if (!existsSync(worktreePath)) return + const branch = branchByWorktree.get(worktreePath) + if (branch) retainedBranches.add(branch) + try { + await this.releaseWorktree(repo, job, worktreePath) + } catch (error) { + logger.error(`Failed to release schedule worktree ${worktreePath}:`, error) + } + })) + + await this.deleteBranches(repo.fullPath, branches.filter((branch) => !retainedBranches.has(branch)), env) + } + + private async deleteBranches(repoPath: string, branches: string[], env: Record): Promise { + if (branches.length === 0) return + await executeCommand(['git', '-C', repoPath, 'branch', '-D', ...branches], { env }).catch(() => {}) } private async commitPendingChanges( @@ -274,6 +305,20 @@ export class ScheduleWorktreeManager { } } + /** + * Refuses to create a worktree on a branch another checkout already has, which git + * would otherwise reject with an opaque error. + */ + private async assertRunBranchAvailable(repoPath: string, runBranch: string, worktreePath: string, env: Record): Promise { + const targetPath = canonicalPathSync(path.resolve(worktreePath)) + const conflicting = (await listGitWorktrees(repoPath, env)).find((worktree) => + worktree.branch === runBranch && canonicalPathSync(path.resolve(worktree.path)) !== targetPath, + ) + if (conflicting) { + throw new Error(`Branch ${runBranch} is checked out in ${conflicting.path}. Switch that checkout to another branch so the schedule can run.`) + } + } + /** * Resolves a user-supplied base branch name to a verified git ref, preferring * the remote-tracking branch for freshness. Returns null when neither the diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 9b35022e5..82b41da9d 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -7,6 +7,7 @@ import { type ScheduleJob, type ScheduleRun, type ScheduleRunTriggerSource, + type ScheduleRunWorktreesMode, type ScheduleWorktree, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' @@ -59,6 +60,7 @@ import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' import { canonicalPathSync } from '../utils/fs-safe' import { buildAssistantRepo } from './assistant-mode' +import { createScheduleWorktreeDescriber } from './schedule-worktree-paths' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' class ScheduleServiceError extends Error { @@ -72,6 +74,8 @@ class ScheduleServiceError extends Error { const SESSION_STOPPED_ERROR = 'The session stopped without producing an assistant response. This usually means OpenCode restarted mid-run or the session was interrupted. Open the linked session to inspect any partial output and rerun if needed.' +const DELETION_RESERVATION_RUN_ID = -1 + interface SessionSignal { errorText: string | null disposed: boolean @@ -227,6 +231,7 @@ export class ScheduleService { private static pendingCancels = new Map() private static activeTeardowns = new Set() private onJobChange: ((job: ScheduleJob | null, jobId: number) => void) | null = null + private onWorktreeRemoved: ((directory: string) => Promise) | null = null constructor( private readonly db: Database, @@ -238,6 +243,10 @@ export class ScheduleService { this.onJobChange = handler } + setWorktreeRemovedHandler(handler: ((directory: string) => Promise) | null): void { + this.onWorktreeRemoved = handler + } + private static abortActiveRun(jobId: number, runId: number): void { const active = ScheduleService.activeRuns.get(jobId) if (active?.runId === runId) { @@ -325,11 +334,13 @@ export class ScheduleService { } listAllJobsWithRepos(): Array { - const runningPaths = this.listRunningWorktreePaths() + const describeSchedule = createScheduleWorktreeDescriber(this.db) const counts = new Map() for (const entry of this.worktreeManager.listWorktrees()) { - if (runningPaths.has(entry.worktreePath)) continue - counts.set(entry.jobId, (counts.get(entry.jobId) ?? 0) + 1) + const described = describeSchedule(canonicalPathSync(path.resolve(entry.worktreePath))) + if (described?.inUse === false) { + counts.set(entry.jobId, (counts.get(entry.jobId) ?? 0) + 1) + } } return listAllScheduleJobsWithRepos(this.db).map((job) => ({ ...job, retainedWorktreeCount: counts.get(job.id) ?? 0 })) } @@ -340,16 +351,18 @@ export class ScheduleService { */ listWorktrees(repoId: number, jobId: number): ScheduleWorktree[] { this.assertJob(repoId, jobId) - const runningPaths = this.listRunningWorktreePaths() - const jobRunning = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, repoId, jobId) !== null + const describeSchedule = createScheduleWorktreeDescriber(this.db) return this.worktreeManager.listWorktrees(jobId) - .map((entry) => ({ - worktreePath: entry.worktreePath, - branch: entry.branch, - runId: entry.runId, - inUse: runningPaths.has(entry.worktreePath) || (entry.runId === null && jobRunning), - })) + .map((entry) => { + const described = describeSchedule(canonicalPathSync(path.resolve(entry.worktreePath))) + return { + worktreePath: entry.worktreePath, + branch: entry.branch, + runId: entry.runId, + inUse: described?.inUse ?? false, + } + }) .sort((left, right) => (right.runId ?? Number.MAX_SAFE_INTEGER) - (left.runId ?? Number.MAX_SAFE_INTEGER)) } @@ -383,15 +396,14 @@ export class ScheduleService { throw new ScheduleServiceError(`Failed to remove worktree ${target.worktreePath}: ${getErrorMessage(error)}`, 500) } clearScheduleRunWorktreePath(this.db, repoId, jobId, target.worktreePath) + if (this.onWorktreeRemoved) { + await this.onWorktreeRemoved(target.worktreePath) + } } return { removed: targets.length } } - private listRunningWorktreePaths(): Set { - return new Set(listRunningScheduleRuns(this.db).flatMap((run) => (run.worktreePath ? [run.worktreePath] : []))) - } - listAllRuns(options: ListAllRunsOptions = {}): ScheduleRunWithContext[] { const limit = Math.min(Math.max(options.limit ?? 20, 1), 100) const offset = Math.max(options.offset ?? 0, 0) @@ -460,6 +472,14 @@ export class ScheduleService { if (input.agentSlug !== undefined) { await this.assertAgentAvailable(repo.fullPath, input.agentSlug?.trim() || null) } + + if (input.workspaceMode !== undefined && input.workspaceMode !== existing.workspaceMode) { + const running = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, repoId, jobId) !== null + if (running) { + throw new ScheduleServiceError('Cannot change where runs execute while a run is in progress.', 409) + } + } + let job: ScheduleJob | null try { @@ -477,7 +497,7 @@ export class ScheduleService { async deleteJob(repoId: number, jobId: number): Promise { this.assertRepo(repoId) - this.assertJob(repoId, jobId) + const job = this.assertJob(repoId, jobId) if (ScheduleService.activeRuns.has(jobId)) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) @@ -488,17 +508,27 @@ export class ScheduleService { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } - await this.removeWorktrees(repoId, jobId) + this.onJobChange?.(null, jobId) + ScheduleService.activeRuns.set(jobId, { runId: DELETION_RESERVATION_RUN_ID, abort: new AbortController() }) - const deleted = deleteScheduleJob(this.db, repoId, jobId) - if (!deleted) { - throw new ScheduleServiceError('Schedule not found', 404) + try { + await this.removeWorktrees(repoId, jobId) + + const deleted = deleteScheduleJob(this.db, repoId, jobId) + if (!deleted) { + throw new ScheduleServiceError('Schedule not found', 404) + } + } catch (error) { + this.onJobChange?.(job, jobId) + throw error + } finally { + ScheduleService.releaseActiveRun(jobId, DELETION_RESERVATION_RUN_ID) } - this.onJobChange?.(null, jobId) } - prepareRepoDelete(repoId: number): void { + async prepareRepoDelete(repoId: number): Promise { const jobIds = listScheduleJobIdsByRepo(this.db, repoId) + for (const jobId of jobIds) { if (ScheduleService.activeRuns.has(jobId)) { throw new ScheduleServiceError('Cannot delete a repo while a schedule run is in progress. Cancel the run first.', 409) @@ -508,8 +538,11 @@ export class ScheduleService { if (runningRun) { throw new ScheduleServiceError('Cannot delete a repo while a schedule run is in progress. Cancel the run first.', 409) } + } + for (const jobId of jobIds) { this.onJobChange?.(null, jobId) + await this.removeWorktrees(repoId, jobId) } } @@ -543,36 +576,40 @@ export class ScheduleService { /** * Clears a job's run history: deletes every finished run's row plus its git * run branch and any leftover worktree. A run currently in progress is left - * untouched (its row and live worktree are skipped). + * untouched (its row and live worktree are skipped). `worktrees` controls + * whether a worktree still on disk is committed and kept (`commit`) or + * discarded along with its branch (`discard`). */ - async clearRunHistory(repoId: number, jobId: number): Promise<{ cleared: number }> { + async clearRunHistory(repoId: number, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { const repo = this.assertRepo(repoId) - this.assertJob(repoId, jobId) + const job = this.assertJob(repoId, jobId) const removable = listScheduleRunArtifactsByJob(this.db, repoId, jobId).filter((run) => run.status !== 'running') if (removable.length === 0) { return { cleared: 0 } } - await this.worktreeManager.pruneRunArtifacts(repo, jobId, removable) + await this.worktreeManager.pruneRunArtifacts(repo, job, removable, worktrees) const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id)) return { cleared } } /** * Deletes a single finished run plus its git run branch and any leftover - * worktree. A run in progress must be cancelled first. + * worktree. A run in progress must be cancelled first. `worktrees` controls + * whether a worktree still on disk is committed and kept (`commit`) or + * discarded along with its branch (`discard`). */ - async deleteRun(repoId: number, jobId: number, runId: number): Promise { + async deleteRun(repoId: number, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { const repo = this.assertRepo(repoId) - this.assertJob(repoId, jobId) + const job = this.assertJob(repoId, jobId) const run = this.getRun(repoId, jobId, runId) if (run.status === 'running') { throw new ScheduleServiceError('Cannot delete a run while it is in progress. Cancel it first.', 409) } - await this.worktreeManager.pruneRunArtifacts(repo, jobId, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }]) + await this.worktreeManager.pruneRunArtifacts(repo, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) const deleted = deleteScheduleRunById(this.db, repoId, jobId, runId) if (!deleted) { throw new ScheduleServiceError('Run not found', 404) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index f56c3011f..3a2ded491 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -432,6 +432,17 @@ describe('schedule database queries', () => { expect(job).toBeNull() }) + it('maps a legacy repo workspace mode to worktree', () => { + const stmt = { + get: vi.fn().mockReturnValue(makeJobRow({ workspace_mode: 'repo' })), + } + mockDb.prepare.mockReturnValue(stmt) + + const job = schedulesDb.getScheduleJobById(mockDb, 42, 7) + + expect(job?.workspaceMode).toBe('worktree') + }) + it('deletes a schedule job successfully', () => { const stmt = { run: vi.fn().mockReturnValue({ changes: 1 }), @@ -672,12 +683,11 @@ describe('schedule database queries', () => { ]) }) - it('getScheduleJobRepoId returns the owning repo or null for an unknown job', () => { - const stmt = { get: vi.fn().mockReturnValueOnce({ repo_id: 42 }).mockReturnValueOnce(undefined) } + it('listScheduleJobWorktreeOwners returns id, repoId and name for every job', () => { + const stmt = { all: vi.fn().mockReturnValue([{ id: 7, repo_id: 42, name: 'nightly' }]) } mockDb.prepare.mockReturnValue(stmt) - expect(schedulesDb.getScheduleJobRepoId(mockDb, 7)).toBe(42) - expect(schedulesDb.getScheduleJobRepoId(mockDb, 8)).toBeNull() + expect(schedulesDb.listScheduleJobWorktreeOwners(mockDb)).toEqual([{ id: 7, repoId: 42, name: 'nightly' }]) }) it('deleteScheduleRunById deletes a single run row', () => { diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index b2321dba2..3a75379be 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -63,6 +63,7 @@ const scheduleRunnerMock = vi.hoisted(() => ({ vi.mock('../src/services/schedules', () => ({ ScheduleService: vi.fn().mockImplementation(() => ({ getActiveRunSessions: vi.fn().mockResolvedValue([]), + setWorktreeRemovedHandler: vi.fn(), })), ScheduleRunner: vi.fn().mockImplementation(() => scheduleRunnerMock), })) diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index bbb780455..a5c592438 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -913,7 +913,7 @@ describe('Repo Routes', () => { vi.mocked(repoService.deleteRepoFiles).mockResolvedValue(undefined) vi.mocked(repoService.getSiblingRepos).mockResolvedValue([ { ...createMockRepo({ id: 2, fullPath: '/tmp/repos/manager-worktree', isWorktree: true }), currentBranch: undefined }, - { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeStrategy: 'git', worktreeSource: 'opencode' }, + { ...createMockRepo({ id: -1, fullPath: '/tmp/plugin-workspace' }), currentBranch: undefined, worktreeSource: 'opencode' }, ]) const app = createTestRoutes() diff --git a/backend/test/routes/schedules.test.ts b/backend/test/routes/schedules.test.ts index 24b949237..ae7214376 100644 --- a/backend/test/routes/schedules.test.ts +++ b/backend/test/routes/schedules.test.ts @@ -12,6 +12,8 @@ const scheduleService = { listRuns: vi.fn(), getRun: vi.fn(), cancelRun: vi.fn(), + clearRunHistory: vi.fn(), + deleteRun: vi.fn(), listAllEnabledJobs: vi.fn(), listAllJobsWithRepos: vi.fn(), listAllRuns: vi.fn(), @@ -363,4 +365,42 @@ describe('Schedule Routes', () => { expect(body.error).toBe('Invalid run id') expect(scheduleService.markRunViewed).not.toHaveBeenCalled() }) + + it('clears run history with the default commit worktrees mode', async () => { + scheduleService.clearRunHistory.mockResolvedValue({ cleared: 2 }) + + const response = await app.request('/repos/42/schedules/7/runs', { method: 'DELETE' }) + const body = await response.json() as { cleared: number } + + expect(response.status).toBe(200) + expect(body.cleared).toBe(2) + expect(scheduleService.clearRunHistory).toHaveBeenCalledWith(42, 7, 'commit') + }) + + it('passes the discard worktrees mode to clearRunHistory', async () => { + scheduleService.clearRunHistory.mockResolvedValue({ cleared: 1 }) + + const response = await app.request('/repos/42/schedules/7/runs?worktrees=discard', { method: 'DELETE' }) + + expect(response.status).toBe(200) + expect(scheduleService.clearRunHistory).toHaveBeenCalledWith(42, 7, 'discard') + }) + + it('rejects an invalid worktrees mode when clearing run history', async () => { + const response = await app.request('/repos/42/schedules/7/runs?worktrees=nope', { method: 'DELETE' }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Invalid worktrees mode') + expect(scheduleService.clearRunHistory).not.toHaveBeenCalled() + }) + + it('deletes a run with the requested worktrees mode', async () => { + scheduleService.deleteRun.mockResolvedValue(undefined) + + const response = await app.request('/repos/42/schedules/7/runs/5?worktrees=discard', { method: 'DELETE' }) + + expect(response.status).toBe(200) + expect(scheduleService.deleteRun).toHaveBeenCalledWith(42, 7, 5, 'discard') + }) }) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index c2bd023b6..028fdbb29 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -301,6 +301,21 @@ describe('ocm-manager plugin', () => { } }) + it('allows listing schedule worktrees but rejects deleting them', async () => { + const tool = await loadTool(configHome) + + const fetchMock = jsonResponse({}) + vi.stubGlobal('fetch', fetchMock) + await runTool(tool, { action: 'request', params: { method: 'GET', path: '/repos/1/schedules/2/worktrees' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const deleteMock = jsonResponse({}) + vi.stubGlobal('fetch', deleteMock) + await expect(runTool(tool, { action: 'request', params: { method: 'DELETE', path: '/repos/1/schedules/2/worktrees' } })) + .rejects.toThrow(/is not an allowed OpenCode Manager route/) + expect(deleteMock).not.toHaveBeenCalled() + }) + it('normalizes path traversal and rejects the resolved route without calling the API', async () => { const fetchMock = jsonResponse({}) vi.stubGlobal('fetch', fetchMock) diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index 4e287a4e3..d5c423313 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { createServer } from 'node:https' import { tmpdir } from 'node:os' import path from 'node:path' @@ -998,7 +998,7 @@ describe('repo service real git', () => { expect(worktreeSiblings.map((repo) => repo.fullPath).sort()).toEqual([extraDir, duplicateDir, keptPath, sharedPath, orphanPath, manualPath].sort()) expect(siblings.some((repo) => repo.fullPath === unownedDir && repo.worktreeSource === undefined)).toBe(true) expect(worktreeSiblings.every((repo) => repo.id === -1 && repo.localPath === path.basename(repo.fullPath))).toBe(true) - expect(findSibling(extraDir)).toMatchObject({ worktreeSource: 'opencode', worktreeStrategy: 'git', branch: undefined }) + expect(findSibling(extraDir)).toMatchObject({ worktreeSource: 'opencode', branch: undefined }) expect(findSibling(keptPath)).toMatchObject({ worktreeSource: 'schedule', branch: `schedule/${jobId}/run-3`, schedule: { repoId: a.id, jobId, runId: 3, inUse: false } }) expect(findSibling(sharedPath)).toMatchObject({ worktreeSource: 'schedule', schedule: { repoId: a.id, jobId, runId: null, inUse: true } }) expect(findSibling(orphanPath)).toMatchObject({ worktreeSource: 'git', branch: 'schedule/999999/run-1' }) @@ -1006,6 +1006,45 @@ describe('repo service real git', () => { expect(findSibling(manualPath)).toMatchObject({ worktreeSource: 'git', currentBranch: 'manual-work' }) }) + it('describes schedule worktrees by canonical path and shared-worktree in-use state', async () => { + const { createScheduleWorktreeDescriber } = await import('../../src/services/schedule-worktree-paths') + const repoA = path.join(reposPath, uniqueName('schedule-describer-a')) + mkdirSync(repoA, { recursive: true }) + const a = createRepo(db, { + isLocal: true, + localPath: path.basename(repoA), + sourcePath: repoA, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const jobId = Number(db.prepare('INSERT INTO schedule_jobs (repo_id, name, prompt, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(a.id, 'nightly', 'check', Date.now(), Date.now()).lastInsertRowid) + const root = getScheduleWorktreesPath() + mkdirSync(root, { recursive: true }) + const runDir = path.join(root, `job-${jobId}-run-1`) + const sharedDir = path.join(root, `job-${jobId}-shared`) + mkdirSync(runDir, { recursive: true }) + mkdirSync(sharedDir, { recursive: true }) + const linkRoot = path.join(workspaceRoot, uniqueName('schedule-describer-link')) + symlinkSync(root, linkRoot, 'dir') + + const describeWorktree = createScheduleWorktreeDescriber(db) + + const canonicalRun = describeWorktree(realpathSync(runDir)) + expect(canonicalRun).toMatchObject({ repoId: a.id, jobId, runId: 1, inUse: false, name: 'nightly' }) + expect(describeWorktree(path.join(linkRoot, `job-${jobId}-run-1`))).toEqual(canonicalRun) + + const sharedBefore = describeWorktree(realpathSync(sharedDir)) + expect(sharedBefore).toMatchObject({ repoId: a.id, jobId, runId: null, inUse: false, name: 'nightly' }) + + db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(jobId, a.id, 'manual', 'running', Date.now(), Date.now(), sharedDir) + + const describeAfterRun = createScheduleWorktreeDescriber(db) + expect(describeAfterRun(realpathSync(sharedDir))).toMatchObject({ jobId, runId: null, inUse: true, name: 'nightly' }) + expect(describeAfterRun(path.join(root, 'job-999999-run-1'))).toBeUndefined() + }) + it('returns repo siblings when the OpenCode client fails', async () => { const { getSiblingRepos } = await import('../../src/services/repo') const origin = path.join(workspaceRoot, uniqueName('sibling-err-origin.git')) diff --git a/backend/test/services/repo-workspace.test.ts b/backend/test/services/repo-workspace.test.ts index e445b9ec4..d4c640882 100644 --- a/backend/test/services/repo-workspace.test.ts +++ b/backend/test/services/repo-workspace.test.ts @@ -131,7 +131,7 @@ describe('RepoWorkspaceService', () => { it('throws a 400 when the directory is not a worktree sibling and touches nothing', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/other', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) const error = await service.remove(REPO, '/worktrees/unknown').catch((caught: unknown) => caught) @@ -145,7 +145,7 @@ describe('RepoWorkspaceService', () => { it('removes terminals for the matched sibling before removing the worktree', async () => { const worktreeRemove = vi.fn(async () => undefined) const removeAll = vi.fn(async () => undefined) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await service.remove(REPO, '/worktrees/feature-x') @@ -164,7 +164,7 @@ describe('RepoWorkspaceService', () => { const removeAll = vi.fn(async () => { throw new Error('pty cleanup failed') }) - mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeStrategy: 'git', worktreeSource: 'opencode' }]) + mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await expect(service.remove(REPO, '/worktrees/feature-x')).resolves.toBeUndefined() @@ -176,18 +176,20 @@ describe('RepoWorkspaceService', () => { force: true, }) }) - it('removes a schedule worktree through its schedule instead of OpenCode', async () => { + it('removes a schedule worktree through its schedule without touching terminals', async () => { const worktreeRemove = vi.fn(async () => undefined) + const removeAll = vi.fn(async () => undefined) mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/schedule-worktrees/job-7-shared', worktreeSource: 'schedule', - schedule: { repoId: 3, jobId: 7, runId: null, inUse: false }, + schedule: { repoId: 3, jobId: 7, runId: null, inUse: false, name: 'Shared job' }, }]) - const { service, removeScheduleWorktrees } = createService({ worktreeRemove }) + const { service, removeScheduleWorktrees } = createService({ worktreeRemove, removeAll }) await service.remove(REPO, '/schedule-worktrees/job-7-shared') expect(removeScheduleWorktrees).toHaveBeenCalledWith(3, 7, '/schedule-worktrees/job-7-shared') + expect(removeAll).not.toHaveBeenCalled() expect(worktreeRemove).not.toHaveBeenCalled() }) @@ -196,7 +198,7 @@ describe('RepoWorkspaceService', () => { mocks.getSiblingRepos.mockResolvedValue([{ fullPath: '/schedule-worktrees/job-7-run-2', worktreeSource: 'schedule', - schedule: { repoId: 1, jobId: 7, runId: 2, inUse: true }, + schedule: { repoId: 1, jobId: 7, runId: 2, inUse: true, name: 'Run job' }, }]) const { service, removeScheduleWorktrees } = createService({ removeAll }) @@ -222,8 +224,8 @@ describe('RepoWorkspaceService', () => { it('removes terminals for the repo directory and each worktree sibling', async () => { const removeAll = vi.fn(async () => undefined) mocks.getSiblingRepos.mockResolvedValue([ - { fullPath: '/worktrees/a', worktreeStrategy: 'git', worktreeSource: 'opencode' }, - { fullPath: '/worktrees/b', worktreeStrategy: 'git', worktreeSource: 'opencode' }, + { fullPath: '/worktrees/a', worktreeSource: 'opencode' }, + { fullPath: '/worktrees/b', worktreeSource: 'opencode' }, { fullPath: '/repos/manager-worktree', isWorktree: true }, ]) const { service } = createService({ removeAll }) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 879224b44..7e5159724 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -5,7 +5,7 @@ import { getReposPath } from '@opencode-manager/shared/config/env' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' -import type { RepoSibling } from '../../src/services/repo' +import type { RepoSibling } from '@opencode-manager/shared/utils' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' @@ -536,7 +536,7 @@ describe('getSiblingRepos worktree API', () => { id: -1, fullPath: '/worktrees/feature-x', localPath: 'feature-x', - worktreeStrategy: 'git', + worktreeSource: 'opencode', }) expect(siblings[1]?.branch).toBeUndefined() expect(siblings[1]?.currentBranch).toBeUndefined() @@ -565,6 +565,7 @@ describe('getSiblingRepos worktree API', () => { it('marks an active schedule run worktree as an in-use schedule worktree', async () => { const { getSiblingRepos } = await import('../../src/services/repo') const activePath = '/worktrees/active-run' + db.prepare('INSERT INTO schedule_jobs (repo_id, name, prompt, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(1, 'nightly', 'check', Date.now(), Date.now()) db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path) VALUES (?, ?, ?, ?, ?, ?, ?)').run(1, 1, 'manual', 'running', Date.now(), Date.now(), activePath) const client = createClient([ { directory: activePath, strategy: 'git' }, @@ -574,7 +575,7 @@ describe('getSiblingRepos worktree API', () => { const siblings = await getSiblingRepos(db, 1, {}, client) as SiblingRepo[] expect(siblings).toHaveLength(3) - expect(siblings[1]).toMatchObject({ fullPath: activePath, worktreeSource: 'schedule', schedule: { repoId: 1, jobId: 1, runId: 1, inUse: true } }) + expect(siblings[1]).toMatchObject({ fullPath: activePath, worktreeSource: 'schedule', schedule: { repoId: 1, jobId: 1, runId: 1, inUse: true, name: 'nightly' } }) expect(siblings[2]).toMatchObject({ fullPath: '/worktrees/feature-x', worktreeSource: 'opencode' }) }) diff --git a/backend/test/services/schedule-worktree.test.ts b/backend/test/services/schedule-worktree.test.ts index e0fa55b8c..b5727356d 100644 --- a/backend/test/services/schedule-worktree.test.ts +++ b/backend/test/services/schedule-worktree.test.ts @@ -274,7 +274,7 @@ describe('ScheduleWorktreeManager', () => { await removeWorktree(baseRepoPath, ctx!.worktreePath) }) - it('pruneRunArtifacts removes the worktree directory and deletes the run branch', async () => { + it('pruneRunArtifacts removes the worktree directory and deletes the run branch in discard mode', async () => { const manager = await createManager() const repo = testRepo() const job = { id: 90, branch: null, workspaceMode: 'worktree' as const } @@ -287,7 +287,7 @@ describe('ScheduleWorktreeManager', () => { const runBranch = ctx!.runBranch expect(existsSync(worktreePath)).toBe(true) - await manager.pruneRunArtifacts(repo, 90, [{ runBranch, worktreePath }]) + await manager.pruneRunArtifacts(repo, { id: 90, name: 'Prune job' }, [{ runBranch, worktreePath }], 'discard') expect(existsSync(worktreePath)).toBe(false) const branchList = execSync(`git -C "${baseRepoPath}" branch --list "${runBranch}"`, { @@ -296,12 +296,61 @@ describe('ScheduleWorktreeManager', () => { expect(branchList).toBe('') }) - it('prepare returns null in repo mode so the run works in the checkout', async () => { + it('pruneRunArtifacts commits a kept worktree and keeps its branch in commit mode', async () => { const manager = await createManager() - const ctx = await manager.prepare(testRepo(), { id: 30, branch: null, workspaceMode: 'repo' }, 1) + const repo = testRepo() + const job = { id: 91, branch: null, name: 'Commit prune', workspaceMode: 'kept-worktree' as const } + const runId = 910 - expect(ctx).toBeNull() - expect(manager.listWorktrees(30)).toEqual([]) + const ctx = await manager.prepare(repo, job, runId) + expect(ctx).not.toBeNull() + writeFileSync(path.join(ctx!.worktreePath, 'pending.md'), 'pending work') + + await manager.pruneRunArtifacts(repo, { id: 91, name: 'Commit prune' }, [{ runBranch: ctx!.runBranch, worktreePath: ctx!.worktreePath }], 'commit') + + expect(existsSync(ctx!.worktreePath)).toBe(false) + const branchList = execSync(`git -C "${baseRepoPath}" branch --list "${ctx!.runBranch}"`, { + encoding: 'utf-8', + }).trim() + expect(branchList).not.toBe('') + const log = execSync(`git -C "${baseRepoPath}" log "${ctx!.runBranch}" --oneline`, { encoding: 'utf-8' }) + expect(log).toContain('Schedule worktree removed: Commit prune') + }) + + it('pruneRunArtifacts deletes the branch of a run whose worktree is already gone in commit mode', async () => { + const manager = await createManager() + const repo = testRepo() + const job = { id: 92, branch: null, workspaceMode: 'kept-worktree' as const } + const runId = 920 + + const ctx = await manager.prepare(repo, job, runId) + const { removeWorktree } = await import('../../src/services/repo') + await removeWorktree(baseRepoPath, ctx!.worktreePath) + expect(existsSync(ctx!.worktreePath)).toBe(false) + + await manager.pruneRunArtifacts(repo, { id: 92, name: 'Gone prune' }, [{ runBranch: ctx!.runBranch, worktreePath: ctx!.worktreePath }], 'commit') + + const branchList = execSync(`git -C "${baseRepoPath}" branch --list "${ctx!.runBranch}"`, { + encoding: 'utf-8', + }).trim() + expect(branchList).toBe('') + }) + + it('prepare refuses when the run branch is checked out in another worktree', async () => { + const manager = await createManager() + const repo = testRepo() + const otherPath = path.join(tmpDir, 'other-checkout') + const runBranch = 'schedule/40/run-1' + execSync(`git -C "${baseRepoPath}" worktree add -b "${runBranch}" "${otherPath}" main`, { env }) + + try { + const error = await manager.prepare(repo, { id: 40, branch: null, workspaceMode: 'worktree' }, 1).catch((caught: unknown) => caught) + expect((error as Error).message).toContain(`Branch ${runBranch} is checked out in`) + expect((error as Error).message).toContain('Switch that checkout to another branch so the schedule can run.') + } finally { + execSync(`git -C "${baseRepoPath}" worktree remove --force "${otherPath}"`, { env }) + execSync(`git -C "${baseRepoPath}" branch -D "${runBranch}"`, { env }) + } }) it('finalize commits but keeps the worktree on its branch in kept-worktree mode', async () => { @@ -336,22 +385,35 @@ describe('ScheduleWorktreeManager', () => { await manager.finalize(repo, job, { id: 1, worktreePath: sharedPath, runBranch: 'schedule/32/shared', triggerSource: 'manual' }) expect(existsSync(sharedPath)).toBe(true) - writeFileSync(path.join(sharedPath, 'uncommitted.md'), 'pending') const second = await manager.prepare(repo, job, 2) expect(second!.worktreePath).toBe(sharedPath) - expect(existsSync(path.join(sharedPath, 'uncommitted.md'))).toBe(true) await manager.releaseWorktree(repo, job, sharedPath) expect(existsSync(sharedPath)).toBe(false) const third = await manager.prepare(repo, job, 3) expect(existsSync(path.join(third!.worktreePath, 'first.md'))).toBe(true) - expect(existsSync(path.join(third!.worktreePath, 'uncommitted.md'))).toBe(true) - await manager.pruneRunArtifacts(repo, 32, [{ runBranch: 'schedule/32/shared', worktreePath: sharedPath }]) + await manager.pruneRunArtifacts(repo, { id: 32, name: 'Shared job' }, [{ runBranch: 'schedule/32/shared', worktreePath: sharedPath }]) expect(existsSync(sharedPath)).toBe(true) expect(execSync(`git -C "${baseRepoPath}" branch --list schedule/32/shared`, { encoding: 'utf-8' }).trim()).not.toBe('') await manager.releaseWorktree(repo, job, sharedPath) }) + + it('refuses to reuse a shared worktree with uncommitted changes made outside a run', async () => { + const manager = await createManager() + const repo = testRepo() + const job = { id: 33, branch: null, name: 'Dirty shared', prompt: '', workspaceMode: 'shared-worktree' as const } + const sharedPath = path.join(scheduleWorktreesRoot, 'job-33-shared') + + await manager.prepare(repo, job, 1) + writeFileSync(path.join(sharedPath, 'outside.md'), 'outside edit') + + await expect(manager.prepare(repo, job, 2)).rejects.toThrow( + `Shared worktree ${sharedPath} has uncommitted changes made outside a scheduled run.`, + ) + + await manager.releaseWorktree(repo, job, sharedPath) + }) }) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 039eba0d9..6448b592d 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'path' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' const mocks = vi.hoisted(() => ({ getRepoById: vi.fn(), @@ -16,6 +18,7 @@ const mocks = vi.hoisted(() => ({ getScheduleRunById: vi.fn(), listEnabledScheduleJobs: vi.fn(), listRunningScheduleRuns: vi.fn(), + listScheduleJobWorktreeOwners: vi.fn(), clearScheduleRunWorktreePath: vi.fn(), listScheduleJobIdsByRepo: vi.fn(), listScheduleJobsByRepo: vi.fn(), @@ -58,6 +61,7 @@ vi.mock('../../src/db/schedules', () => ({ getScheduleRunById: mocks.getScheduleRunById, listEnabledScheduleJobs: mocks.listEnabledScheduleJobs, listRunningScheduleRuns: mocks.listRunningScheduleRuns, + listScheduleJobWorktreeOwners: mocks.listScheduleJobWorktreeOwners, clearScheduleRunWorktreePath: mocks.clearScheduleRunWorktreePath, listScheduleJobIdsByRepo: mocks.listScheduleJobIdsByRepo, listScheduleJobsByRepo: mocks.listScheduleJobsByRepo, @@ -187,6 +191,7 @@ describe('ScheduleService', () => { mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.listScheduleJobWorktreeOwners.mockReturnValue([{ id: job.id, repoId: job.repoId, name: job.name }]) mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) @@ -915,6 +920,18 @@ describe('ScheduleService', () => { expect(stub.api.agent.list).toHaveBeenCalledTimes(1) }) + it('rejects changing the workspace mode while a run is in progress', async () => { + const stub = createStubScheduleApi() + const service = makeService(stub.api) + Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) + + await expect(service.updateJob(42, 7, { workspaceMode: 'kept-worktree' })).rejects.toMatchObject({ + message: 'Cannot change where runs execute while a run is in progress.', + status: 409, + }) + expect(mocks.updateScheduleJob).not.toHaveBeenCalled() + }) + it('throws when deleting or loading missing records', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) @@ -942,7 +959,7 @@ describe('ScheduleService', () => { await expect(service.deleteJob(42, 7)).rejects.toThrow('Cannot delete a schedule while it is running. Cancel the run first.') }) - it('blocks prepareRepoDelete when a running run exists in activeRuns', () => { + it('blocks prepareRepoDelete when a running run exists in activeRuns', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) const onJobChange = vi.fn() @@ -950,11 +967,11 @@ describe('ScheduleService', () => { mocks.listScheduleJobIdsByRepo.mockReturnValue([7, 8]) Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) - expect(() => service.prepareRepoDelete(42)).toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') + await expect(service.prepareRepoDelete(42)).rejects.toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') expect(onJobChange).not.toHaveBeenCalled() }) - it('blocks prepareRepoDelete when a database running run exists', () => { + it('blocks prepareRepoDelete when a database running run exists', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) const onJobChange = vi.fn() @@ -962,7 +979,7 @@ describe('ScheduleService', () => { mocks.listScheduleJobIdsByRepo.mockReturnValue([7, 8]) mocks.getRunningScheduleRunByJob.mockReturnValue({ ...baseRun, status: 'running' }) - expect(() => service.prepareRepoDelete(42)).toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') + await expect(service.prepareRepoDelete(42)).rejects.toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') expect(onJobChange).not.toHaveBeenCalled() }) @@ -979,19 +996,23 @@ describe('ScheduleService', () => { expect(onJobChange).toHaveBeenCalledWith(null, 7) }) - it('prepares repo deletion by unregistering repo jobs without deleting records', () => { + it('prepares repo deletion by unregistering repo jobs and removing their worktrees without deleting records', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) const onJobChange = vi.fn() service.setJobChangeHandler(onJobChange) + const removeWorktrees = vi.spyOn(service, 'removeWorktrees').mockResolvedValue({ removed: 0 }) mocks.listScheduleJobIdsByRepo.mockReturnValue([7, 8]) - service.prepareRepoDelete(42) + await service.prepareRepoDelete(42) expect(mocks.listScheduleJobIdsByRepo).toHaveBeenCalledWith(expect.anything(), 42) expect(onJobChange).toHaveBeenCalledWith(null, 7) expect(onJobChange).toHaveBeenCalledWith(null, 8) expect(onJobChange).toHaveBeenCalledTimes(2) + expect(removeWorktrees).toHaveBeenCalledWith(42, 7) + expect(removeWorktrees).toHaveBeenCalledWith(42, 8) + expect(mocks.deleteScheduleJob).not.toHaveBeenCalled() }) it('cancels by finalizing the run when the assistant already completed', async () => { @@ -1238,6 +1259,7 @@ describe('ScheduleService startup cancellation', () => { mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.listScheduleJobWorktreeOwners.mockReturnValue([{ id: job.id, repoId: job.repoId, name: job.name }]) mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) @@ -1499,6 +1521,7 @@ describe('ScheduleService monitor cancellation', () => { mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.listScheduleJobWorktreeOwners.mockReturnValue([{ id: job.id, repoId: job.repoId, name: job.name }]) mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) @@ -2335,6 +2358,7 @@ describe('ScheduleService run history cleanup', () => { vi.clearAllMocks() mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) + mocks.listScheduleJobWorktreeOwners.mockReturnValue([{ id: job.id, repoId: job.repoId, name: job.name }]) mocks.stubWorktreeManager.pruneRunArtifacts.mockResolvedValue(undefined) }) @@ -2356,14 +2380,28 @@ describe('ScheduleService run history cleanup', () => { const result = await makeService().clearRunHistory(42, 7) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, 7, [ + expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, job, [ { id: 3, status: 'completed', runBranch: 'schedule/7/run-3', worktreePath: null }, { id: 1, status: 'failed', runBranch: null, worktreePath: null }, - ]) + ], 'commit') expect(mocks.deleteScheduleRunsByIds).toHaveBeenCalledWith({}, 42, 7, [3, 1]) expect(result).toEqual({ cleared: 2 }) }) + it('clearRunHistory forwards the discard worktrees mode to pruneRunArtifacts', async () => { + mocks.listScheduleRunArtifactsByJob.mockReturnValue([ + { id: 3, status: 'completed', runBranch: 'schedule/7/run-3', worktreePath: '/wt/3' }, + ]) + mocks.deleteScheduleRunsByIds.mockReturnValue(1) + + const result = await makeService().clearRunHistory(42, 7, 'discard') + + expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, job, [ + { id: 3, status: 'completed', runBranch: 'schedule/7/run-3', worktreePath: '/wt/3' }, + ], 'discard') + expect(result).toEqual({ cleared: 1 }) + }) + it('clearRunHistory is a no-op when only a running run exists', async () => { mocks.listScheduleRunArtifactsByJob.mockReturnValue([ { id: 2, status: 'running', runBranch: 'schedule/7/run-2', worktreePath: '/wt/2' }, @@ -2382,25 +2420,27 @@ describe('ScheduleService run history cleanup', () => { await makeService().deleteRun(42, 7, 5) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, 7, [ + expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, job, [ { runBranch: 'schedule/7/run-5', worktreePath: '/wt/5' }, - ]) + ], 'commit') expect(mocks.deleteScheduleRunById).toHaveBeenCalledWith({}, 42, 7, 5) }) it('listWorktrees marks the shared worktree and running run worktrees as in use', () => { - mocks.listRunningScheduleRuns.mockReturnValue([{ ...baseRun, status: 'running', worktreePath: '/wt/job-7-run-9' }]) - mocks.getRunningScheduleRunByJob.mockReturnValue({ ...baseRun, status: 'running' }) + const root = getScheduleWorktreesPath() + const sharedPath = path.join(root, 'job-7-shared') + const runningPath = path.join(root, 'job-7-run-9') + mocks.listRunningScheduleRuns.mockReturnValue([{ ...baseRun, status: 'running', worktreePath: runningPath }]) mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ - { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, - { jobId: 7, runId: null, worktreePath: '/wt/job-7-shared', branch: 'schedule/7/shared' }, - { jobId: 7, runId: 9, worktreePath: '/wt/job-7-run-9', branch: 'schedule/7/run-9' }, + { jobId: 7, runId: 3, worktreePath: path.join(root, 'job-7-run-3'), branch: 'schedule/7/run-3' }, + { jobId: 7, runId: null, worktreePath: sharedPath, branch: 'schedule/7/shared' }, + { jobId: 7, runId: 9, worktreePath: runningPath, branch: 'schedule/7/run-9' }, ]) expect(makeService().listWorktrees(42, 7)).toEqual([ - { worktreePath: '/wt/job-7-shared', branch: 'schedule/7/shared', runId: null, inUse: true }, - { worktreePath: '/wt/job-7-run-9', branch: 'schedule/7/run-9', runId: 9, inUse: true }, - { worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3', runId: 3, inUse: false }, + { worktreePath: sharedPath, branch: 'schedule/7/shared', runId: null, inUse: true }, + { worktreePath: runningPath, branch: 'schedule/7/run-9', runId: 9, inUse: true }, + { worktreePath: path.join(root, 'job-7-run-3'), branch: 'schedule/7/run-3', runId: 3, inUse: false }, ]) }) @@ -2428,4 +2468,62 @@ describe('ScheduleService run history cleanup', () => { expect(mocks.stubWorktreeManager.pruneRunArtifacts).not.toHaveBeenCalled() expect(mocks.deleteScheduleRunById).not.toHaveBeenCalled() }) + + it('deleteJob unregisters the job and reserves it before awaiting worktree removal', async () => { + const service = makeService() + const onJobChange = vi.fn() + service.setJobChangeHandler(onJobChange) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.listRunningScheduleRuns.mockReturnValue([]) + mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ + { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, + ]) + let resolveRelease!: () => void + mocks.stubWorktreeManager.releaseWorktree.mockReturnValueOnce(new Promise((resolve) => { resolveRelease = resolve })) + mocks.deleteScheduleJob.mockReturnValue(true) + + const deletePromise = service.deleteJob(42, 7) + await vi.waitFor(() => expect(mocks.stubWorktreeManager.releaseWorktree).toHaveBeenCalled()) + + expect(onJobChange).toHaveBeenCalledWith(null, 7) + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(true) + + resolveRelease() + await deletePromise + + expect(mocks.deleteScheduleJob).toHaveBeenCalledWith({}, 42, 7) + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false) + }) + + it('deleteJob re-registers the job and releases the reservation when worktree removal fails', async () => { + const service = makeService() + const onJobChange = vi.fn() + service.setJobChangeHandler(onJobChange) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.listRunningScheduleRuns.mockReturnValue([]) + mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ + { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, + ]) + mocks.stubWorktreeManager.releaseWorktree.mockRejectedValueOnce(new Error('release failed')) + + await expect(service.deleteJob(42, 7)).rejects.toThrow('Failed to remove worktree') + + expect(onJobChange).toHaveBeenCalledWith(job, 7) + expect(mocks.deleteScheduleJob).not.toHaveBeenCalled() + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false) + }) + + it('removeWorktrees removes the terminals of each released worktree', async () => { + const service = makeService() + const onWorktreeRemoved = vi.fn(async () => undefined) + service.setWorktreeRemovedHandler(onWorktreeRemoved) + mocks.listRunningScheduleRuns.mockReturnValue([]) + mocks.stubWorktreeManager.listWorktrees.mockReturnValue([ + { jobId: 7, runId: 3, worktreePath: '/wt/job-7-run-3', branch: 'schedule/7/run-3' }, + ]) + + await expect(service.removeWorktrees(42, 7)).resolves.toEqual({ removed: 1 }) + + expect(onWorktreeRemoved).toHaveBeenCalledWith('/wt/job-7-run-3') + }) }) diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 66f97fe4f..0c529f7c3 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -3,7 +3,10 @@ import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './ import { API_BASE_URL } from '@/config' import { saveFile } from '@/lib/download' import type { CreateRepoWorkspaceRequest, DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest, DeleteRepoRequest, DeleteRepoResult, RepoGitIdentity, WorktreeSetupResult } from '@opencode-manager/shared/types' -import type { RepoWorktreeSchedule, RepoWorktreeSource } from '@opencode-manager/shared/utils' +import type { RepoSibling } from '@opencode-manager/shared/utils' +import { formatScheduleWorktreeLabel } from '@/lib/schedules/schedule-worktree' + +export type { RepoSibling } from '@opencode-manager/shared/utils' export interface CreateRepoOptions { repoUrl?: string @@ -39,20 +42,13 @@ export async function getRepo(id: number): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`) } -export type RepoSibling = Repo & { - currentBranch?: string - worktreeSource?: RepoWorktreeSource - worktreeStrategy?: string - schedule?: RepoWorktreeSchedule -} - export function workspaceLabel(workspace: RepoSibling): string { return workspace.currentBranch || workspace.branch || workspace.localPath || 'worktree' } export function worktreeSourceLabel(worktree: RepoSibling): string | null { if (worktree.schedule) { - return worktree.schedule.runId === null ? 'Schedule · shared' : `Schedule · run #${worktree.schedule.runId}` + return `Schedule · ${formatScheduleWorktreeLabel(worktree.schedule.runId)}` } if (worktree.worktreeSource === 'git') return 'Git' return worktree.worktreeSource === 'opencode' ? 'OpenCode' : null diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index f834cd98a..715c81777 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -2,9 +2,11 @@ import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' import { API_BASE_URL } from '@/config' import type { CreateScheduleJobRequest, + RemoveScheduleWorktreesRequest, ScheduleJob, ScheduleRun, ScheduleRunStatus, + ScheduleRunWorktreesMode, ScheduleWorktree, UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' @@ -158,8 +160,9 @@ export async function cancelRepoScheduleRun(repoId: number, jobId: number, runId }) } -export async function clearRepoScheduleRuns(repoId: number, jobId: number): Promise<{ cleared: number }> { - return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs`, { +export async function clearRepoScheduleRuns(repoId: number, jobId: number, worktrees?: ScheduleRunWorktreesMode): Promise<{ cleared: number }> { + const qs = worktrees ? `?worktrees=${worktrees}` : '' + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs${qs}`, { method: 'DELETE', }) } @@ -168,16 +171,17 @@ export async function listScheduleWorktrees(repoId: number, jobId: number): Prom return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/worktrees`) } -export async function removeScheduleWorktrees(repoId: number, jobId: number, worktreePath?: string): Promise<{ removed: number }> { +export async function removeScheduleWorktrees(repoId: number, jobId: number, request: RemoveScheduleWorktreesRequest = {}): Promise<{ removed: number }> { return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/worktrees`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(worktreePath ? { worktreePath } : {}), + body: JSON.stringify(request), }) } -export async function deleteRepoScheduleRun(repoId: number, jobId: number, runId: number): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs/${runId}`, { +export async function deleteRepoScheduleRun(repoId: number, jobId: number, runId: number, worktrees?: ScheduleRunWorktreesMode): Promise { + const qs = worktrees ? `?worktrees=${worktrees}` : '' + return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs/${runId}${qs}`, { method: 'DELETE', }) } diff --git a/frontend/src/components/repo/WorktreeSessionGroups.test.tsx b/frontend/src/components/repo/WorktreeSessionGroups.test.tsx index 0c5612c70..f152e4575 100644 --- a/frontend/src/components/repo/WorktreeSessionGroups.test.tsx +++ b/frontend/src/components/repo/WorktreeSessionGroups.test.tsx @@ -15,7 +15,6 @@ const makeWorktree = (overrides: Partial & Pick void } = {}) { return render(
{session.title}
} + onExpandedScheduleDirectoriesChange={overrides.onExpandedScheduleDirectoriesChange} onNewSession={onNewSession} onOpenTerminal={onOpenTerminal} onCreateWorktree={onCreateWorktree} @@ -100,9 +100,9 @@ describe('WorktreeSessionGroups', () => { expect(screen.getByText('Auth work')).toBeInTheDocument() expect(screen.getByText('1 session')).toBeInTheDocument() - expect(screen.getByText('Dependency audit')).toBeInTheDocument() - expect(screen.getByText('Schedule · shared')).toBeInTheDocument() - expect(screen.getByText('Schedule · run #340')).toBeInTheDocument() + expect(screen.getByText('Nightly')).toBeInTheDocument() + expect(screen.getByText('Schedule · Shared worktree')).toBeInTheDocument() + expect(screen.getByText('Schedule · Run #340')).toBeInTheDocument() expect(screen.getByText('in use')).toBeInTheDocument() expect(screen.getByText('Git')).toBeInTheDocument() @@ -157,7 +157,7 @@ describe('WorktreeSessionGroups', () => { expect(screen.getByText('manual')).toBeInTheDocument() expect(screen.queryByText('feature/auth')).not.toBeInTheDocument() - expect(screen.queryByText('Dependency audit')).not.toBeInTheDocument() + expect(screen.queryByText('Nightly')).not.toBeInTheDocument() }) it('collapses a worktree and persists the collapsed state across remount', async () => { @@ -172,12 +172,23 @@ describe('WorktreeSessionGroups', () => { expect(screen.queryByText('Auth work')).not.toBeInTheDocument() }) + it('reports a schedule worktree directory only after its group is expanded', async () => { + const onExpandedScheduleDirectoriesChange = vi.fn() + renderGroups({ onExpandedScheduleDirectoriesChange }) + + expect(onExpandedScheduleDirectoriesChange).toHaveBeenLastCalledWith([]) + + await userEvent.click(screen.getByRole('button', { expanded: false, name: /schedule\/14\/shared/ })) + + expect(onExpandedScheduleDirectoriesChange).toHaveBeenLastCalledWith(['/s/job-14-shared']) + }) + it('hides empty worktrees while searching and renders the empty state when there are none', async () => { const { unmount } = renderGroups({ searchQuery: 'auth' }) expect(screen.getByText('feature/auth')).toBeInTheDocument() expect(screen.queryByText('manual')).not.toBeInTheDocument() - expect(screen.queryByText('Dependency audit')).not.toBeInTheDocument() + expect(screen.queryByText('Nightly')).not.toBeInTheDocument() unmount() renderGroups({ worktrees: [], sessions: [] }) diff --git a/frontend/src/components/repo/WorktreeSessionGroups.tsx b/frontend/src/components/repo/WorktreeSessionGroups.tsx index 14727ee6d..caf97543d 100644 --- a/frontend/src/components/repo/WorktreeSessionGroups.tsx +++ b/frontend/src/components/repo/WorktreeSessionGroups.tsx @@ -13,8 +13,8 @@ interface WorktreeSessionGroupsProps { worktrees: RepoSibling[] sessions: Session[] searchQuery: string - scheduleNames: Record renderSessionCard: (session: Session) => ReactNode + onExpandedScheduleDirectoriesChange?: (directories: string[]) => void onNewSession: (directory: string) => void onOpenTerminal: (directory: string) => void onCreateWorktree: () => void @@ -31,7 +31,7 @@ interface WorktreeGroup { type GroupEntry = | { kind: 'worktree'; key: string; group: WorktreeGroup; lastActive: number } - | { kind: 'schedule'; key: string; jobId: number; groups: WorktreeGroup[]; lastActive: number } + | { kind: 'schedule'; key: string; jobId: number; name: string; groups: WorktreeGroup[]; lastActive: number } const OWNER_FILTERS: Array<{ value: OwnerFilter; label: string }> = [ { value: 'all', label: 'All' }, @@ -57,8 +57,8 @@ export function WorktreeSessionGroups({ worktrees, sessions, searchQuery, - scheduleNames, renderSessionCard, + onExpandedScheduleDirectoriesChange, onNewSession, onOpenTerminal, onCreateWorktree, @@ -67,9 +67,21 @@ export function WorktreeSessionGroups({ }: WorktreeSessionGroupsProps) { const [ownerFilter, setOwnerFilter] = useState('all') const [collapsed, setCollapsed] = useCollapsedGroups(repoId) + const [expandedSchedule, setExpandedSchedule] = useState>(new Set()) const [pendingDelete, setPendingDelete] = useState<{ directories: string[]; label: string } | null>(null) const isSearching = searchQuery.trim().length > 0 + const scheduleWorktreeDirectories = useMemo( + () => worktrees.filter(isScheduleWorktree).map((worktree) => worktree.fullPath), + [worktrees], + ) + + useEffect(() => { + onExpandedScheduleDirectoriesChange?.( + scheduleWorktreeDirectories.filter((directory) => expandedSchedule.has(directory)), + ) + }, [expandedSchedule, scheduleWorktreeDirectories, onExpandedScheduleDirectoriesChange]) + const ownerCounts = useMemo(() => { const counts: Record = { all: worktrees.length, opencode: 0, schedule: 0, git: 0 } worktrees.forEach((worktree) => { @@ -92,10 +104,46 @@ export function WorktreeSessionGroups({ }) } - const allKeys = entries.flatMap((entry) => ( - entry.kind === 'schedule' ? [entry.key, ...entry.groups.map((group) => groupKey(group.directory))] : [entry.key] + const isGroupOpen = (group: WorktreeGroup) => ( + isScheduleWorktree(group.worktree) + ? expandedSchedule.has(group.directory) + : !collapsed.has(groupKey(group.directory)) + ) + + const toggleGroup = (group: WorktreeGroup) => { + if (isScheduleWorktree(group.worktree)) { + setExpandedSchedule((current) => { + const next = new Set(current) + if (next.has(group.directory)) next.delete(group.directory) + else next.add(group.directory) + return next + }) + return + } + toggle(groupKey(group.directory)) + } + + const scheduleGroupDirectories = entries.flatMap((entry) => ( + entry.kind === 'schedule' ? entry.groups.map((group) => group.directory) : [] )) - const allCollapsed = allKeys.length > 0 && allKeys.every((key) => collapsed.has(key)) + const allCollapsed = entries.length > 0 && entries.every((entry) => { + if (collapsed.has(entry.key)) return true + if (entry.kind === 'schedule') return entry.groups.every((group) => !expandedSchedule.has(group.directory)) + return false + }) + const toggleAll = () => { + if (allCollapsed) { + setCollapsed((current) => { + const next = new Set(current) + entries.forEach((entry) => next.delete(entry.key)) + return next + }) + setExpandedSchedule(new Set(scheduleGroupDirectories)) + return + } + setCollapsed(new Set(entries.map((entry) => entry.key))) + setExpandedSchedule(new Set()) + } if (worktrees.length === 0) { return ( @@ -112,7 +160,7 @@ export function WorktreeSessionGroups({ const renderWorktree = (group: WorktreeGroup, nested: boolean) => { const key = groupKey(group.directory) - const isOpen = !collapsed.has(key) + const isOpen = isGroupOpen(group) const label = workspaceLabel(group.worktree) const source = group.worktree.worktreeSource const sourceLabel = worktreeSourceLabel(group.worktree) @@ -122,7 +170,7 @@ export function WorktreeSessionGroups({
))}
-
)} - {workspaceMode !== 'repo' && ( -
-
- - -
- +
+
+ +
- )} + +
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index f7d27e1f4..1274cb59e 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -11,6 +11,7 @@ import { } from '@/components/schedules/schedule-utils' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { useRemoveScheduleWorktrees, useScheduleWorktrees } from '@/hooks/useSchedules' +import { formatScheduleWorktreeLabel } from '@/lib/schedules/schedule-worktree' import { Bot, CalendarClock, Clock3, GitBranch, History, Loader2, Pencil, Play, Sparkles, Trash2 } from 'lucide-react' import { useScheduleModels } from '@/hooks/useScheduleModels' import { resolveScheduleModel } from '@/lib/schedules/schedule-model' @@ -139,7 +140,7 @@ export function JobDetailTab({

{worktree.branch}

- {worktree.runId === null ? 'Shared worktree' : `Run #${worktree.runId}`} + {formatScheduleWorktreeLabel(worktree.runId)} {worktree.inUse ? ' · in use' : ''} · {worktree.worktreePath}

diff --git a/frontend/src/components/schedules/ScheduleRunRemovalDialog.test.tsx b/frontend/src/components/schedules/ScheduleRunRemovalDialog.test.tsx new file mode 100644 index 000000000..6ef2a4c9f --- /dev/null +++ b/frontend/src/components/schedules/ScheduleRunRemovalDialog.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ScheduleRunRemovalDialog } from './ScheduleRunRemovalDialog' + +type DialogProps = Parameters[0] + +function renderDialog(overrides: Partial = {}) { + const props: DialogProps = { + open: true, + onOpenChange: vi.fn(), + title: 'Delete run', + description: 'This permanently deletes this run.', + affectedWorktreeCount: 0, + isPending: false, + onCancel: vi.fn(), + onConfirm: vi.fn(), + ...overrides, + } + render() + return props +} + +describe('ScheduleRunRemovalDialog', () => { + it('confirms without a worktrees mode when no kept worktree is affected', async () => { + const user = userEvent.setup() + const props = renderDialog() + + await user.click(screen.getByRole('button', { name: 'Delete' })) + + expect(props.onConfirm).toHaveBeenCalledWith() + }) + + it('offers commit and force delete when kept worktrees are affected', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + renderDialog({ affectedWorktreeCount: 2, onConfirm }) + + expect(screen.getByText(/2 kept worktrees will be removed/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Commit and remove' })) + expect(onConfirm).toHaveBeenCalledWith('commit') + + await user.click(screen.getByRole('button', { name: 'Force delete' })) + expect(onConfirm).toHaveBeenCalledWith('discard') + }) +}) diff --git a/frontend/src/components/schedules/ScheduleRunRemovalDialog.tsx b/frontend/src/components/schedules/ScheduleRunRemovalDialog.tsx new file mode 100644 index 000000000..eeb56473f --- /dev/null +++ b/frontend/src/components/schedules/ScheduleRunRemovalDialog.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react' +import type { ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' +import { Button } from '@/components/ui/button' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertTriangle, Loader2 } from 'lucide-react' + +interface ScheduleRunRemovalDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + affectedWorktreeCount: number + isPending: boolean + onCancel: () => void + onConfirm: (worktrees?: ScheduleRunWorktreesMode) => void +} + +export function ScheduleRunRemovalDialog({ + open, + onOpenChange, + title, + description, + affectedWorktreeCount, + isPending, + onCancel, + onConfirm, +}: ScheduleRunRemovalDialogProps) { + if (affectedWorktreeCount === 0) { + return ( + onConfirm()} + onCancel={onCancel} + title={title} + description={description} + isDeleting={isPending} + /> + ) + } + + const worktreeLabel = `${affectedWorktreeCount} kept worktree${affectedWorktreeCount === 1 ? '' : 's'}` + + return ( + + + + {title} + {description} + + + + + + {worktreeLabel} will be removed. Choose how to handle {affectedWorktreeCount === 1 ? 'it' : 'them'}. + + + +
+

+ Commit and remove — pending changes are committed to each run branch, then the worktree folders are removed. The branches are kept. +

+

+ Force delete — the worktree folders are force-removed and their run branches deleted. Any uncommitted changes are lost. +

+
+ + + + + + +
+
+ ) +} diff --git a/frontend/src/components/schedules/index.ts b/frontend/src/components/schedules/index.ts index c8c33362d..b2e18d1f1 100644 --- a/frontend/src/components/schedules/index.ts +++ b/frontend/src/components/schedules/index.ts @@ -1,6 +1,7 @@ export { ScheduleJobsTable } from './ScheduleJobsTable' export { JobDetailTab } from './JobDetailTab' export { RunHistoryTab } from './RunHistoryTab' +export { ScheduleRunRemovalDialog } from './ScheduleRunRemovalDialog' export { ScheduleRunDrawer } from './ScheduleRunDrawer' export { ScheduleRunsTable } from './ScheduleRunsTable' export { RunDetailPanel } from './RunDetailPanel' diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index 450eb3193..a62f4bd1a 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -56,11 +56,6 @@ export const workspaceModeOptions: Array> = [ label: 'Shared worktree', description: 'Every run reuses one worktree and branch, so work builds up from run to run.', }, - { - value: 'repo', - label: 'Repository checkout', - description: 'Runs directly in the repository working directory. Nothing is isolated, committed, or cleaned up.', - }, ] export function formatWorkspaceMode(mode: ScheduleWorkspaceMode): string { diff --git a/frontend/src/components/session/SessionCard.tsx b/frontend/src/components/session/SessionCard.tsx index 8fe656432..16e5e5d13 100644 --- a/frontend/src/components/session/SessionCard.tsx +++ b/frontend/src/components/session/SessionCard.tsx @@ -14,7 +14,6 @@ interface SessionCardProps { isSelected: boolean; isActive: boolean; manageMode: boolean; - workspaceLabel?: string; isPinned?: boolean; onTogglePin?: () => void; onSelect: (sessionID: string) => void; @@ -27,7 +26,6 @@ export const SessionCard = ({ isSelected, isActive, manageMode, - workspaceLabel, isPinned, onTogglePin, onSelect, @@ -117,9 +115,6 @@ export const SessionCard = ({ addSuffix: true, })} - {workspaceLabel ? ( - {workspaceLabel} - ) : null}
@@ -139,9 +134,6 @@ export const SessionCard = ({ addSuffix: true, })} - {workspaceLabel ? ( - {workspaceLabel} - ) : null}
diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 07f04bfe9..f8a140a2c 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -16,7 +16,6 @@ interface SessionListProps { directory?: string; directories?: string[]; createDirectory?: string; - directoryLabels?: Record; activeSessionID?: string; onSelectSession: (sessionID: string) => void; renderSessions?: (args: SessionListRenderArgs) => ReactNode; @@ -36,7 +35,6 @@ export const SessionList = ({ directory, directories, createDirectory, - directoryLabels, activeSessionID, onSelectSession, renderSessions, @@ -220,7 +218,6 @@ export const SessionList = ({ isSelected={selectedSessions.has(key)} isActive={activeSessionID === session.id} manageMode={manageMode} - workspaceLabel={directoryLabels?.[session.location.directory]} isPinned={isPinned} onSelect={onSelectSession} onToggleSelection={(selected) => toggleSessionSelection(session, selected)} diff --git a/frontend/src/hooks/useSchedules.ts b/frontend/src/hooks/useSchedules.ts index 70957e40f..a3dd7c246 100644 --- a/frontend/src/hooks/useSchedules.ts +++ b/frontend/src/hooks/useSchedules.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { CreateScheduleJobRequest, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' +import type { CreateScheduleJobRequest, ScheduleRunWorktreesMode, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' import { cancelRepoScheduleRun, clearRepoScheduleRuns, @@ -224,8 +224,8 @@ export function useClearRepoScheduleRuns() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ repoId, jobId }: { repoId: number; jobId: number }) => { - return clearRepoScheduleRuns(repoId, jobId) + mutationFn: async ({ repoId, jobId, worktrees }: { repoId: number; jobId: number; worktrees?: ScheduleRunWorktreesMode }) => { + return clearRepoScheduleRuns(repoId, jobId, worktrees) }, onSuccess: (result, variables) => { queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId, variables.jobId] }) @@ -243,8 +243,8 @@ export function useDeleteRepoScheduleRun() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ repoId, jobId, runId }: { repoId: number; jobId: number; runId: number }) => { - return deleteRepoScheduleRun(repoId, jobId, runId) + mutationFn: ({ repoId, jobId, runId, worktrees }: { repoId: number; jobId: number; runId: number; worktrees?: ScheduleRunWorktreesMode }) => { + return deleteRepoScheduleRun(repoId, jobId, runId, worktrees) }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs', variables.repoId, variables.jobId] }) @@ -275,7 +275,7 @@ export function useRemoveScheduleWorktrees() { return useMutation({ mutationFn: ({ repoId, jobId, worktreePath }: { repoId: number; jobId: number; worktreePath?: string }) => { - return removeScheduleWorktrees(repoId, jobId, worktreePath) + return removeScheduleWorktrees(repoId, jobId, worktreePath ? { worktreePath } : {}) }, onSuccess: (result, variables) => { queryClient.invalidateQueries({ queryKey: ['schedule-worktrees', variables.repoId, variables.jobId] }) diff --git a/frontend/src/lib/schedules/schedule-worktree.ts b/frontend/src/lib/schedules/schedule-worktree.ts new file mode 100644 index 000000000..2e6c5ccc4 --- /dev/null +++ b/frontend/src/lib/schedules/schedule-worktree.ts @@ -0,0 +1,3 @@ +export function formatScheduleWorktreeLabel(runId: number | null): string { + return runId === null ? 'Shared worktree' : `Run #${runId}` +} diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index 9ebd3be20..f4608f498 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { getRepo } from "@/api/repos"; @@ -22,7 +22,6 @@ import { useOpenTerminal, useTerminalDialogParam, useTerminalDirectoryParam } fr import { useWorktreeTab } from "@/hooks/useWorktreeTab"; import { WorktreeTabs } from "@/components/repo/WorktreeTabs"; import { WorktreeSessionGroups } from "@/components/repo/WorktreeSessionGroups"; -import { useAllSchedules } from "@/hooks/useSchedules"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -70,30 +69,56 @@ export function RepoDetail() { [siblings], ); - const workspaceDirectories = useMemo( - () => workspaceSiblings.map((sibling) => sibling.fullPath).filter(Boolean), + const scheduleDirectorySet = useMemo( + () => new Set( + workspaceSiblings + .filter((sibling) => sibling.worktreeSource === 'schedule') + .map((sibling) => sibling.fullPath), + ), [workspaceSiblings], ); - const { data: allSchedules } = useAllSchedules(); - const scheduleNames = useMemo(() => { - const names: Record = {}; - (allSchedules ?? []).forEach((job) => { - if (job.repoId === repoId) names[job.id] = job.name; - }); - return names; - }, [allSchedules, repoId]); + const nonScheduleWorkspaceDirectories = useMemo( + () => workspaceSiblings + .filter((sibling) => sibling.worktreeSource !== 'schedule') + .map((sibling) => sibling.fullPath) + .filter(Boolean), + [workspaceSiblings], + ); + + const [expandedScheduleDirectories, setExpandedScheduleDirectories] = useState([]); + + const activeScheduleDirectories = useMemo( + () => expandedScheduleDirectories.filter((directory) => scheduleDirectorySet.has(directory)), + [expandedScheduleDirectories, scheduleDirectorySet], + ); + + const handleExpandedScheduleDirectoriesChange = useCallback((directories: string[]) => { + setExpandedScheduleDirectories((current) => + current.length === directories.length && current.every((directory, index) => directory === directories[index]) + ? current + : directories, + ); + }, []); const baseDirectory = repo?.fullPath; const subscriptionDirectories = useMemo(() => { const set = new Set(); if (baseDirectory) set.add(baseDirectory); - workspaceDirectories.forEach((dir) => set.add(dir)); + nonScheduleWorkspaceDirectories.forEach((dir) => set.add(dir)); + activeScheduleDirectories.forEach((dir) => set.add(dir)); return Array.from(set); - }, [baseDirectory, workspaceDirectories]); + }, [baseDirectory, nonScheduleWorkspaceDirectories, activeScheduleDirectories]); const showWorktrees = activeTab === 'workspaces'; - const sessionListDirectories = showWorktrees ? workspaceDirectories : (baseDirectory ? [baseDirectory] : []); + const sessionListDirectories = useMemo(() => { + if (!showWorktrees) return baseDirectory ? [baseDirectory] : []; + return Array.from(new Set([...nonScheduleWorkspaceDirectories, ...activeScheduleDirectories])); + }, [showWorktrees, baseDirectory, nonScheduleWorkspaceDirectories, activeScheduleDirectories]); + + useEffect(() => { + if (!showWorktrees) setExpandedScheduleDirectories([]); + }, [showWorktrees]); useSSE(subscriptionDirectories); @@ -138,8 +163,8 @@ export function RepoDetail() { worktrees={workspaceSiblings} sessions={sessions} searchQuery={searchQuery} - scheduleNames={scheduleNames} renderSessionCard={renderSessionCard} + onExpandedScheduleDirectoriesChange={handleExpandedScheduleDirectoriesChange} onNewSession={handleCreateSession} onOpenTerminal={(directory) => openTerminal(null, { repoTab: "workspaces", terminalDirectory: directory })} onCreateWorktree={() => setCreateWorkspaceOpen(true)} diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index c1349d3d7..277446818 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { useLocation, useParams } from 'react-router-dom' -import type { CreateScheduleJobRequest, ScheduleJob } from '@opencode-manager/shared/types' +import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' import { useAllSchedules, useCancelRepoScheduleRun, @@ -11,12 +11,13 @@ import { useRepoSchedule, useRepoScheduleRuns, useRunRepoSchedule, + useScheduleWorktrees, useUpdateRepoSchedule, } from '@/hooks/useSchedules' import { useRepoActivity } from '@/hooks/useRepoActivity' import { useScheduleTarget } from '@/hooks/useScheduleTarget' import { useScheduleUrlState } from '@/hooks/useScheduleUrlState' -import { ScheduleJobDialog, ScheduleJobsTable, ScheduleListToolbar, JobDetailTab, RunHistoryTab, ScheduleTabMenu } from '@/components/schedules' +import { ScheduleJobDialog, ScheduleJobsTable, ScheduleListToolbar, JobDetailTab, RunHistoryTab, ScheduleRunRemovalDialog, ScheduleTabMenu } from '@/components/schedules' import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' import type { ScheduleJobWithRepo } from '@/api/schedules' import { Header } from '@/components/ui/header' @@ -60,6 +61,7 @@ export function Schedules() { ) const { data: selectedJob, isFetching: isJobFetching } = useRepoSchedule(repoId, jobId) const { data: runs, isLoading: runsLoading } = useRepoScheduleRuns(repoId, jobId, 30) + const { data: scheduleWorktrees = [] } = useScheduleWorktrees(repoId, jobId) const createMutation = useCreateRepoSchedule() const updateMutation = useUpdateRepoSchedule() @@ -74,8 +76,14 @@ export function Schedules() { const [runToDelete, setRunToDelete] = useState(null) const clearableRuns = useMemo(() => (runs ?? []).filter((run) => run.status !== 'running'), [runs]) - const clearableWorktrees = useMemo(() => clearableRuns.filter((run) => run.worktreePath).length, [clearableRuns]) - const clearableBranches = useMemo(() => clearableRuns.filter((run) => run.runBranch).length, [clearableRuns]) + const affectedClearWorktreeCount = useMemo( + () => scheduleWorktrees.filter((worktree) => worktree.runId !== null && !worktree.inUse).length, + [scheduleWorktrees], + ) + const runToDeleteWorktreeCount = useMemo( + () => (runToDelete !== null && scheduleWorktrees.some((worktree) => worktree.runId === runToDelete) ? 1 : 0), + [scheduleWorktrees, runToDelete], + ) useEffect(() => { if (scheduleTab === 'prompts') { @@ -224,22 +232,22 @@ export function Schedules() { }) } - const handleClearHistory = () => { + const handleClearHistory = (worktrees?: ScheduleRunWorktreesMode) => { if (jobId === null) { return } - clearRunsMutation.mutate({ repoId: repoId!, jobId }, { + clearRunsMutation.mutate({ repoId: repoId!, jobId, worktrees }, { onSuccess: () => setClearRunsOpen(false), }) } - const handleConfirmDeleteRun = () => { + const handleConfirmDeleteRun = (worktrees?: ScheduleRunWorktreesMode) => { if (jobId === null || runToDelete === null) { return } - deleteRunMutation.mutate({ repoId: repoId!, jobId, runId: runToDelete }, { + deleteRunMutation.mutate({ repoId: repoId!, jobId, runId: runToDelete, worktrees }, { onSuccess: () => setRunToDelete(null), }) } @@ -374,40 +382,28 @@ export function Schedules() { isDeleting={deleteMutation.isPending} /> - !open && setClearRunsOpen(false)} - onConfirm={handleClearHistory} - onCancel={() => setClearRunsOpen(false)} title="Clear run history" description={ - <> -

This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule.

- {clearableBranches > 0 && ( -

Git artifacts that will be pruned:

- )} -
    - {clearableWorktrees > 0 && ( -
  • {clearableWorktrees} worktree{clearableWorktrees === 1 ? '' : 's'}
  • - )} - {clearableBranches > 0 && ( -
  • {clearableBranches} run branch{clearableBranches === 1 ? '' : 'es'}
  • - )} -
-

A run in progress is kept. This cannot be undone.

- +

This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone.

} - isDeleting={clearRunsMutation.isPending} + affectedWorktreeCount={affectedClearWorktreeCount} + isPending={clearRunsMutation.isPending} + onCancel={() => setClearRunsOpen(false)} + onConfirm={handleClearHistory} /> - !open && setRunToDelete(null)} - onConfirm={handleConfirmDeleteRun} - onCancel={() => setRunToDelete(null)} title="Delete run" - description="This permanently deletes this run along with its git run branch and worktree. This cannot be undone." - isDeleting={deleteRunMutation.isPending} + description="This permanently deletes this run. This cannot be undone." + affectedWorktreeCount={runToDeleteWorktreeCount} + isPending={deleteRunMutation.isPending} + onCancel={() => setRunToDelete(null)} + onConfirm={handleConfirmDeleteRun} />
) diff --git a/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx b/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx new file mode 100644 index 000000000..d64c3fbfc --- /dev/null +++ b/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import type { Repo, Session } from '@/api/types' +import type { RepoSibling } from '@/api/repos' +import { RepoDetail } from '../RepoDetail' + +interface SessionListCapture { + directories?: string[] + renderSessions?: (args: { + sessions: Session[] + searchQuery: string + renderSessionCard: (session: Session) => ReactNode + }) => ReactNode +} + +const mocks = vi.hoisted(() => ({ + getRepo: vi.fn(), + useRepoSiblings: vi.fn(), + useCreateRepoWorkspace: vi.fn(), + useDeleteRepoWorkspaces: vi.fn(), + useRepoActivity: vi.fn(), + useSSE: vi.fn(), + useCreateSession: vi.fn(), + lastSessionListProps: { current: undefined as SessionListCapture | undefined }, +})) + +vi.mock('@/api/repos', () => ({ + getRepo: mocks.getRepo, + workspaceLabel: (workspace: { currentBranch?: string; branch?: string; localPath?: string }) => + workspace.currentBranch || workspace.branch || workspace.localPath || 'worktree', + worktreeSourceLabel: (worktree: { schedule?: { runId: number | null }; worktreeSource?: string }) => { + if (worktree.schedule) { + return worktree.schedule.runId === null ? 'Schedule · shared' : `Schedule · run #${worktree.schedule.runId}` + } + if (worktree.worktreeSource === 'git') return 'Git' + return worktree.worktreeSource === 'opencode' ? 'OpenCode' : null + }, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/hooks/useRepoSiblings', () => ({ + useRepoSiblings: mocks.useRepoSiblings, + useCreateRepoWorkspace: mocks.useCreateRepoWorkspace, + useDeleteRepoWorkspaces: mocks.useDeleteRepoWorkspaces, +})) + +vi.mock('@/hooks/useRepoActivity', () => ({ useRepoActivity: mocks.useRepoActivity })) +vi.mock('@/hooks/useSSE', () => ({ useSSE: mocks.useSSE })) +vi.mock('@/hooks/useOpenCode', () => ({ useCreateSession: mocks.useCreateSession })) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: (props: SessionListCapture) => { + mocks.lastSessionListProps.current = props + return ( +
+ {props.renderSessions?.({ + sessions: [], + searchQuery: '', + renderSessionCard: () => null, + })} +
+ ) + }, +})) + +vi.mock('@/components/repo/WorktreeTabs', () => ({ + WorktreeTabs: ({ onValueChange }: { onValueChange: (value: 'workspaces') => void }) => ( + + ), +})) + +vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalPanel: () => null })) +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: () => null })) +vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: () => null })) +vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: () => null })) +vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsDialog: () => null })) +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsDialog: () => null })) +vi.mock('@/components/source-control', () => ({ SourceControlPanel: () => null })) +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDialog: () => null })) +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null })) + +const baseRepo: Repo = { + id: 1, + name: 'my-repo', + localPath: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + defaultBranch: 'main', + currentBranch: 'main', + branch: 'main', + cloneStatus: 'ready', + clonedAt: 1, + isWorktree: false, +} + +const opencodeWorktree: RepoSibling = { + ...baseRepo, + id: 2, + fullPath: '/abs/repos/my-repo-ws', + currentBranch: 'feature/x', + branch: 'feature/x', + isWorktree: true, + worktreeSource: 'opencode', +} + +const scheduleWorktree: RepoSibling = { + ...baseRepo, + id: 3, + fullPath: '/abs/repos/schedule-nightly', + currentBranch: 'schedule/5/run-7', + branch: 'schedule/5/run-7', + isWorktree: true, + worktreeSource: 'schedule', + schedule: { repoId: 1, jobId: 5, runId: 7, inUse: false, name: 'Nightly' }, +} + +function renderRepoDetail() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + } /> + + + , + ) +} + +describe('RepoDetail schedule session fetching', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.lastSessionListProps.current = undefined + mocks.getRepo.mockResolvedValue(baseRepo) + mocks.useRepoSiblings.mockReturnValue({ data: [opencodeWorktree, scheduleWorktree] }) + mocks.useCreateRepoWorkspace.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useDeleteRepoWorkspaces.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useCreateSession.mockReturnValue({ mutate: vi.fn(), isPending: false }) + }) + + it('fetches a schedule worktree directory only after its group is expanded', async () => { + const user = userEvent.setup() + renderRepoDetail() + + await user.click(await screen.findByRole('button', { name: 'Worktrees tab' })) + + await waitFor(() => { + expect(mocks.lastSessionListProps.current?.directories).toEqual(['/abs/repos/my-repo-ws']) + }) + + await user.click(screen.getByRole('button', { expanded: false, name: /schedule\/5\/run-7/ })) + + await waitFor(() => { + expect(mocks.lastSessionListProps.current?.directories).toEqual([ + '/abs/repos/my-repo-ws', + '/abs/repos/schedule-nightly', + ]) + }) + }) +}) diff --git a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx index 46c84bec6..203b27e1e 100644 --- a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx +++ b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx @@ -64,7 +64,6 @@ vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: () vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsDialog: () => null })) vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsDialog: () => null })) vi.mock('@/components/source-control', () => ({ SourceControlPanel: () => null })) -vi.mock('@/hooks/useSchedules', () => ({ useAllSchedules: () => ({ data: [] }) })) vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDialog: () => null })) vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null })) diff --git a/frontend/src/pages/__tests__/Schedules.test.tsx b/frontend/src/pages/__tests__/Schedules.test.tsx index 59478a1fc..630dc0fb4 100644 --- a/frontend/src/pages/__tests__/Schedules.test.tsx +++ b/frontend/src/pages/__tests__/Schedules.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ useCancelRepoScheduleRun: vi.fn(), useClearRepoScheduleRuns: vi.fn(), useDeleteRepoScheduleRun: vi.fn(), + useScheduleWorktrees: vi.fn(), useRepoActivity: vi.fn(), useScheduleUrlState: vi.fn(), })) @@ -41,6 +42,7 @@ vi.mock('@/hooks/useSchedules', () => ({ useCancelRepoScheduleRun: mocks.useCancelRepoScheduleRun, useClearRepoScheduleRuns: mocks.useClearRepoScheduleRuns, useDeleteRepoScheduleRun: mocks.useDeleteRepoScheduleRun, + useScheduleWorktrees: mocks.useScheduleWorktrees, })) vi.mock('@/hooks/useRepoActivity', () => ({ @@ -73,6 +75,7 @@ vi.mock('@/components/schedules', () => ({ )), RunHistoryTab: vi.fn(() =>
RunHistoryTab
), + ScheduleRunRemovalDialog: () => null, ScheduleTabMenu: vi.fn(() =>
ScheduleTabMenu
), })) @@ -135,6 +138,7 @@ describe('Schedules', () => { mocks.useCancelRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.useClearRepoScheduleRuns.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.useDeleteRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useScheduleWorktrees.mockReturnValue({ data: [], isLoading: false }) }) describe('assistant schedule target (repoId=0)', () => { diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index d2a0878a8..e9bb5d3ed 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -11,13 +11,20 @@ export type ScheduleMode = z.infer /** * Where a scheduled run executes. `worktree` gives every run a fresh worktree that is - * removed when the run ends, `kept-worktree` keeps each run's worktree afterwards, - * `shared-worktree` reuses one worktree and branch for every run of the schedule, and - * `repo` runs directly in the repository checkout without isolation. + * removed when the run ends, `kept-worktree` keeps each run's worktree afterwards, and + * `shared-worktree` reuses one worktree and branch for every run of the schedule. */ -export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree', 'repo']) +export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree']) export type ScheduleWorkspaceMode = z.infer +/** + * What clearing run history does with the run worktrees that still exist on disk. + * `commit` commits their pending changes to their branches and keeps those branches; + * `discard` force-removes the worktrees and deletes their branches. + */ +export const ScheduleRunWorktreesModeSchema = z.enum(['commit', 'discard']).default('commit') +export type ScheduleRunWorktreesMode = z.infer + /** * A schedule worktree that still exists on disk. `runId` is null for the schedule's * shared worktree, and `inUse` is true while a running run is working in it. diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 5375c0dfe..6a02e8432 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -155,6 +155,7 @@ export type { export type { ScheduleMode, ScheduleWorkspaceMode, + ScheduleRunWorktreesMode, ScheduleWorktree, RemoveScheduleWorktreesRequest, SchedulePermissionConfig, diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index 662ed20b8..a12efff08 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -1,3 +1,5 @@ +import type { Repo } from '../types' + export const ASSISTANT_REPO_ID = 0 export const ASSISTANT_REPO_NAME = 'Assistant' export const ASSISTANT_REPO_PATH = 'assistant' @@ -94,13 +96,24 @@ export type RepoWorktreeSource = 'opencode' | 'schedule' | 'git' /** * The schedule a worktree belongs to. `runId` is null for the schedule's shared worktree, - * and `inUse` is true while a running run works in it. + * `inUse` is true while a running run works in it, and `name` is the schedule's display name. */ export interface RepoWorktreeSchedule { repoId: number jobId: number runId: number | null inUse: boolean + name: string +} + +/** + * A repository visible from another repository in the same project: the repo itself, a plain + * git or OpenCode worktree, or a schedule worktree. + */ +export type RepoSibling = Repo & { + currentBranch?: string + worktreeSource?: RepoWorktreeSource + schedule?: RepoWorktreeSchedule } export function isWorktreeSibling(sibling: { worktreeSource?: RepoWorktreeSource }): boolean { From 8ac0f317584a6e82fed8c59660d7eece3eb839d5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:15:21 -0400 Subject: [PATCH 3/4] fix(schedules): render schedule-only worktrees and fix job detail mobile layout - mount the worktrees session list for schedule-only repos so kept worktrees are visible and expandable - keep worktree groups mounted while sessions load so schedule group expansion is not reset - constrain the job detail grid on mobile so the worktree path no longer overflows and clips - label the icon-only Remove all worktrees button - avoid invalid p-in-p nesting in the clear history description --- frontend/src/components/schedules/JobDetailTab.tsx | 3 ++- frontend/src/components/session/SessionList.tsx | 4 ++-- frontend/src/pages/RepoDetail.tsx | 6 +----- frontend/src/pages/Schedules.tsx | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index 1274cb59e..a3bacaae2 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -97,7 +97,7 @@ export function JobDetailTab({
-
+
@@ -127,6 +127,7 @@ export function JobDetailTab({
); } - if (hasNextPage || isFetchingNextPage) { + if ((hasNextPage || isFetchingNextPage) && !renderSessions) { return
Loading sessions...
; } if (!searchQuery.trim() && !renderSessions) { diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index f4608f498..b9a93d824 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -270,11 +270,7 @@ export function RepoDetail() { />
- {showWorktrees && workspaceSiblings.length === 0 ? ( -
- {renderWorktreeGroups({ sessions: [], searchQuery: "", renderSessionCard: () => null })} -
- ) : sessionListDirectories.length > 0 && ( + {(showWorktrees || sessionListDirectories.length > 0) && ( !open && setClearRunsOpen(false)} title="Clear run history" description={ -

This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone.

+ <>This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone. } affectedWorktreeCount={affectedClearWorktreeCount} isPending={clearRunsMutation.isPending} From 7612ee31e5fc16d8366723547cd0fe65a142e217 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:21:51 -0400 Subject: [PATCH 4/4] feat(schedules): switch repository from the schedules header - add ScheduleRepoSwitcher: the header title opens a picker with All repos and every repository, on desktop (dropdown) and mobile (sheet with search) - wire it into the per-repo schedules header and the all-repos header so the scope can be changed from either page - mark the current scope active and navigate on selection --- .../schedules/ScheduleRepoSwitcher.test.tsx | 143 ++++++++++++++ .../schedules/ScheduleRepoSwitcher.tsx | 176 ++++++++++++++++++ frontend/src/components/schedules/index.ts | 1 + frontend/src/pages/GlobalSchedules.tsx | 7 +- frontend/src/pages/Schedules.tsx | 4 +- .../pages/__tests__/GlobalSchedules.test.tsx | 1 + .../src/pages/__tests__/Schedules.test.tsx | 1 + 7 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/schedules/ScheduleRepoSwitcher.test.tsx create mode 100644 frontend/src/components/schedules/ScheduleRepoSwitcher.tsx diff --git a/frontend/src/components/schedules/ScheduleRepoSwitcher.test.tsx b/frontend/src/components/schedules/ScheduleRepoSwitcher.test.tsx new file mode 100644 index 000000000..da5a3c39f --- /dev/null +++ b/frontend/src/components/schedules/ScheduleRepoSwitcher.test.tsx @@ -0,0 +1,143 @@ +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { MemoryRouter, useLocation } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ScheduleRepoSwitcher } from './ScheduleRepoSwitcher' +import { listRepos } from '@/api/repos' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' + +const mobileState = { current: false } + +vi.mock('@/hooks/useMobile', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useMobile: () => mobileState.current } +}) + +vi.mock('@/api/repos') + +function createRepo(id: number, name: string) { + return { + id, + name, + repoUrl: `https://github.com/test/${name}.git`, + localPath: `${name}-path`, + fullPath: `/repos/${name}-path`, + sourcePath: null, + currentBranch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 0, + lastAccessedAt: 0, + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } +} + +function LocationSpy() { + const { pathname } = useLocation() + return
{pathname}
+} + +function renderSwitcher(repoId: number | undefined, name: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + + , + ) +} + +describe('ScheduleRepoSwitcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mobileState.current = false + vi.mocked(listRepos).mockResolvedValue([ + createRepo(1, 'repo1'), + createRepo(2, 'repo2'), + createRepo(ASSISTANT_REPO_ID, 'Assistant'), + ]) + }) + + it('shows the current repository and switches to another repo (desktop)', async () => { + const user = userEvent.setup() + renderSwitcher(1, 'repo1') + + await user.click(screen.getByRole('button', { name: 'Switch repository (repo1)' })) + + await waitFor(() => expect(screen.getByText('All repos')).toBeInTheDocument()) + expect(screen.getByText('repo2')).toBeInTheDocument() + + await user.click(screen.getByText('repo2')) + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/repos/2/schedules')) + }) + + it('switches to all repos', async () => { + const user = userEvent.setup() + renderSwitcher(1, 'repo1') + + await user.click(screen.getByRole('button', { name: 'Switch repository (repo1)' })) + await waitFor(() => expect(screen.getByText('All repos')).toBeInTheDocument()) + + await user.click(screen.getByText('All repos')) + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/schedules')) + }) + + it('marks the current repository as active', async () => { + const user = userEvent.setup() + renderSwitcher(1, 'repo1') + + await user.click(screen.getByRole('button', { name: 'Switch repository (repo1)' })) + const menu = await screen.findByRole('menu') + + expect(within(menu).getByText('repo1').closest('[aria-current="true"]')).not.toBeNull() + expect(within(menu).getByText('repo2').closest('[aria-current="true"]')).toBeNull() + }) + + it('navigates to the assistant schedules for repo 0', async () => { + const user = userEvent.setup() + renderSwitcher(ASSISTANT_REPO_ID, 'Assistant') + + await user.click(screen.getByRole('button', { name: 'Switch repository (Assistant)' })) + await waitFor(() => expect(screen.getByText('repo2')).toBeInTheDocument()) + + await user.click(screen.getByText('repo2')) + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/repos/2/schedules')) + }) + + it('filters repositories by search on mobile', async () => { + mobileState.current = true + renderSwitcher(1, 'repo1') + + fireEvent.click(screen.getByRole('button', { name: 'Switch repository (repo1)' })) + + const search = await screen.findByPlaceholderText('Search repositories...') + fireEvent.change(search, { target: { value: 'repo2' } }) + + const sheet = screen.getByRole('dialog', { name: 'Switch repository' }) + await waitFor(() => { + expect(within(sheet).getByText('repo2')).toBeInTheDocument() + expect(within(sheet).queryByText('repo1')).not.toBeInTheDocument() + }) + }) + + it('switches repositories from the mobile sheet', async () => { + mobileState.current = true + renderSwitcher(1, 'repo1') + + fireEvent.click(screen.getByRole('button', { name: 'Switch repository (repo1)' })) + await screen.findByPlaceholderText('Search repositories...') + await waitFor(() => expect(screen.getByText('repo2')).toBeInTheDocument()) + + fireEvent.click(screen.getByText('repo2')) + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/repos/2/schedules')) + }) +}) diff --git a/frontend/src/components/schedules/ScheduleRepoSwitcher.tsx b/frontend/src/components/schedules/ScheduleRepoSwitcher.tsx new file mode 100644 index 000000000..93f107e68 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleRepoSwitcher.tsx @@ -0,0 +1,176 @@ +import { Fragment, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Check, ChevronsUpDown, FolderGit2, LayoutGrid } from 'lucide-react' +import { getRepoDisplayName } from '@/lib/utils' +import { useMobile } from '@/hooks/useMobile' +import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { Input } from '@/components/ui/input' +import { BottomSheet, BottomSheetContent, BottomSheetHeader } from '@/components/ui/bottom-sheet' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +const ALL_REPOS_PATH = '/schedules' + +interface ScheduleRepoSwitcherProps { + repoId?: number + name: string +} + +interface RepoOption { + key: string + label: string + description: string + path: string + active: boolean + isAllRepos: boolean +} + +function schedulePath(repoId: number): string { + return `/repos/${repoId}/schedules` +} + +export function ScheduleRepoSwitcher({ repoId, name }: ScheduleRepoSwitcherProps) { + const navigate = useNavigate() + const isMobile = useMobile() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const { repos } = useNavigableRepos() + + const options = useMemo(() => { + const allRepos: RepoOption = { + key: 'all', + label: 'All repos', + description: 'Every repository', + path: ALL_REPOS_PATH, + active: repoId === undefined, + isAllRepos: true, + } + const repoOptions = repos.map((repo): RepoOption => { + const label = getRepoDisplayName(repo) + const localPath = repo.localPath ?? '' + return { + key: String(repo.id), + label, + description: localPath && localPath !== label ? localPath : '', + path: schedulePath(repo.id), + active: repo.id === repoId, + isAllRepos: false, + } + }) + return [allRepos, ...repoOptions] + }, [repos, repoId]) + + const filteredOptions = useMemo(() => { + const term = search.trim().toLowerCase() + if (!term) return options + return options.filter((option) => ( + option.label.toLowerCase().includes(term) || option.description.toLowerCase().includes(term) + )) + }, [options, search]) + + const close = () => { + setOpen(false) + setSearch('') + } + + const select = (option: RepoOption) => { + close() + if (!option.active) navigate(option.path) + } + + const renderOptionContent = (option: RepoOption) => ( + <> + {option.isAllRepos ? ( + + ) : ( + + )} + + {option.label} + {option.description && {option.description}} + + {option.active && } + + ) + + const trigger = (onClick?: () => void) => ( + + ) + + if (isMobile) { + return ( + <> + {trigger(() => setOpen(true))} + + + setSearch(event.target.value)} + autoComplete="off" + name="schedule-repo-switch" + className="mt-2" + /> + + + {filteredOptions.length === 0 ? ( +
+ +

No repositories found

+
+ ) : ( +
+ {filteredOptions.map((option) => ( + + ))} +
+ )} +