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..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, @@ -12,6 +13,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 +35,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 @@ -100,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, @@ -118,6 +126,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { permissionConfig: parsePermissionConfig(row.permission_config), mcpServers: parseMcpServers(row.mcp_servers), branch: row.branch, + workspaceMode: normalizeWorkspaceMode(row.workspace_mode), createdAt: row.created_at, updatedAt: row.updated_at, lastRunAt: row.last_run_at, @@ -199,9 +208,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 +230,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 +255,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 +274,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 +310,21 @@ 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 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 { + 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..4f0efb137 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -243,9 +243,16 @@ 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) +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) const notificationService = new NotificationService(db) diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index 9da42bac7..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,10 +64,10 @@ 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) + const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, stubGitAuthService, projectConfigService, terminalService, scheduleService) app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces)) return app } @@ -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 a421a2f67..96f295a42 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) @@ -399,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 98b9c5d8d..20f7bc7a3 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -1,8 +1,11 @@ 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' @@ -36,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() @@ -152,17 +163,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) @@ -212,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) @@ -224,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 c01dcbdb2..dfd0c08ce 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), 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 { "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/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 07f3d32ea..4a393b764 100644 --- a/backend/src/services/repo-workspace.ts +++ b/backend/src/services/repo-workspace.ts @@ -6,9 +6,11 @@ import type { GitAuthService } from './git-auth' import type { OpenCodeClient } from './opencode/client' import type { ProjectConfigService } from './project-config' import type { TerminalService } from './terminal' -import { findSiblingByDirectory, getSiblingRepos, RepoWorkspaceError, resolveRepoProjectId } from './repo' +import type { ScheduleService } from './schedules' +import { findSiblingByDirectory, getSiblingRepos, removeWorktree, RepoWorkspaceError, resolveRepoProjectId } from './repo' +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 +18,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 +32,33 @@ 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 (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) + + 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') { + 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 + } + 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..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 } 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,7 +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 { listActiveScheduleRunWorktreePaths } from '../db/schedules' +import { createScheduleWorktreeDescriber } from './schedule-worktree-paths' import { SettingsService } from './settings' import { buildAssistantRepo } from './assistant-mode' import type { OpenCodeClient } from './opencode/client' @@ -1019,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 { @@ -1234,7 +1245,7 @@ export async function getSiblingRepos( 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 +1276,100 @@ 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, + ...(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 [] } } 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..7f149ce95 --- /dev/null +++ b/backend/src/services/schedule-worktree-paths.ts @@ -0,0 +1,64 @@ +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 getScheduleWorktreePath(jobId: number, runId: number | null): string { + return path.join(getScheduleWorktreesPath(), runId === null ? `job-${jobId}-shared` : `job-${jobId}-run-${runId}`) +} + +export function getScheduleWorktreeBranch(jobId: number, runId: number | null): string { + return runId === null ? `schedule/${jobId}/shared` : `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 } +} + +/** + * 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 ec1fa7ce5..118d50add 100644 --- a/backend/src/services/schedule-worktree.ts +++ b/backend/src/services/schedule-worktree.ts @@ -1,15 +1,22 @@ -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 { 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 { + getScheduleWorktreeBranch, + getScheduleWorktreePath, + isSharedScheduleWorktreePath, + parseScheduleWorktreeName, +} from './schedule-worktree-paths' export interface ScheduleWorktreeContext { directory: string @@ -17,6 +24,16 @@ 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 +} + /** * Build repo-context environment variables used by the GIT_ASKPASS handler * to resolve repo-specific credentials. Mirrors GitService.getEnvironmentForRepo. @@ -34,9 +51,15 @@ export class ScheduleWorktreeManager { private readonly db: Database, ) {} + /** + * 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 }, + job: { id: number; branch: string | null; workspaceMode: ScheduleWorkspaceMode }, runId: number, ): Promise { if (repo.id === ASSISTANT_REPO_ID) return null @@ -47,6 +70,11 @@ export class ScheduleWorktreeManager { return null } + const shared = job.workspaceMode === 'shared-worktree' + 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)) { await this.gitAuthService.setupSSHForRepoUrl(repo.repoUrl, this.db) @@ -56,6 +84,14 @@ export class ScheduleWorktreeManager { try { 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 } + } + await executeCommand(['git', '-C', repo.fullPath, 'fetch', '--prune', 'origin'], { env }).catch(() => {}) const base = job.branch?.trim() || (await resolveDefaultBranch(repo.fullPath, env)) @@ -64,9 +100,12 @@ 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) + } + + await this.assertRunBranchAvailable(repo.fullPath, runBranch, 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 +121,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 = isSharedScheduleWorktreePath(run.worktreePath) || job.workspaceMode === 'kept-worktree' let sshSetup = false let env: Record | undefined let commitHash: string | null = null @@ -103,33 +148,31 @@ export class ScheduleWorktreeManager { env = await this.buildGitEnv(repo, sshSetup, false) - const status = await executeCommand(['git', '-C', run.worktreePath, 'status', '--porcelain'], { env }).catch(() => '') + 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}`, + ) - 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 }) - - 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,30 +181,141 @@ 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: getScheduleWorktreeBranch(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. + * 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, + job: { id: number; name: string }, artifacts: { runBranch: string | null; worktreePath: string | null }[], + mode: ScheduleRunWorktreesMode = 'commit', ): Promise { - if (artifacts.length === 0) return + 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((artifact) => artifact.runBranch) + .filter((branch): branch is string => branch !== null && branch.length > 0 && branch !== sharedBranch) + + if (runWorktrees.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) - }), - ) + 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) + } - 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 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( + 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 + } + } + + /** + * 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.`) } } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 12ab0952f..82b41da9d 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,8 @@ import { type ScheduleJob, type ScheduleRun, type ScheduleRunTriggerSource, + type ScheduleRunWorktreesMode, + type ScheduleWorktree, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' import { mcpStatusByName, openCodeLocation } from '@opencode-manager/shared/opencode' @@ -13,6 +17,7 @@ import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' import { cleanupOrphanedSchedules, + clearScheduleRunWorktreePath, createScheduleJob, createScheduleRun, deleteScheduleJob, @@ -53,7 +58,9 @@ 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 { createScheduleWorktreeDescriber } from './schedule-worktree-paths' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' class ScheduleServiceError extends Error { @@ -67,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 @@ -222,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, @@ -233,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) { @@ -319,8 +333,75 @@ export class ScheduleService { return listEnabledScheduleJobs(this.db) } - listAllJobsWithRepos(): ScheduleJobWithRepo[] { - return listAllScheduleJobsWithRepos(this.db) + listAllJobsWithRepos(): Array { + const describeSchedule = createScheduleWorktreeDescriber(this.db) + const counts = new Map() + for (const entry of this.worktreeManager.listWorktrees()) { + 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 })) + } + + /** + * 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 describeSchedule = createScheduleWorktreeDescriber(this.db) + + return this.worktreeManager.listWorktrees(jobId) + .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)) + } + + /** + * 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) + if (this.onWorktreeRemoved) { + await this.onWorktreeRemoved(target.worktreePath) + } + } + + return { removed: targets.length } } listAllRuns(options: ListAllRunsOptions = {}): ScheduleRunWithContext[] { @@ -391,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 { @@ -406,9 +495,9 @@ 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) + 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) @@ -419,15 +508,27 @@ export class ScheduleService { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } - const deleted = deleteScheduleJob(this.db, repoId, jobId) - if (!deleted) { - throw new ScheduleServiceError('Schedule not found', 404) - } this.onJobChange?.(null, jobId) + ScheduleService.activeRuns.set(jobId, { runId: DELETION_RESERVATION_RUN_ID, abort: new AbortController() }) + + 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) + } } - 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) @@ -437,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) } } @@ -472,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, 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, [{ 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) @@ -1109,17 +1217,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..3a2ded491 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, @@ -428,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 }), @@ -668,19 +683,11 @@ 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('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) - 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.listScheduleJobWorktreeOwners(mockDb)).toEqual([{ id: 7, repoId: 42, name: 'nightly' }]) }) 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/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/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..a5c592438 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, 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/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/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 d9768327a..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, 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' @@ -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,64 @@ 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).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', 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('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) - 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) + 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 () => { @@ -1021,7 +1074,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..d4c640882 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', 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', 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', worktreeSource: 'opencode' }]) const { service } = createService({ worktreeRemove, removeAll }) await expect(service.remove(REPO, '/worktrees/feature-x')).resolves.toBeUndefined() @@ -173,14 +176,56 @@ describe('RepoWorkspaceService', () => { force: true, }) }) + 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, name: 'Shared job' }, + }]) + 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() + }) + + 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, name: 'Run job' }, + }]) + 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', 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 77a2a977e..7e5159724 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 '@opencode-manager/shared/utils' 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 () => { @@ -509,23 +536,21 @@ 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() }) - 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,9 +562,10 @@ 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_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' }, @@ -548,8 +574,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, name: 'nightly' } }) + 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..b5727356d 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) @@ -274,10 +274,10 @@ 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 } + 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, { id: 90, name: 'Prune job' }, [{ runBranch, worktreePath }], 'discard') expect(existsSync(worktreePath)).toBe(false) const branchList = execSync(`git -C "${baseRepoPath}" branch --list "${runBranch}"`, { @@ -295,4 +295,125 @@ describe('ScheduleWorktreeManager', () => { }).trim() expect(branchList).toBe('') }) + + it('pruneRunArtifacts commits a kept worktree and keeps its branch in commit mode', async () => { + const manager = await createManager() + const repo = testRepo() + const job = { id: 91, branch: null, name: 'Commit prune', workspaceMode: 'kept-worktree' as const } + const runId = 910 + + 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 () => { + 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) + + const second = await manager.prepare(repo, job, 2) + expect(second!.worktreePath).toBe(sharedPath) + + 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) + + 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.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..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,8 @@ 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(), listScheduleRunsByJob: vi.fn(), @@ -35,6 +39,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 +61,8 @@ 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, listScheduleRunsByJob: mocks.listScheduleRunsByJob, @@ -146,6 +154,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), @@ -182,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()) @@ -910,34 +920,46 @@ describe('ScheduleService', () => { expect(stub.api.agent.list).toHaveBeenCalledTimes(1) }) - it('throws when deleting or loading missing records', () => { + 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) 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', () => { + it('blocks prepareRepoDelete when a running run exists in activeRuns', async () => { const stub = createStubScheduleApi() const service = makeService(stub.api) const onJobChange = vi.fn() @@ -945,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() @@ -957,36 +979,40 @@ 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() }) - 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) }) - 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 () => { @@ -1233,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()) @@ -1494,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()) @@ -2153,6 +2181,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2191,6 +2220,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2227,6 +2257,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2262,6 +2293,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2297,6 +2329,7 @@ describe('ScheduleRunner', () => { permissionConfig: null, mcpServers: [], branch: null, + workspaceMode: 'worktree', nextRunAt: Date.now(), lastRunAt: null, createdAt: Date.now(), @@ -2325,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) }) @@ -2346,14 +2380,28 @@ describe('ScheduleService run history cleanup', () => { const result = await makeService().clearRunHistory(42, 7) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, [ + 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' }, @@ -2372,12 +2420,47 @@ describe('ScheduleService run history cleanup', () => { await makeService().deleteRun(42, 7, 5) - expect(mocks.stubWorktreeManager.pruneRunArtifacts).toHaveBeenCalledWith(repo, [ + 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', () => { + 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: 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: 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 }, + ]) + }) + + 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' }) @@ -2385,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 3cb89bf63..0c529f7c3 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -2,7 +2,11 @@ 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 { 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 @@ -38,13 +42,16 @@ export async function getRepo(id: number): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`) } -export type RepoSibling = Repo & { - currentBranch?: string - worktreeStrategy?: string +export function workspaceLabel(workspace: RepoSibling): string { + return workspace.currentBranch || workspace.branch || workspace.localPath || 'worktree' } -export function workspaceLabel(workspace: RepoSibling): string { - return workspace.currentBranch || workspace.branch || workspace.localPath || 'workspace' +export function worktreeSourceLabel(worktree: RepoSibling): string | null { + if (worktree.schedule) { + return `Schedule · ${formatScheduleWorktreeLabel(worktree.schedule.runId)}` + } + if (worktree.worktreeSource === 'git') return 'Git' + return worktree.worktreeSource === 'opencode' ? 'OpenCode' : null } export interface RepoWorktree { @@ -63,9 +70,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..715c81777 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -2,9 +2,12 @@ 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' @@ -22,6 +25,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoPath: string repoUrl: string lastRun: ScheduleRunSummary | null + retainedWorktreeCount: number } export interface ScheduleRunWithContext extends ScheduleRun { @@ -156,14 +160,28 @@ 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', }) } -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 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, 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(request), + }) +} + +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/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..f152e4575 --- /dev/null +++ b/frontend/src/components/repo/WorktreeSessionGroups.test.tsx @@ -0,0 +1,200 @@ +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, + ...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, name: 'Nightly' }, +}) + +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, name: 'Nightly' }, +}) + +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 + onExpandedScheduleDirectoriesChange?: (directories: string[]) => void +} = {}) { + return render( +
{session.title}
} + onExpandedScheduleDirectoriesChange={overrides.onExpandedScheduleDirectoriesChange} + 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('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() + 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('Nightly')).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('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('Nightly')).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..caf97543d --- /dev/null +++ b/frontend/src/components/repo/WorktreeSessionGroups.tsx @@ -0,0 +1,418 @@ +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 + renderSessionCard: (session: Session) => ReactNode + onExpandedScheduleDirectoriesChange?: (directories: string[]) => void + 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; name: string; 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, + renderSessionCard, + onExpandedScheduleDirectoriesChange, + onNewSession, + onOpenTerminal, + onCreateWorktree, + onDelete, + isDeleting = false, +}: 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) => { + 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 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 = 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 ( +
+

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 = isGroupOpen(group) + 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 = entry.name + 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 isScheduleWorktree(worktree: RepoSibling): boolean { + return worktree.worktreeSource === 'schedule' +} + +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 schedule = group.worktree.schedule + if (!schedule) { + entries.push({ kind: 'worktree', key: groupKey(group.directory), group, lastActive: group.lastActive }) + return + } + const existing = scheduleEntries.get(schedule.jobId) + if (existing) { + existing.groups.push(group) + existing.lastActive = Math.max(existing.lastActive, group.lastActive) + return + } + const entry: Extract = { + kind: 'schedule', + key: `schedule:${schedule.jobId}`, + jobId: schedule.jobId, + name: schedule.name, + groups: [group], + lastActive: group.lastActive, + } + scheduleEntries.set(schedule.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..04e01fc7e 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' @@ -14,7 +14,7 @@ const makeWorkspaceSibling = (branch: string): RepoSibling => ({ isWorktree: true, currentBranch: branch, branch, - worktreeStrategy: 'git', + worktreeSource: 'opencode', }) const onValueChange = vi.fn() @@ -30,18 +30,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 +69,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..21b05d314 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,11 +161,22 @@ export function GeneralTab({
-
+
+ {showWorkspaceMode && ( +
+

Workspace

+ +
+ )}
- +
!worktree.inUse).length if (!selectedJob) { return ( @@ -90,7 +97,7 @@ export function JobDetailTab({
-
+
@@ -109,6 +116,50 @@ 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}

    +

    + {formatScheduleWorktreeLabel(worktree.runId)} + {worktree.inUse ? ' · in use' : ''} · {worktree.worktreePath} +

    +
    + +
  • + ))} +
+
+ )}
@@ -131,6 +182,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/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) => ( + + ))} +
+ )} +
@@ -139,9 +134,6 @@ export const SessionCard = ({ addSuffix: true, })} - {workspaceLabel ? ( - {workspaceLabel} - ) : null}
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..a1bebb2d4 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"; @@ -16,18 +16,28 @@ interface SessionListProps { directory?: string; directories?: string[]; createDirectory?: string; - 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 = ({ directory, directories, createDirectory, - directoryLabels, activeSessionID, onSelectSession, + renderSessions, }: SessionListProps) => { const directoriesList = useMemo(() => { const source = directories && directories.length > 0 ? directories : directory ? [directory] : []; @@ -100,7 +110,7 @@ export const SessionList = ({ } }, [isLoading, filteredSessions, hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - if (isLoading) { + if (isLoading && !renderSessions) { return
Loading sessions...
; } @@ -115,10 +125,10 @@ export const SessionList = ({
); } - if (hasNextPage || isFetchingNextPage) { + if ((hasNextPage || isFetchingNextPage) && !renderSessions) { return
Loading sessions...
; } - if (!searchQuery.trim()) { + if (!searchQuery.trim() && !renderSessions) { return (
toggleSessionSelection(session, selected)} @@ -285,7 +294,13 @@ export const SessionList = ({ onScroll={handleSessionsScroll} >
- {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..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, @@ -11,9 +11,11 @@ import { listAllScheduleRuns, listAllSchedules, listRepoScheduleRuns, + listScheduleWorktrees, listUnreadScheduleRuns, markAllScheduleRunsViewed, markScheduleRunViewed, + removeScheduleWorktrees, runRepoSchedule, updateRepoSchedule, } from '@/api/schedules' @@ -222,12 +224,13 @@ 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] }) 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) => { @@ -240,12 +243,13 @@ 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] }) 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 ? { 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/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/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 26ce632ab..772b27745 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -2,9 +2,9 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { useAllSchedules, useAllScheduleRuns, useCancelRepoScheduleRun, useUnreadScheduleRuns } from '@/hooks/useSchedules' import { useDeleteRepoSchedule, useRunRepoSchedule, useUpdateRepoSchedule, useCreateRepoSchedule } from '@/hooks/useSchedules' -import { ScheduleJobDialog, PromptsTab, ScheduleJobsTable, ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable } from '@/components/schedules' +import { ScheduleJobDialog, PromptsTab, ScheduleJobsTable, ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable, ScheduleRepoSwitcher } 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' @@ -402,7 +402,10 @@ export function GlobalSchedules() {
- Schedules +
+ +

Schedules

+
@@ -779,7 +782,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..b9a93d824 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, useEffect, 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,13 @@ 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 { 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 +32,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 +48,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], @@ -68,102 +69,110 @@ 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 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]); - 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 showWorktrees = activeTab === 'workspaces'; + const sessionListDirectories = useMemo(() => { + if (!showWorktrees) return baseDirectory ? [baseDirectory] : []; + return Array.from(new Set([...nonScheduleWorkspaceDirectories, ...activeScheduleDirectories])); + }, [showWorktrees, baseDirectory, nonScheduleWorkspaceDirectories, activeScheduleDirectories]); - const activeWorkspaceLabel = activeWorkspaceDirectory ? directoryLabels[activeWorkspaceDirectory] : undefined; + useEffect(() => { + if (!showWorktrees) setExpandedScheduleDirectories([]); + }, [showWorktrees]); 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 +227,7 @@ export function RepoDetail() {
- +
- - + + + + + ); diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 018214243..7fd85e05b 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,13 +11,14 @@ 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 { matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' +import { ScheduleJobDialog, ScheduleJobsTable, ScheduleListToolbar, JobDetailTab, RunHistoryTab, ScheduleRunRemovalDialog, ScheduleTabMenu, ScheduleRepoSwitcher } 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' import { Button } from '@/components/ui/button' @@ -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), }) } @@ -253,7 +261,7 @@ export function Schedules() {
- {scheduleTarget.name} +

{scheduleTarget.subtitle}

@@ -370,44 +378,32 @@ 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} /> - !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__/GlobalSchedules.test.tsx b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx index 67f91721d..c5142ea36 100644 --- a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx +++ b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx @@ -45,6 +45,7 @@ vi.mock('@/components/schedules', () => ({ ScheduleRunDrawer: mocks.ScheduleRunDrawer, ScheduleListToolbar: ({ children }: { children?: React.ReactNode }) =>
{children}
, PromptsTab: vi.fn(() => null), + ScheduleRepoSwitcher: ({ name }: { name: string }) =>
{name}
, })) function makeRun(overrides: Record = {}) { 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 a37dd83c8..203b27e1e 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,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('@/components/repo/WorkspaceManager', () => ({ WorkspaceManager: () => null })) vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDialog: () => null })) vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null })) @@ -106,12 +106,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 +123,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 +157,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 +171,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/frontend/src/pages/__tests__/Schedules.test.tsx b/frontend/src/pages/__tests__/Schedules.test.tsx index 59478a1fc..73900c899 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,7 +75,9 @@ vi.mock('@/components/schedules', () => ({
)), RunHistoryTab: vi.fn(() =>
RunHistoryTab
), + ScheduleRunRemovalDialog: () => null, ScheduleTabMenu: vi.fn(() =>
ScheduleTabMenu
), + ScheduleRepoSwitcher: ({ name }: { name: string }) =>
{name}
, })) function createMockScheduleUrlState(overrides: Record = {}) { @@ -135,6 +139,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/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..e9bb5d3ed 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -9,6 +9,39 @@ 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, and + * `shared-worktree` reuses one worktree and branch for every run of the schedule. + */ +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. + */ +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 +186,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 +226,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 +257,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..6a02e8432 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,10 @@ export type { export type { ScheduleMode, + ScheduleWorkspaceMode, + ScheduleRunWorktreesMode, + ScheduleWorktree, + RemoveScheduleWorktreesRequest, SchedulePermissionConfig, ScheduleRunTriggerSource, ScheduleRunStatus, diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index 50d1ad979..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' @@ -87,8 +89,35 @@ 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, + * `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 { + return sibling.worktreeSource !== undefined } export function getRepoBaseDirectoryName(repo: { localPath: string; branch?: string; isWorktree?: boolean }): string {