diff --git a/backend/src/db/migrations/202610181300-session-server-scope.ts b/backend/src/db/migrations/202610181300-session-server-scope.ts new file mode 100644 index 000000000..ea7431244 --- /dev/null +++ b/backend/src/db/migrations/202610181300-session-server-scope.ts @@ -0,0 +1,144 @@ +import type { Database } from 'bun:sqlite' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import type { Migration } from '../migration-runner' + +const SERVER_ID_COLUMN = 'server_id' +const PERMISSION_MODES_TABLE = 'session_permission_modes' +const PERMISSION_MODES_REBUILD_TABLE = 'session_permission_modes_rebuild' + +interface ColumnInfo { + name: string + pk: number +} + +function tableExists(db: Database, table: string): boolean { + return Boolean( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), + ) +} + +function tableColumns(db: Database, table: string): ColumnInfo[] { + return db.prepare(`PRAGMA table_info(${table})`).all() as ColumnInfo[] +} + +function hasColumn(db: Database, table: string, column: string): boolean { + return tableColumns(db, table).some((entry) => entry.name === column) +} + +function primaryKeyColumns(db: Database, table: string): string[] { + return tableColumns(db, table) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name) +} + +function addServerIdColumn(db: Database, table: string): void { + if (!tableExists(db, table) || hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`ALTER TABLE ${table} ADD COLUMN server_id TEXT NOT NULL DEFAULT '${LOCAL_OPENCODE_SERVER_ID}'`) +} + +function dropServerIdColumn(db: Database, table: string): void { + if (!tableExists(db, table) || !hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`ALTER TABLE ${table} DROP COLUMN ${SERVER_ID_COLUMN}`) +} + +function deleteRemoteRows(db: Database, table: string): void { + if (!tableExists(db, table) || !hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`DELETE FROM ${table} WHERE ${SERVER_ID_COLUMN} <> '${LOCAL_OPENCODE_SERVER_ID}'`) +} + +function rebuildPermissionModesForServers(db: Database): void { + if (!tableExists(db, PERMISSION_MODES_TABLE) || !hasColumn(db, PERMISSION_MODES_TABLE, SERVER_ID_COLUMN)) { + return + } + const primaryKey = primaryKeyColumns(db, PERMISSION_MODES_TABLE) + if (primaryKey.length === 2 && primaryKey[0] === SERVER_ID_COLUMN && primaryKey[1] === 'session_id') { + return + } + + db.run(`DROP TABLE IF EXISTS ${PERMISSION_MODES_REBUILD_TABLE}`) + db.run(` + CREATE TABLE ${PERMISSION_MODES_REBUILD_TABLE} ( + session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT '${LOCAL_OPENCODE_SERVER_ID}', + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, session_id) + ) + `) + db.run(` + INSERT INTO ${PERMISSION_MODES_REBUILD_TABLE}(session_id, server_id, mode, updated_at) + SELECT session_id, ${SERVER_ID_COLUMN}, mode, updated_at FROM ${PERMISSION_MODES_TABLE} + `) + db.run(`DROP TABLE ${PERMISSION_MODES_TABLE}`) + db.run(`ALTER TABLE ${PERMISSION_MODES_REBUILD_TABLE} RENAME TO ${PERMISSION_MODES_TABLE}`) +} + +function rebuildPermissionModesForLocalSessions(db: Database): void { + if (!tableExists(db, PERMISSION_MODES_TABLE) || !hasColumn(db, PERMISSION_MODES_TABLE, SERVER_ID_COLUMN)) { + return + } + const primaryKey = primaryKeyColumns(db, PERMISSION_MODES_TABLE) + if (primaryKey.length === 1 && primaryKey[0] === 'session_id') { + return + } + + db.run(`DROP TABLE IF EXISTS ${PERMISSION_MODES_REBUILD_TABLE}`) + db.run(` + CREATE TABLE ${PERMISSION_MODES_REBUILD_TABLE} ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + db.run(` + INSERT INTO ${PERMISSION_MODES_REBUILD_TABLE}(session_id, mode, updated_at) + SELECT session_id, mode, updated_at FROM ${PERMISSION_MODES_TABLE} + WHERE ${SERVER_ID_COLUMN} = '${LOCAL_OPENCODE_SERVER_ID}' + `) + db.run(`DROP TABLE ${PERMISSION_MODES_TABLE}`) + db.run(`ALTER TABLE ${PERMISSION_MODES_REBUILD_TABLE} RENAME TO ${PERMISSION_MODES_TABLE}`) +} + +const migration: Migration = { + id: '202610181300-session-server-scope', + + up(db) { + addServerIdColumn(db, PERMISSION_MODES_TABLE) + addServerIdColumn(db, 'session_goals') + rebuildPermissionModesForServers(db) + + db.run('DROP INDEX IF EXISTS idx_session_goals_open') + if (tableExists(db, 'session_goals')) { + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(server_id, session_id) + WHERE status IN ('active','paused') + `) + } + }, + + down(db) { + deleteRemoteRows(db, 'session_goals') + deleteRemoteRows(db, PERMISSION_MODES_TABLE) + + db.run('DROP INDEX IF EXISTS idx_session_goals_open') + if (tableExists(db, 'session_goals')) { + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(session_id) + WHERE status IN ('active','paused') + `) + } + rebuildPermissionModesForLocalSessions(db) + dropServerIdColumn(db, 'session_goals') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610181310-multi-run-targets.ts b/backend/src/db/migrations/202610181310-multi-run-targets.ts new file mode 100644 index 000000000..bd932c7da --- /dev/null +++ b/backend/src/db/migrations/202610181310-multi-run-targets.ts @@ -0,0 +1,84 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string + notnull: number +} + +const REBUILT_COLUMNS = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL +` + +function needsRebuild(columns: ColumnInfo[]): boolean { + if (columns.length === 0) return false + + const byName = new Map(columns.map((column) => [column.name, column])) + const repoId = byName.get('repo_id') + if (!repoId || repoId.notnull === 1) return true + return !byName.has('server_id') || !byName.has('directory') +} + +const migration: Migration = { + id: '202610181310-multi-run-targets', + + up(db) { + const columns = db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] + if (!needsRebuild(columns)) return + + const names = new Set(columns.map((column) => column.name)) + const serverId = names.has('server_id') ? 'server_id' : "'local'" + const directory = names.has('directory') ? 'directory' : 'NULL' + + db.run('DROP TABLE IF EXISTS multi_runs_targets') + db.run(`CREATE TABLE multi_runs_targets (${REBUILT_COLUMNS})`) + db.run(` + INSERT INTO multi_runs_targets (id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + SELECT id, repo_id, ${serverId}, ${directory}, name, prompt, isolated, base_ref, created_at + FROM multi_runs + `) + db.run('DROP TABLE multi_runs') + db.run('ALTER TABLE multi_runs_targets RENAME TO multi_runs') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_server ON multi_runs(server_id, directory, created_at DESC)') + }, + + down(db) { + const columns = db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] + if (columns.length === 0) return + + const names = new Set(columns.map((column) => column.name)) + if (!names.has('server_id') && !names.has('directory')) return + + db.run('DROP TABLE IF EXISTS multi_runs_repo_only') + db.run(` + CREATE TABLE multi_runs_repo_only ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL + ) + `) + db.run(` + INSERT INTO multi_runs_repo_only (id, repo_id, name, prompt, isolated, base_ref, created_at) + SELECT id, repo_id, name, prompt, isolated, base_ref, created_at + FROM multi_runs + WHERE repo_id IS NOT NULL + `) + db.run('DROP TABLE multi_runs') + db.run('ALTER TABLE multi_runs_repo_only RENAME TO multi_runs') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610181320-remote-schedule-targets.ts b/backend/src/db/migrations/202610181320-remote-schedule-targets.ts new file mode 100644 index 000000000..7ad36fafa --- /dev/null +++ b/backend/src/db/migrations/202610181320-remote-schedule-targets.ts @@ -0,0 +1,254 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string + notnull: number +} + +const LEGACY_SUFFIX = '_legacy_202610181320' + +function createJobsTable(db: { run: (sql: string) => void }, table: string): void { + db.run(` + CREATE TABLE ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + schedule_mode TEXT NOT NULL DEFAULT 'interval', + cron_expression TEXT, + timezone TEXT, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + permission_config TEXT, + mcp_servers TEXT, + branch TEXT, + workspace_mode TEXT NOT NULL DEFAULT 'worktree', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) +} + +function createRunsTable(db: { run: (sql: string) => void }, table: string): void { + db.run(` + CREATE TABLE ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE, + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + server_id TEXT NOT NULL DEFAULT 'local', + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + viewed_at INTEGER, + created_at INTEGER NOT NULL, + session_id TEXT, + session_title TEXT, + log_text TEXT, + response_text TEXT, + error_text TEXT, + run_branch TEXT, + commit_hash TEXT, + worktree_path TEXT + ) + `) +} + +const migration: Migration = { + id: '202610181320-remote-schedule-targets', + + up(db) { + const jobColumns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] + if (jobColumns.length === 0) { + return + } + + const runColumns = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] + const jobColumnNames = new Set(jobColumns.map((column) => column.name)) + const runColumnNames = new Set(runColumns.map((column) => column.name)) + const jobsNeedRebuild = + !jobColumnNames.has('server_id') || + !jobColumnNames.has('directory') || + jobColumns.find((column) => column.name === 'repo_id')?.notnull !== 0 + const runsNeedRebuild = + !runColumnNames.has('server_id') || + runColumns.find((column) => column.name === 'repo_id')?.notnull !== 0 + + if (!jobsNeedRebuild && !runsNeedRebuild) { + return + } + + const jobsLegacy = `schedule_jobs${LEGACY_SUFFIX}` + const runsLegacy = `schedule_runs${LEGACY_SUFFIX}` + + db.run(`DROP TABLE IF EXISTS ${runsLegacy}`) + db.run(`DROP TABLE IF EXISTS ${jobsLegacy}`) + + db.run(`ALTER TABLE schedule_jobs RENAME TO ${jobsLegacy}`) + db.run(`ALTER TABLE schedule_runs RENAME TO ${runsLegacy}`) + + createJobsTable(db, 'schedule_jobs') + createRunsTable(db, 'schedule_runs') + + const jobServerColumn = jobColumnNames.has('server_id') ? 'server_id' : "'local'" + const jobDirectoryColumn = jobColumnNames.has('directory') ? 'directory' : 'NULL' + const jobOptionalColumns = ['permission_config', 'mcp_servers', 'branch', 'workspace_mode'] + .map((column) => (jobColumnNames.has(column) ? column : 'NULL')) + + db.run(` + INSERT INTO schedule_jobs ( + id, repo_id, server_id, directory, name, description, enabled, interval_minutes, schedule_mode, + cron_expression, timezone, agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, + branch, workspace_mode, created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, repo_id, ${jobServerColumn}, ${jobDirectoryColumn}, name, description, enabled, interval_minutes, + COALESCE(schedule_mode, 'interval'), cron_expression, timezone, agent_slug, prompt, model, skill_metadata, + ${jobOptionalColumns[0]}, ${jobOptionalColumns[1]}, ${jobOptionalColumns[2]}, + COALESCE(${jobOptionalColumns[3]}, 'worktree'), created_at, updated_at, last_run_at, next_run_at + FROM ${jobsLegacy} + `) + + const runServerColumn = runColumnNames.has('server_id') ? 'server_id' : "'local'" + const runOptionalColumns = ['viewed_at', 'run_branch', 'commit_hash', 'worktree_path'] + .map((column) => (runColumnNames.has(column) ? column : 'NULL')) + + db.run(` + INSERT INTO schedule_runs ( + id, job_id, repo_id, server_id, trigger_source, status, started_at, finished_at, viewed_at, + created_at, session_id, session_title, log_text, response_text, error_text, run_branch, + commit_hash, worktree_path + ) + SELECT + id, job_id, repo_id, ${runServerColumn}, trigger_source, status, started_at, finished_at, + ${runOptionalColumns[0]}, created_at, session_id, session_title, log_text, response_text, error_text, + ${runOptionalColumns[1]}, ${runOptionalColumns[2]}, ${runOptionalColumns[3]} + FROM ${runsLegacy} + `) + + db.run(`DROP TABLE ${runsLegacy}`) + db.run(`DROP TABLE ${jobsLegacy}`) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_server ON schedule_jobs(server_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_session ON schedule_runs(session_id, started_at DESC)') + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_unread + ON schedule_runs(finished_at DESC) + WHERE viewed_at IS NULL + `) + }, + + down(db) { + db.run('DELETE FROM schedule_runs WHERE repo_id IS NULL') + db.run('DELETE FROM schedule_jobs WHERE repo_id IS NULL') + + const jobsLegacy = `schedule_jobs${LEGACY_SUFFIX}` + const runsLegacy = `schedule_runs${LEGACY_SUFFIX}` + + db.run(`DROP TABLE IF EXISTS ${runsLegacy}`) + db.run(`DROP TABLE IF EXISTS ${jobsLegacy}`) + + db.run(`ALTER TABLE schedule_jobs RENAME TO ${jobsLegacy}`) + db.run(`ALTER TABLE schedule_runs RENAME TO ${runsLegacy}`) + + db.run(` + CREATE TABLE schedule_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + schedule_mode TEXT NOT NULL DEFAULT 'interval', + cron_expression TEXT, + timezone TEXT, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + permission_config TEXT, + mcp_servers TEXT, + branch TEXT, + workspace_mode TEXT NOT NULL DEFAULT 'worktree', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) + + db.run(` + CREATE TABLE schedule_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + viewed_at INTEGER, + created_at INTEGER NOT NULL, + session_id TEXT, + session_title TEXT, + log_text TEXT, + response_text TEXT, + error_text TEXT, + run_branch TEXT, + commit_hash TEXT, + worktree_path TEXT + ) + `) + + db.run(` + INSERT INTO schedule_jobs ( + id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone, + agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, + created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone, + agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, + created_at, updated_at, last_run_at, next_run_at + FROM ${jobsLegacy} + `) + + db.run(` + INSERT INTO schedule_runs ( + id, job_id, repo_id, trigger_source, status, started_at, finished_at, viewed_at, created_at, + session_id, session_title, log_text, response_text, error_text, run_branch, commit_hash, worktree_path + ) + SELECT + id, job_id, repo_id, trigger_source, status, started_at, finished_at, viewed_at, created_at, + session_id, session_title, log_text, response_text, error_text, run_branch, commit_hash, worktree_path + FROM ${runsLegacy} + `) + + db.run(`DROP TABLE ${runsLegacy}`) + db.run(`DROP TABLE ${jobsLegacy}`) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_session ON schedule_runs(session_id, started_at DESC)') + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_unread + ON schedule_runs(finished_at DESC) + WHERE viewed_at IS NULL + `) + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 93cb73794..5ef12db80 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -34,6 +34,9 @@ import migration202610141200 from './202610141200-session-sandbox-modes' import migration202610151200 from './202610151200-change-walkthrough-sessions' import migration202610161200 from './202610161200-change-walkthrough-execution' import migration202610181200 from './202610181200-opencode-servers' +import migration202610181300 from './202610181300-session-server-scope' +import migration202610181310 from './202610181310-multi-run-targets' +import migration202610181320 from './202610181320-remote-schedule-targets' export const allMigrations: Migration[] = [ migration001, @@ -71,4 +74,7 @@ export const allMigrations: Migration[] = [ migration202610151200, migration202610161200, migration202610181200, + migration202610181300, + migration202610181310, + migration202610181320, ] diff --git a/backend/src/db/multi-runs.ts b/backend/src/db/multi-runs.ts index 8a039233a..14466a806 100644 --- a/backend/src/db/multi-runs.ts +++ b/backend/src/db/multi-runs.ts @@ -4,6 +4,7 @@ import { type MultiRunEntryStatus, type MultiRunFusionSource, type MultiRunFusionStatus, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' export interface MultiRunEntryRecord { @@ -37,7 +38,9 @@ export interface MultiRunFusionRecord { export interface MultiRunRecord { id: number - repoId: number + repoId: number | null + serverId: string + directory: string | null name: string prompt: string isolated: boolean @@ -48,7 +51,9 @@ export interface MultiRunRecord { } export interface CreateMultiRunGroup { - repoId: number + repoId: number | null + serverId: string + directory: string | null name: string prompt: string isolated: boolean @@ -81,7 +86,9 @@ export interface MultiRunFusionPatch { interface MultiRunRow { id: number - repo_id: number + repo_id: number | null + server_id: string + directory: string | null name: string prompt: string isolated: number @@ -118,7 +125,7 @@ interface MultiRunFusionRow { updated_at: number } -const MULTI_RUN_COLUMNS = 'id, repo_id, name, prompt, isolated, base_ref, created_at' +const MULTI_RUN_COLUMNS = 'id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at' const MULTI_RUN_ENTRY_COLUMNS = 'id, multi_run_id, model, status, session_id, directory, error, created_at, updated_at' @@ -129,7 +136,9 @@ export function ensureMultiRunTables(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS multi_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_id INTEGER NOT NULL, + repo_id INTEGER, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, name TEXT NOT NULL, prompt TEXT NOT NULL, isolated INTEGER NOT NULL, @@ -158,6 +167,10 @@ export function ensureMultiRunTables(db: Database): void { CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC) `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_multi_runs_server + ON multi_runs(server_id, directory, created_at DESC) + `) } export function ensureMultiRunFusionTable(db: Database): void { @@ -194,8 +207,20 @@ export function createMultiRunWithEntries( const now = Date.now() const create = db.transaction(() => { const result = db - .prepare(`INSERT INTO multi_runs(repo_id, name, prompt, isolated, base_ref, created_at) VALUES(?,?,?,?,?,?)`) - .run(group.repoId, group.name, group.prompt, group.isolated ? 1 : 0, group.baseRef, now) + .prepare( + `INSERT INTO multi_runs(repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(?,?,?,?,?,?,?,?)`, + ) + .run( + group.repoId, + group.serverId, + group.directory, + group.name, + group.prompt, + group.isolated ? 1 : 0, + group.baseRef, + now, + ) const multiRunId = Number(result.lastInsertRowid) const insertEntry = db.prepare(` @@ -226,15 +251,20 @@ export function getMultiRun(db: Database, id: number): MultiRunRecord | null { return toMultiRunRecord(row, loadEntries(db, row.id), loadFusions(db, row.id)) } -export function listMultiRuns(db: Database, repoId: number, limit: number): MultiRunRecord[] { +export function listMultiRuns(db: Database, target: MultiRunTarget, limit: number): MultiRunRecord[] { + const [where, params] = + target.kind === 'repo' + ? ['repo_id = ?', [target.repoId] as const] + : ['server_id = ? AND directory = ?', [target.serverId, target.directory] as const] + const rows = db .prepare(` SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs - WHERE repo_id = ? + WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ? `) - .all(repoId, limit) as MultiRunRow[] + .all(...params, limit) as MultiRunRow[] if (rows.length === 0) { return [] } @@ -445,6 +475,8 @@ function toMultiRunRecord( return { id: row.id, repoId: row.repo_id, + serverId: row.server_id, + directory: row.directory, name: row.name, prompt: row.prompt, isolated: row.isolated === 1, diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index db9f11f36..1e677d10f 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite' import { + LOCAL_OPENCODE_SERVER_ID, ScheduleJobSchema, ScheduleMcpServersSchema, SchedulePermissionConfigSchema, @@ -20,7 +21,9 @@ import type { ScheduleJobPersistenceInput } from '../services/schedule-config' interface ScheduleJobRow { id: number - repo_id: number + repo_id: number | null + server_id: string + directory: string | null name: string description: string | null enabled: number @@ -45,7 +48,8 @@ interface ScheduleJobRow { interface ScheduleRunRow { id: number job_id: number - repo_id: number + repo_id: number | null + server_id: string trigger_source: string status: string started_at: number @@ -112,6 +116,8 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { return ScheduleJobSchema.parse({ id: row.id, repoId: row.repo_id, + serverId: row.server_id ?? 'local', + directory: row.directory ?? null, name: row.name, description: row.description, enabled: Boolean(row.enabled), @@ -139,6 +145,7 @@ function rowToScheduleRun(row: ScheduleRunRow): ScheduleRun { id: row.id, jobId: row.job_id, repoId: row.repo_id, + serverId: row.server_id ?? 'local', triggerSource: row.trigger_source, status: row.status, startedAt: row.started_at, @@ -177,13 +184,19 @@ function serializeMcpServers(mcpServers: ScheduleJobPersistenceInput['mcpServers } export function listScheduleJobsByRepo(db: Database, repoId: number): ScheduleJob[] { - const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') const rows = stmt.all(repoId) as ScheduleJobRow[] return rows.map(rowToScheduleJob) } +export function listScheduleJobsByServer(db: Database, serverId: string): ScheduleJob[] { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE server_id = ? ORDER BY created_at DESC') + const rows = stmt.all(serverId) as ScheduleJobRow[] + return rows.map(rowToScheduleJob) +} + export function listScheduleJobIdsByRepo(db: Database, repoId: number): number[] { - const stmt = db.prepare('SELECT id FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + const stmt = db.prepare('SELECT id FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') const rows = stmt.all(repoId) as Array<{ id: number }> return rows.map((row) => row.id) } @@ -194,28 +207,35 @@ export function listEnabledScheduleJobs(db: Database): ScheduleJob[] { return rows.map(rowToScheduleJob) } -export function getScheduleJobById(db: Database, repoId: number, jobId: number): ScheduleJob | null { - const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? AND id = ?') +export function getScheduleJobById(db: Database, repoId: number | null, jobId: number): ScheduleJob | null { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? AND id = ?') const row = stmt.get(repoId, jobId) as ScheduleJobRow | undefined return row ? rowToScheduleJob(row) : null } -export function createScheduleJob(db: Database, repoId: number, input: ScheduleJobPersistenceInput): ScheduleJob { +export function createScheduleJob( + db: Database, + repoId: number | null, + input: ScheduleJobPersistenceInput, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): ScheduleJob { const now = Date.now() const stmt = db.prepare(` INSERT INTO schedule_jobs ( - repo_id, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata, + repo_id, server_id, directory, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, created_at, updated_at, last_run_at, next_run_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( repoId, + serverId, + input.directory ?? null, input.name, input.description ?? null, input.enabled ? 1 : 0, @@ -244,7 +264,7 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ return job } -export function updateScheduleJob(db: Database, repoId: number, jobId: number, input: ScheduleJobPersistenceInput): ScheduleJob | null { +export function updateScheduleJob(db: Database, repoId: number | null, jobId: number, input: ScheduleJobPersistenceInput): ScheduleJob | null { const existing = getScheduleJobById(db, repoId, jobId) if (!existing) { return null @@ -255,8 +275,8 @@ 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 = ?, workspace_mode = ?, updated_at = ?, next_run_at = ? - WHERE repo_id = ? AND id = ? + agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, directory = ?, updated_at = ?, next_run_at = ? + WHERE repo_id IS ? AND id = ? `) stmt.run( @@ -275,6 +295,7 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i serializeMcpServers(input.mcpServers), input.branch, input.workspaceMode, + input.directory ?? null, now, input.nextRunAt, repoId, @@ -284,9 +305,9 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i return getScheduleJobById(db, repoId, jobId) } -export function deleteScheduleJob(db: Database, repoId: number, jobId: number): boolean { - db.prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?').run(repoId, jobId) - const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') +export function deleteScheduleJob(db: Database, repoId: number | null, jobId: number): boolean { + db.prepare('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ?').run(repoId, jobId) + const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') const result = stmt.run(repoId, jobId) return result.changes > 0 } @@ -298,9 +319,9 @@ export interface ScheduleRunArtifact { worktreePath: string | null } -export function listScheduleRunArtifactsByJob(db: Database, repoId: number, jobId: number): ScheduleRunArtifact[] { +export function listScheduleRunArtifactsByJob(db: Database, repoId: number | null, jobId: number): ScheduleRunArtifact[] { const rows = db - .prepare('SELECT id, status, run_branch, worktree_path FROM schedule_runs WHERE repo_id = ? AND job_id = ? ORDER BY id DESC') + .prepare('SELECT id, status, run_branch, worktree_path FROM schedule_runs WHERE repo_id IS ? AND job_id = ? ORDER BY id DESC') .all(repoId, jobId) as { id: number; status: string; run_branch: string | null; worktree_path: string | null }[] return rows.map((row) => ({ id: row.id, @@ -317,28 +338,28 @@ export interface ScheduleJobWorktreeOwner { } 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 }> + const rows = db.prepare('SELECT id, repo_id, name FROM schedule_jobs WHERE repo_id IS NOT NULL').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 { +export function clearScheduleRunWorktreePath(db: Database, repoId: number | null, jobId: number, worktreePath: string): number { return db - .prepare('UPDATE schedule_runs SET worktree_path = NULL WHERE repo_id = ? AND job_id = ? AND worktree_path = ?') + .prepare('UPDATE schedule_runs SET worktree_path = NULL WHERE repo_id IS ? AND job_id = ? AND worktree_path = ?') .run(repoId, jobId, worktreePath).changes } -export function deleteScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): boolean { +export function deleteScheduleRunById(db: Database, repoId: number | null, jobId: number, runId: number): boolean { const result = db - .prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + .prepare('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') .run(repoId, jobId, runId) return result.changes > 0 } -export function deleteScheduleRunsByIds(db: Database, repoId: number, jobId: number, runIds: number[]): number { +export function deleteScheduleRunsByIds(db: Database, repoId: number | null, jobId: number, runIds: number[]): number { if (runIds.length === 0) return 0 const placeholders = runIds.map(() => '?').join(', ') const result = db - .prepare(`DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id IN (${placeholders})`) + .prepare(`DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id IN (${placeholders})`) .run(repoId, jobId, ...runIds) return result.changes } @@ -346,22 +367,22 @@ export function deleteScheduleRunsByIds(db: Database, repoId: number, jobId: num export function cleanupOrphanedSchedules(db: Database): { orphanedJobs: number; orphanedRuns: number } { const runStmt = db.prepare(` DELETE FROM schedule_runs - WHERE (repo_id != ? AND repo_id NOT IN (SELECT id FROM repos)) + WHERE (repo_id IS NOT NULL AND repo_id != ? AND repo_id NOT IN (SELECT id FROM repos)) OR job_id NOT IN (SELECT id FROM schedule_jobs) `) const orphanedRuns = runStmt.run(ASSISTANT_REPO_ID).changes const jobStmt = db.prepare(` DELETE FROM schedule_jobs - WHERE repo_id != ? AND repo_id NOT IN (SELECT id FROM repos) + WHERE repo_id IS NOT NULL AND repo_id != ? AND repo_id NOT IN (SELECT id FROM repos) `) const orphanedJobs = jobStmt.run(ASSISTANT_REPO_ID).changes return { orphanedJobs, orphanedRuns } } -export function updateScheduleJobRunState(db: Database, repoId: number, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { - const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') +export function updateScheduleJobRunState(db: Database, repoId: number | null, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { + const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id IS ? AND id = ?') stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId) } @@ -374,7 +395,8 @@ export function createScheduleRun( db: Database, input: { jobId: number - repoId: number + repoId: number | null + serverId?: string triggerSource: ScheduleRunTriggerSource status: ScheduleRunStatus startedAt: number @@ -382,13 +404,14 @@ export function createScheduleRun( }, ): ScheduleRun { const stmt = db.prepare(` - INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO schedule_runs (job_id, repo_id, server_id, trigger_source, status, started_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( input.jobId, input.repoId, + input.serverId ?? LOCAL_OPENCODE_SERVER_ID, input.triggerSource, input.status, input.startedAt, @@ -404,7 +427,7 @@ export function createScheduleRun( export function updateScheduleRun( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { @@ -420,7 +443,7 @@ export function updateScheduleRun( const stmt = db.prepare(` UPDATE schedule_runs SET status = ?, finished_at = ?, session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -441,7 +464,7 @@ export function updateScheduleRun( export function updateScheduleRunMetadata( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { @@ -460,7 +483,7 @@ export function updateScheduleRunMetadata( const stmt = db.prepare(` UPDATE schedule_runs SET session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -479,7 +502,7 @@ export function updateScheduleRunMetadata( export function updateScheduleRunWorktree( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { worktreePath?: string | null; runBranch?: string | null; commitHash?: string | null }, @@ -492,7 +515,7 @@ export function updateScheduleRunWorktree( const stmt = db.prepare(` UPDATE schedule_runs SET worktree_path = ?, run_branch = ?, commit_hash = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -507,22 +530,26 @@ export function updateScheduleRunWorktree( return getScheduleRunById(db, repoId, jobId, runId) } -export function getScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): ScheduleRun | null { - const stmt = db.prepare('SELECT * FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') +export function getScheduleRunById(db: Database, repoId: number | null, jobId: number, runId: number): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') const row = stmt.get(repoId, jobId, runId) as ScheduleRunRow | undefined return row ? rowToScheduleRun(row) : null } -export function getScheduleRunBySessionId(db: Database, sessionId: string): ScheduleRun | null { - const stmt = db.prepare('SELECT * FROM schedule_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 1') - const row = stmt.get(sessionId) as ScheduleRunRow | undefined +export function getScheduleRunBySessionId( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE server_id = ? AND session_id = ? ORDER BY started_at DESC LIMIT 1') + const row = stmt.get(serverId, sessionId) as ScheduleRunRow | undefined return row ? rowToScheduleRun(row) : null } -export function getRunningScheduleRunByJob(db: Database, repoId: number, jobId: number): ScheduleRun | null { +export function getRunningScheduleRunByJob(db: Database, repoId: number | null, jobId: number): ScheduleRun | null { const stmt = db.prepare(` SELECT * FROM schedule_runs - WHERE repo_id = ? AND job_id = ? AND status = 'running' + WHERE repo_id IS ? AND job_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1 `) @@ -541,12 +568,13 @@ export function listRunningScheduleRuns(db: Database, limit: number = 100): Sche return rows.map(rowToScheduleRun) } -export function listScheduleRunsByJob(db: Database, repoId: number, jobId: number, limit: number = 20): ScheduleRun[] { +export function listScheduleRunsByJob(db: Database, repoId: number | null, jobId: number, limit: number = 20): ScheduleRun[] { const stmt = db.prepare(` SELECT id, job_id, repo_id, + server_id, trigger_source, status, started_at, @@ -562,7 +590,7 @@ export function listScheduleRunsByJob(db: Database, repoId: number, jobId: numbe commit_hash, worktree_path FROM schedule_runs - WHERE repo_id = ? AND job_id = ? + WHERE repo_id IS ? AND job_id = ? ORDER BY started_at DESC LIMIT ? `) @@ -583,6 +611,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + serverName: string | null lastRun: ScheduleRunSummary | null } @@ -591,6 +620,7 @@ interface ScheduleJobWithRepoRow extends ScheduleJobRow { repo_path: string | null repo_name: string | null repo_source_path: string | null + server_name: string | null last_run_id: number | null last_run_status: string | null last_run_started_at: number | null @@ -601,14 +631,19 @@ interface ScheduleJobWithRepoRow extends ScheduleJobRow { } interface RepoDisplayRow { - repo_id: number + repo_id: number | null repo_path: string | null repo_name: string | null repo_url?: string | null repo_source_path?: string | null + directory?: string | null + server_name?: string | null } function resolveRepoDisplay(row: RepoDisplayRow): { repoName: string; repoPath: string } { + if (row.repo_id === null) { + return { repoName: row.server_name ?? 'Linked server', repoPath: row.directory ?? '' } + } if (row.repo_id === ASSISTANT_REPO_ID) { return { repoName: ASSISTANT_REPO_NAME, repoPath: ASSISTANT_REPO_PATH } } @@ -641,6 +676,7 @@ function rowToScheduleJobWithRepo(row: ScheduleJobWithRepoRow): ScheduleJobWithR ...rowToScheduleJob(row), ...resolveRepoDisplay(row), repoUrl: row.repo_url ?? '', + serverName: row.server_name ?? null, lastRun: buildLastRunSummary(row), } } @@ -649,6 +685,7 @@ export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[ const stmt = db.prepare(` SELECT sj.*, r.repo_url, r.local_path as repo_path, r.name as repo_name, r.source_path as repo_source_path, + os.name AS server_name, sr.id AS last_run_id, sr.status AS last_run_status, sr.started_at AS last_run_started_at, @@ -658,10 +695,11 @@ export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[ substr(sr.response_text, 1, 600) AS last_run_response_head FROM schedule_jobs sj LEFT JOIN repos r ON sj.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' LEFT JOIN schedule_runs sr ON sr.id = ( SELECT id FROM schedule_runs WHERE job_id = sj.id ORDER BY started_at DESC LIMIT 1 ) - ORDER BY COALESCE(r.local_path, ''), sj.name + ORDER BY COALESCE(r.local_path, sj.directory, ''), sj.name `) const rows = stmt.all() as ScheduleJobWithRepoRow[] return rows.map(rowToScheduleJobWithRepo) @@ -671,6 +709,7 @@ export interface ScheduleRunWithContext extends ScheduleRun { jobName: string repoName: string repoPath: string + serverName: string | null } interface ScheduleRunWithContextRow extends ScheduleRunRow { @@ -679,12 +718,15 @@ interface ScheduleRunWithContextRow extends ScheduleRunRow { repo_name: string | null repo_url: string | null repo_source_path: string | null + directory?: string | null + server_name?: string | null } function rowToScheduleRunWithContext(row: ScheduleRunWithContextRow): ScheduleRunWithContext { return { ...rowToScheduleRun(row), jobName: row.job_name, + serverName: row.server_name ?? null, ...resolveRepoDisplay(row), } } @@ -732,7 +774,7 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = const searchTerm = search?.trim() if (searchTerm) { const pattern = `%${escapeLikePattern(searchTerm)}%` - const searchColumns = ['sj.name', 'sr.session_title', 'sr.error_text', 'sr.run_branch', 'r.name', 'r.local_path'] + const searchColumns = ['sj.name', 'sr.session_title', 'sr.error_text', 'sr.run_branch', 'r.name', 'r.local_path', 'sj.directory', 'os.name'] conditions.push(`(${searchColumns.map((column) => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})`) params.push(...searchColumns.map(() => pattern)) } @@ -741,17 +783,18 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = const stmt = db.prepare(` SELECT - sr.id, sr.job_id, sr.repo_id, sr.trigger_source, sr.status, + sr.id, sr.job_id, sr.repo_id, sr.server_id, sr.trigger_source, sr.status, sr.started_at, sr.finished_at, sr.created_at, sr.session_id, sr.session_title, NULL AS log_text, NULL AS response_text, sr.error_text, sr.viewed_at, sr.run_branch, sr.commit_hash, sr.worktree_path, - sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, - r.repo_url AS repo_url, r.source_path AS repo_source_path + sj.name AS job_name, sj.directory AS directory, r.local_path AS repo_path, r.name AS repo_name, + r.repo_url AS repo_url, r.source_path AS repo_source_path, os.name AS server_name FROM schedule_runs sr JOIN schedule_jobs sj ON sr.job_id = sj.id LEFT JOIN repos r ON sr.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' ${whereClause} ORDER BY sr.started_at DESC LIMIT ? OFFSET ? @@ -810,17 +853,18 @@ interface UnreadScheduleRunRow extends ScheduleRunWithContextRow { export function listUnreadScheduleRuns(db: Database, limit: number = 20): ScheduleRunWithUnreadPreview[] { const stmt = db.prepare(` SELECT - sr.id, sr.job_id, sr.repo_id, sr.trigger_source, sr.status, + sr.id, sr.job_id, sr.repo_id, sr.server_id, sr.trigger_source, sr.status, sr.started_at, sr.finished_at, sr.created_at, sr.session_id, sr.session_title, sr.viewed_at, NULL AS log_text, NULL AS response_text, sr.error_text, sr.run_branch, sr.commit_hash, sr.worktree_path, - sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, - r.repo_url AS repo_url, r.source_path AS repo_source_path, + sj.name AS job_name, sj.directory AS directory, r.local_path AS repo_path, r.name AS repo_name, + r.repo_url AS repo_url, r.source_path AS repo_source_path, os.name AS server_name, substr(sr.response_text, 1, 600) AS response_head FROM schedule_runs sr JOIN schedule_jobs sj ON sr.job_id = sj.id LEFT JOIN repos r ON sr.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' WHERE sr.status IN ('completed', 'failed') AND sr.viewed_at IS NULL ORDER BY (sr.status = 'failed') DESC, sr.finished_at DESC LIMIT ? diff --git a/backend/src/db/session-goals.ts b/backend/src/db/session-goals.ts index 070977c78..5a2a4620e 100644 --- a/backend/src/db/session-goals.ts +++ b/backend/src/db/session-goals.ts @@ -1,14 +1,16 @@ import type { Database } from 'bun:sqlite' -import type { - SessionGoalStatus, - SessionGoalStopReason, - SessionGoalTurnState, - SessionGoalVerdict, +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionGoalStatus, + type SessionGoalStopReason, + type SessionGoalTurnState, + type SessionGoalVerdict, } from '@opencode-manager/shared/schemas' export interface SessionGoalRecord { id: number sessionId: string + serverId: string directory: string objective: string status: SessionGoalStatus @@ -29,6 +31,7 @@ export interface SessionGoalRecord { export interface InsertSessionGoalInput { sessionId: string + serverId?: string directory: string objective: string maxContinuations: number @@ -51,6 +54,7 @@ export interface SessionGoalPatch { interface SessionGoalRow { id: number session_id: string + server_id: string directory: string objective: string status: SessionGoalStatus @@ -70,7 +74,7 @@ interface SessionGoalRow { } const SESSION_GOAL_COLUMNS = ` - id, session_id, directory, objective, status, stop_reason, turn_state, + id, session_id, server_id, directory, objective, status, stop_reason, turn_state, continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at ` @@ -80,6 +84,7 @@ export function ensureSessionGoalsTable(db: Database): void { CREATE TABLE IF NOT EXISTS session_goals ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT 'local', directory TEXT NOT NULL, objective TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), @@ -100,7 +105,7 @@ export function ensureSessionGoalsTable(db: Database): void { `) db.run(` CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open - ON session_goals(session_id) + ON session_goals(server_id, session_id) WHERE status IN ('active','paused') `) db.run(` @@ -114,12 +119,13 @@ export function insertSessionGoal(db: Database, input: InsertSessionGoalInput): try { const result = db.prepare(` INSERT INTO session_goals( - session_id, directory, objective, status, stop_reason, turn_state, + session_id, server_id, directory, objective, status, stop_reason, turn_state, continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( input.sessionId, + input.serverId ?? LOCAL_OPENCODE_SERVER_ID, input.directory, input.objective, 'active', @@ -153,10 +159,19 @@ export function getSessionGoalById(db: Database, id: number): SessionGoalRecord return row ? mapSessionGoalRow(row) : null } -export function getLatestSessionGoal(db: Database, sessionId: string): SessionGoalRecord | null { +export function getLatestSessionGoal( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): SessionGoalRecord | null { const row = db - .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT 1`) - .get(sessionId) as SessionGoalRow | undefined + .prepare(` + SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals + WHERE server_id = ? AND session_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1 + `) + .get(serverId, sessionId) as SessionGoalRow | undefined return row ? mapSessionGoalRow(row) : null } @@ -228,6 +243,7 @@ function mapSessionGoalRow(row: SessionGoalRow): SessionGoalRecord { return { id: row.id, sessionId: row.session_id, + serverId: row.server_id, directory: row.directory, objective: row.objective, status: row.status, diff --git a/backend/src/db/session-permission-modes.ts b/backend/src/db/session-permission-modes.ts index 2669bb2f0..8d0302332 100644 --- a/backend/src/db/session-permission-modes.ts +++ b/backend/src/db/session-permission-modes.ts @@ -1,38 +1,60 @@ import type { Database } from 'bun:sqlite' -import type { SessionPermissionMode } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, type SessionPermissionMode } from '@opencode-manager/shared/schemas' export function ensureSessionPermissionModesTable(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS session_permission_modes ( - session_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT 'local', mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, session_id) ) `) } -export function getSessionPermissionMode(db: Database, sessionId: string): SessionPermissionMode | null { +export function getSessionPermissionMode( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): SessionPermissionMode | null { const row = db - .prepare('SELECT mode FROM session_permission_modes WHERE session_id = ?') - .get(sessionId) as { mode: SessionPermissionMode } | undefined + .prepare('SELECT mode FROM session_permission_modes WHERE server_id = ? AND session_id = ?') + .get(serverId, sessionId) as { mode: SessionPermissionMode } | undefined return row ? row.mode : null } -export function setSessionPermissionMode(db: Database, sessionId: string, mode: SessionPermissionMode): void { +export function setSessionPermissionMode( + db: Database, + sessionId: string, + mode: SessionPermissionMode, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { db.prepare(` - INSERT INTO session_permission_modes(session_id, mode, updated_at) - VALUES(?,?,?) - ON CONFLICT(session_id) DO UPDATE SET mode=excluded.mode, updated_at=excluded.updated_at - `).run(sessionId, mode, Date.now()) + INSERT INTO session_permission_modes(server_id, session_id, mode, updated_at) + VALUES(?,?,?,?) + ON CONFLICT(server_id, session_id) DO UPDATE SET + mode=excluded.mode, + updated_at=excluded.updated_at + `).run(serverId, sessionId, mode, Date.now()) } -export function insertSessionPermissionModeIfAbsent(db: Database, sessionId: string, mode: SessionPermissionMode): void { +export function insertSessionPermissionModeIfAbsent( + db: Database, + sessionId: string, + mode: SessionPermissionMode, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { db.prepare(` - INSERT OR IGNORE INTO session_permission_modes(session_id, mode, updated_at) - VALUES(?,?,?) - `).run(sessionId, mode, Date.now()) + INSERT OR IGNORE INTO session_permission_modes(server_id, session_id, mode, updated_at) + VALUES(?,?,?,?) + `).run(serverId, sessionId, mode, Date.now()) } -export function deleteSessionPermissionMode(db: Database, sessionId: string): void { - db.prepare('DELETE FROM session_permission_modes WHERE session_id = ?').run(sessionId) +export function deleteSessionPermissionMode( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { + db.prepare('DELETE FROM session_permission_modes WHERE server_id = ? AND session_id = ?').run(serverId, sessionId) } diff --git a/backend/src/index.ts b/backend/src/index.ts index 73ae3d4cb..7068b7325 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,7 +15,7 @@ import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'; import { createSTTRoutes } from './routes/stt' import { createFileRoutes } from './routes/files' import { createFilesystemRoutes } from './routes/filesystem' -import { createScheduleRoutes } from './routes/schedules' +import { createScheduleRoutes, createServerScheduleRoutes } from './routes/schedules' async function getAppVersion(): Promise { try { @@ -53,6 +53,10 @@ import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy' import { createOpenCodeServerProxyRoutes } from './routes/opencode-server-proxy' import { createOpenCodeServerRoutes } from './routes/opencode-servers' +import { + createOpenCodeServerTerminalRoutes, + createOpenCodeServerTerminalSocketRoutes, +} from './routes/opencode-server-terminals' import { sseAggregator } from './services/sse-aggregator' import { RemoteServerEventHub } from './services/remote-server-events' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' @@ -93,7 +97,7 @@ import { getTrustedOrigins, ENV } from '@opencode-manager/shared/config/env' -import { NotificationEventType } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, NotificationEventType } from '@opencode-manager/shared/schemas' const { PORT, HOST } = ENV.SERVER @@ -250,14 +254,25 @@ try { } const terminalService = new TerminalService( - openCodeClient, + { + client: openCodeClient, + baseUrl: () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), + includeLocalCredentials: true, + }, new CredentialProvider(db), - () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), ) +const resolveServerTerminalService = (serverId: string): TerminalService => { + const connection = opencodeServerRegistry.getConnection(serverId) + return terminalService.forTarget({ + client: opencodeServerRegistry.getClient(serverId), + baseUrl: () => connection.baseUrl, + includeLocalCredentials: false, + }) +} const gitService = createGitService(gitAuthService) const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) -const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) +const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry) scheduleService.setWorktreeRemovedHandler(async (directory) => { try { await terminalService.removeAll(directory) @@ -268,37 +283,31 @@ scheduleService.setWorktreeRemovedHandler(async (directory) => { const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) -const notificationService = new NotificationService(db) +const notificationService = new NotificationService(db, opencodeServerRegistry) const sessionSettingsService = new SettingsService(db) -const sessionAncestry = new SessionAncestry(openCodeClient) -const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, sessionSettingsService, sessionAncestry) +const sessionAncestry = new SessionAncestry((serverId) => opencodeServerRegistry.getClient(serverId)) +const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, { removeSessionSandbox: (rootSessionId) => new SandboxRuntimeService(db).removeSessionSandbox(rootSessionId), }) -const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSettingsService, { +const sessionGoalService = new SessionGoalService(db, opencodeServerRegistry, sessionSettingsService, { onOutcome: (goal) => { void notificationService.notifyGoalOutcome(goal).catch((error) => { logger.error('Goal outcome notification error:', error) }) }, - resolveSessionLock: async (sessionId) => - (await sessionPermissionModeService.getEffectiveMode(sessionId)).lockedReason, + resolveSessionLock: async (sessionId, serverId) => + (await sessionPermissionModeService.getEffectiveMode(sessionId, serverId)).lockedReason, }) sessionGoalService.loadOpenGoals() -const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService) +const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService, (serverId) => opencodeServerRegistry.getClient(serverId)) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient, sessionSettingsService, gitAuthService, gitService) sseAggregator.onEvent((directory, event) => { - sessionPermissionModeService.handleEvent(directory, event).catch((err) => { - logger.error('Session permission mode event handling error:', err) - }) sessionSandboxService.handleEvent(directory, event).catch((err) => { logger.error('Session sandbox event handling error:', err) }) - sessionGoalService.handleEvent(directory, event).catch((err) => { - logger.error('Session goal event handling error:', err) - }) try { changeWalkthroughService.handleEvent(event) } catch (err) { @@ -306,12 +315,21 @@ sseAggregator.onEvent((directory, event) => { } }) -notificationService.addEventSuppressor(async (event, sessionId) => { - if (event.type !== 'permission.asked' || !sessionId) return false - return (await sessionPermissionModeService.getEffectiveMode(sessionId)).mode === 'auto' +sseAggregator.onServerEvent((serverId, directory, event) => { + sessionPermissionModeService.handleEvent(directory, event, serverId).catch((err) => { + logger.error('Session permission mode event handling error:', err) + }) + sessionGoalService.handleEvent(directory, event, serverId).catch((err) => { + logger.error('Session goal event handling error:', err) + }) }) -notificationService.addEventSuppressor(async (event, sessionId) => { +notificationService.addEventSuppressor((event, sessionId, serverId) => + sessionPermissionModeService.suppressesPermissionNotification(event, sessionId, serverId), +) + +notificationService.addEventSuppressor(async (event, sessionId, serverId) => { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) return false if (!sessionId) return false if (event.type !== NotificationEventType.SESSION_IDLE && event.type !== NotificationEventType.SESSION_FAILED) return false return getChangeWalkthroughByWalkthroughSession(db, sessionId) !== null @@ -329,8 +347,8 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { privateKey: ENV.VAPID.PRIVATE_KEY, subject: ENV.VAPID.SUBJECT || 'mailto:push@localhost', }) - sseAggregator.onEvent((directory, event) => { - notificationService.handleSSEEvent(directory, event).catch((err) => { + sseAggregator.onServerEvent((serverId, directory, event) => { + notificationService.handleSSEEvent(directory, event, serverId).catch((err) => { logger.error('Push notification dispatch error:', err) }) }) @@ -338,9 +356,9 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { sseAggregator.setPendingActionsFetcher(openCodeClient) sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) -sseAggregator.onUpstreamConnected(() => { - void sessionPermissionModeService.acceptPendingRequestsForActiveSessions().catch((err) => { - logger.error('Failed to accept pending permission requests for active sessions:', err) +sseAggregator.onServerConnected((serverId) => { + void sessionPermissionModeService.acceptPendingRequestsForActiveSessions(serverId).catch((err) => { + logger.error(`Failed to accept pending permission requests for server ${serverId}:`, err) }) }) sseAggregator.start() @@ -387,6 +405,9 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService)) +protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket)) +protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resolveServerTerminalService)) protectedApi.route('/opencode-servers', createOpenCodeServerRoutes(opencodeServerRegistry, () => remoteServerEvents.getStatuses())) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService)) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 00c3a718c..1d1d3870a 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -47,7 +47,7 @@ export function createInternalRoutes( const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) app.route('/schedules', createScheduleRoutes(scheduleService)) - app.route('/session-goals', createSessionGoalRoutes(sessionGoals)) + app.route('/session-goals', createSessionGoalRoutes(sessionGoals, { serverScoped: false })) app.route('/multi-runs', createMultiRunRoutes(multiRuns)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) diff --git a/backend/src/routes/internal/sessions.ts b/backend/src/routes/internal/sessions.ts index fedba716b..cf71ccf30 100644 --- a/backend/src/routes/internal/sessions.ts +++ b/backend/src/routes/internal/sessions.ts @@ -131,7 +131,7 @@ export function createInternalSessionRoutes( permissionModes.pinAsk(launched.sessionId) const url = buildSessionPath( - launched.repoId, + input.repoId, launched.sessionId, launched.workspaceDirectory ? { repoTab: 'workspaces' } : undefined, ) diff --git a/backend/src/routes/multi-runs.ts b/backend/src/routes/multi-runs.ts index 8aa2105eb..69939330a 100644 --- a/backend/src/routes/multi-runs.ts +++ b/backend/src/routes/multi-runs.ts @@ -1,16 +1,31 @@ import { Hono } from 'hono' -import { FuseMultiRunRequestSchema, LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { + FuseMultiRunRequestSchema, + LaunchMultiRunRequestSchema, + type MultiRunTarget, +} from '@opencode-manager/shared/schemas' import { MultiRunError, type MultiRunService } from '../services/multi-runs' import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' import { ServiceError } from '../utils/service-error' +function listTarget(repoId: string | undefined, serverId: string | undefined, directory: string | undefined): MultiRunTarget { + if (serverId) { + if (!directory) { + throw new MultiRunError('Missing directory', 400) + } + return { kind: 'server', serverId, directory } + } + + return { kind: 'repo', repoId: parseId(repoId, 'repoId', MultiRunError) } +} + export function createMultiRunRoutes(service: MultiRunService) { const app = new Hono() app.get('/', (c) => { try { - const repoId = parseId(c.req.query('repoId'), 'repoId', MultiRunError) - return c.json({ runs: service.list(repoId) }) + const target = listTarget(c.req.query('repoId'), c.req.query('serverId'), c.req.query('directory')) + return c.json({ runs: service.list(target) }) } catch (error) { return handleServiceError(c, error, 'Failed to list multi-runs', ServiceError) } diff --git a/backend/src/routes/opencode-server-terminals.ts b/backend/src/routes/opencode-server-terminals.ts new file mode 100644 index 000000000..fa0a1d9af --- /dev/null +++ b/backend/src/routes/opencode-server-terminals.ts @@ -0,0 +1,189 @@ +import { Hono } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' +import type { UpgradeWebSocket } from 'hono/ws' +import { getTrustedOrigins } from '@opencode-manager/shared/config/env' +import { + CreateTerminalRequestSchema, + LOCAL_OPENCODE_SERVER_ID, + ResizeTerminalRequestSchema, +} from '@opencode-manager/shared/schemas' +import { OpenCodeServerError } from '../services/opencode-server-registry' +import { TerminalNotFoundError, type TerminalService } from '../services/terminal' +import { parseTerminalCursor } from '../utils/terminal-cursor' +import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' +import { handleOpenCodeError, handleServiceError, parseJsonBody } from '../utils/route-helpers' + +export type ServerTerminalServiceResolver = (serverId: string) => TerminalService + +interface ServerTerminalSocketVariables { + terminalService: TerminalService + terminalDirectory: string + terminalCursor: number | undefined +} + +function isAbsoluteDirectory(directory: string): boolean { + return directory.startsWith('/') || /^[A-Za-z]:[\\/]/.test(directory) +} + +function readAbsoluteDirectory(raw: string | undefined): string | null { + if (raw === undefined) return null + const directory = raw.trim() + return directory.length > 0 && isAbsoluteDirectory(directory) ? directory : null +} + +function rejectLocalServer(c: Context, serverId: string): Response | null { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) return null + return c.json({ error: 'Local terminals are served by the repo terminal routes' }, 400) +} + +function rejectDirectory(c: Context): Response { + return c.json({ error: 'An absolute directory is required' }, 400) +} + +function handleTerminalError(c: Context, error: unknown) { + if (error instanceof TerminalNotFoundError) { + return c.json({ error: error.message }, 404) + } + if (error instanceof OpenCodeServerError) { + return handleServiceError(c, error, 'Terminal request failed', OpenCodeServerError) + } + return handleOpenCodeError(c, error, 'Terminal request failed') +} + +export function createOpenCodeServerTerminalRoutes(resolveTerminalService: ServerTerminalServiceResolver) { + const app = new Hono() + + app.get('/:serverId/terminals', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + try { + const terminals = await resolveTerminalService(serverId).list(directory) + return c.json({ terminals }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.post('/:serverId/terminals', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const parsed = await parseJsonBody(c, CreateTerminalRequestSchema, { allowEmpty: true }) + if (!parsed.ok) return parsed.response + + const directory = readAbsoluteDirectory(parsed.data.directory) + if (directory === null) return rejectDirectory(c) + + try { + const terminal = await resolveTerminalService(serverId).create(directory, { + kind: 'shell', + name: parsed.data.title ?? 'Terminal', + }) + return c.json(terminal) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.patch('/:serverId/terminals/:ptyID', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const parsed = await parseJsonBody(c, ResizeTerminalRequestSchema) + if (!parsed.ok) return parsed.response + + const directory = readAbsoluteDirectory(parsed.data.directory) + if (directory === null) return rejectDirectory(c) + + const ptyID = c.req.param('ptyID') + + try { + const terminalService = resolveTerminalService(serverId) + await terminalService.requireTerminal(directory, ptyID) + await terminalService.resize(directory, ptyID, { cols: parsed.data.cols, rows: parsed.data.rows }) + return c.json({ success: true }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.delete('/:serverId/terminals/:ptyID', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + const ptyID = c.req.param('ptyID') + + try { + const terminalService = resolveTerminalService(serverId) + await terminalService.requireTerminal(directory, ptyID) + await terminalService.remove(directory, ptyID) + return c.json({ success: true }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + return app +} + +export function createOpenCodeServerTerminalSocketRoutes( + resolveTerminalService: ServerTerminalServiceResolver, + upgradeWebSocket: UpgradeWebSocket, + trustedOrigins: readonly string[] = getTrustedOrigins(), +) { + const app = new Hono<{ Variables: ServerTerminalSocketVariables }>() + + const validate: MiddlewareHandler<{ Variables: ServerTerminalSocketVariables }> = async (c, next) => { + if (!isAllowedUpgradeOrigin(c.req.header('origin'), trustedOrigins)) { + return c.json({ error: 'Origin not allowed' }, 403) + } + + const serverId = c.req.param('serverId') ?? '' + const local = rejectLocalServer(c, serverId) + if (local) return local + + const cursor = parseTerminalCursor(c.req.query('cursor')) + if (cursor === null) { + return c.json({ error: 'Invalid cursor' }, 400) + } + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + try { + c.set('terminalService', resolveTerminalService(serverId)) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + + c.set('terminalDirectory', directory) + c.set('terminalCursor', cursor) + await next() + } + + app.get( + '/:serverId/terminals/:ptyID/connect', + validate, + upgradeWebSocket((c) => { + const terminalService = c.get('terminalService') + const directory = c.get('terminalDirectory') + const cursor = c.get('terminalCursor') + const ptyID = c.req.param('ptyID') + + return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer)) + }), + ) + + return app +} diff --git a/backend/src/routes/repo-terminal-socket.ts b/backend/src/routes/repo-terminal-socket.ts index cfb27b66d..666e548ad 100644 --- a/backend/src/routes/repo-terminal-socket.ts +++ b/backend/src/routes/repo-terminal-socket.ts @@ -7,6 +7,7 @@ import type { GitAuthService } from '../services/git-auth' import type { OpenCodeClient } from '../services/opencode/client' import type { TerminalService } from '../services/terminal' import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' +import { parseTerminalCursor } from '../utils/terminal-cursor' import { resolveRepoRequestDirectory } from './repo-directory' interface RepoTerminalSocketVariables { @@ -14,14 +15,6 @@ interface RepoTerminalSocketVariables { terminalCursor: number | undefined } -function parseCursor(raw: string | undefined): number | undefined | null { - if (raw === undefined) return undefined - if (!/^-?\d+$/.test(raw)) return null - const parsed = Number(raw) - if (!Number.isSafeInteger(parsed) || parsed < -1) return null - return parsed -} - export function createRepoTerminalSocketRoutes( database: Database, gitAuthService: GitAuthService, @@ -38,7 +31,7 @@ export function createRepoTerminalSocketRoutes( return c.json({ error: 'Origin not allowed' }, 403) } - const cursor = parseCursor(c.req.query('cursor')) + const cursor = parseTerminalCursor(c.req.query('cursor')) if (cursor === null) { return c.json({ error: 'Invalid cursor' }, 400) } diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index 20f7bc7a3..331f5f221 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -9,6 +9,13 @@ import type { ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' import { ScheduleService, ScheduleServiceError } from '../services/schedules' import { parseId, handleServiceError } from '../utils/route-helpers' +function parseServerId(value: string | undefined): string { + if (!value) { + throw new ScheduleServiceError('OpenCode server id is required', 400) + } + return value +} + function parseRunListLimit(value: string | undefined): number { if (value === undefined) { return 20 @@ -270,4 +277,133 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { return app } +/** + * Schedule routes for a project directory on a linked OpenCode server. They mirror the + * repo routes without worktree endpoints, because remote runs execute in place. + */ +export function createServerScheduleRoutes(scheduleService: ScheduleService) { + const app = new Hono() + + app.get('/', (c) => { + try { + return c.json({ jobs: scheduleService.listServerJobs(parseServerId(c.req.param('serverId'))) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list schedules', ScheduleServiceError) + } + }) + + app.post('/', async (c) => { + try { + const serverId = parseServerId(c.req.param('serverId')) + const body = await c.req.json() + const input = CreateScheduleJobRequestSchema.parse(body) + if (!input.directory) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + const job = await scheduleService.createServerJob(serverId, input.directory, input) + return c.json({ job }, 201) + } catch (error) { + return handleServiceError(c, error, 'Failed to create schedule', ScheduleServiceError) + } + }) + + app.get('/:jobId', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const job = scheduleService.getServerJob(parseServerId(c.req.param('serverId')), jobId) + if (!job) { + return c.json({ error: 'Schedule not found' }, 404) + } + return c.json({ job }) + } catch (error) { + return handleServiceError(c, error, 'Failed to get schedule', ScheduleServiceError) + } + }) + + app.patch('/:jobId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const body = await c.req.json() + const input = UpdateScheduleJobRequestSchema.parse(body) + const job = await scheduleService.updateServerJob(parseServerId(c.req.param('serverId')), jobId, input) + return c.json({ job }) + } catch (error) { + return handleServiceError(c, error, 'Failed to update schedule', ScheduleServiceError) + } + }) + + app.delete('/:jobId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + await scheduleService.deleteServerJob(parseServerId(c.req.param('serverId')), jobId) + return c.json({ success: true }) + } catch (error) { + return handleServiceError(c, error, 'Failed to delete schedule', ScheduleServiceError) + } + }) + + app.post('/:jobId/run', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const run = await scheduleService.runServerJob(parseServerId(c.req.param('serverId')), jobId, 'manual') + return c.json({ run }) + } catch (error) { + return handleServiceError(c, error, 'Failed to run schedule', ScheduleServiceError) + } + }) + + app.get('/:jobId/runs', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const limit = parseRunListLimit(c.req.query('limit')) + return c.json({ runs: scheduleService.listServerRuns(parseServerId(c.req.param('serverId')), jobId, limit) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list schedule runs', ScheduleServiceError) + } + }) + + app.get('/:jobId/runs/:runId', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + return c.json({ run: scheduleService.getServerRun(parseServerId(c.req.param('serverId')), jobId, runId) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to get schedule run', ScheduleServiceError) + } + }) + + app.post('/:jobId/runs/:runId/cancel', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + const run = await scheduleService.cancelServerRun(parseServerId(c.req.param('serverId')), jobId, runId) + return c.json({ run }) + } catch (error) { + return handleServiceError(c, error, 'Failed to cancel schedule run', ScheduleServiceError) + } + }) + + app.delete('/:jobId/runs', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + return c.json(await scheduleService.clearServerRunHistory(parseServerId(c.req.param('serverId')), jobId)) + } catch (error) { + return handleServiceError(c, error, 'Failed to clear schedule run history', ScheduleServiceError) + } + }) + + app.delete('/:jobId/runs/:runId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + await scheduleService.deleteServerRun(parseServerId(c.req.param('serverId')), jobId, runId) + return c.json({ success: true }) + } catch (error) { + return handleServiceError(c, error, 'Failed to delete schedule run', ScheduleServiceError) + } + }) + + return app +} + diff --git a/backend/src/routes/session-goals.ts b/backend/src/routes/session-goals.ts index 9300ad49a..8f4ca4321 100644 --- a/backend/src/routes/session-goals.ts +++ b/backend/src/routes/session-goals.ts @@ -3,7 +3,15 @@ import { StartSessionGoalRequestSchema } from '@opencode-manager/shared/schemas' import { SessionGoalError, type SessionGoalService } from '../services/session-goals' import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' -export function createSessionGoalRoutes(service: SessionGoalService) { +export interface SessionGoalRouteOptions { + serverScoped?: boolean +} + +export function createSessionGoalRoutes( + service: SessionGoalService, + options: SessionGoalRouteOptions = {}, +) { + const serverScoped = options.serverScoped ?? true const app = new Hono() app.get('/', (c) => { @@ -12,7 +20,8 @@ export function createSessionGoalRoutes(service: SessionGoalService) { if (!sessionId) { return c.json({ error: 'Invalid request' }, 400) } - return c.json({ goal: service.getLatest(sessionId) }) + const serverId = serverScoped ? c.req.query('serverId') : undefined + return c.json({ goal: serverId ? service.getLatest(sessionId, serverId) : service.getLatest(sessionId) }) } catch (error) { return handleServiceError(c, error, 'Failed to read session goal', SessionGoalError) } @@ -25,7 +34,7 @@ export function createSessionGoalRoutes(service: SessionGoalService) { } try { - const goal = await service.start(parsed.data) + const goal = await service.start(serverScoped ? parsed.data : { ...parsed.data, serverId: undefined }) return c.json({ goal }, 201) } catch (error) { return handleServiceError(c, error, 'Failed to start session goal', SessionGoalError) diff --git a/backend/src/routes/session-permission-modes.ts b/backend/src/routes/session-permission-modes.ts index 19e17ded4..8afcc979d 100644 --- a/backend/src/routes/session-permission-modes.ts +++ b/backend/src/routes/session-permission-modes.ts @@ -8,7 +8,10 @@ export function createSessionPermissionModeRoutes(service: SessionPermissionMode app.get('/:sessionId', async (c) => { try { - const state = await service.getEffectiveMode(c.req.param('sessionId')) + const state = await service.getEffectiveMode( + c.req.param('sessionId'), + c.req.query('serverId') || undefined, + ) return c.json(state) } catch (error) { return handleServiceError(c, error, 'Failed to read session permission mode', SessionPermissionModeError) @@ -22,7 +25,12 @@ export function createSessionPermissionModeRoutes(service: SessionPermissionMode } try { - const state = await service.setMode(c.req.param('sessionId'), parsed.data.mode, parsed.data.directory) + const state = await service.setMode( + c.req.param('sessionId'), + parsed.data.mode, + parsed.data.directory, + parsed.data.serverId, + ) return c.json(state) } catch (error) { return handleServiceError(c, error, 'Failed to set session permission mode', SessionPermissionModeError) diff --git a/backend/src/routes/sse.ts b/backend/src/routes/sse.ts index e1af25cfd..2d4882ce2 100644 --- a/backend/src/routes/sse.ts +++ b/backend/src/routes/sse.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { stream } from 'hono/streaming' import { sseAggregator } from '../services/sse-aggregator' -import { SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' import { createQueuedSSEWriter } from './sse-writer' @@ -92,7 +92,12 @@ export function createSSERoutes() { if (!result.success) { return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400) } - const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible, result.data.activeSessionId ?? null) + const success = sseAggregator.setClientVisibility( + result.data.clientId, + result.data.visible, + result.data.activeSessionId ?? null, + result.data.activeServerId ?? LOCAL_OPENCODE_SERVER_ID, + ) if (!success) { return c.json({ success: false, error: 'Client not found' }, 404) } diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 9c7d5cc88..90890703d 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -1,6 +1,12 @@ import type { Database } from 'bun:sqlite' import { existsSync } from 'node:fs' -import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type FuseMultiRunRequest, + type LaunchMultiRunRequest, + type MultiRun, + type MultiRunTarget, +} from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createMultiRunWithEntries, @@ -16,7 +22,6 @@ import { type MultiRunRecord, } from '../db/multi-runs' import { getRepoById } from '../db/queries' -import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' import { ServiceError } from '../utils/service-error' @@ -24,7 +29,15 @@ import { buildFusionPrompt, buildFusionSourcePermissionRuleset, collectFusionSou import type { OpenCodeClient } from './opencode/client' import { RepoWorkspaceError } from './repo' import type { RepoWorkspaceService } from './repo-workspace' -import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher' +import { + resolveSessionTarget, + SessionLauncher, + SessionLaunchError, + sessionTargetInput, + sessionTargetOfRun, + type LaunchedSession, + type ResolvedSessionTarget, +} from './session-launcher' import type { SessionPermissionModeService } from './session-permission-modes' const MULTI_RUN_LIST_LIMIT = 20 @@ -35,6 +48,8 @@ function toMultiRun(record: MultiRunRecord): MultiRun { return { id: record.id, repoId: record.repoId, + serverId: record.serverId, + directory: record.directory, name: record.name, prompt: record.prompt, isolated: record.isolated, @@ -69,6 +84,25 @@ function toMultiRun(record: MultiRunRecord): MultiRun { } } +function multiRunTargetFromRequest( + request: Pick, +): MultiRunTarget { + if (request.serverId !== undefined) { + if (request.serverId === LOCAL_OPENCODE_SERVER_ID) { + throw new MultiRunError('Local multi-runs must target a repository', 400) + } + if (request.directory === undefined) { + throw new MultiRunError('A server multi-run needs a directory', 400) + } + return { kind: 'server', serverId: request.serverId, directory: request.directory } + } + + if (request.repoId === undefined) { + throw new MultiRunError('A multi-run needs a repository or server target', 400) + } + return { kind: 'repo', repoId: request.repoId } +} + export class MultiRunService { private readonly sessionLauncher: SessionLauncher private readonly discardingEntries = new Set() @@ -78,22 +112,21 @@ export class MultiRunService { private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, private readonly permissionModes: SessionPermissionModeService, + private readonly getServerClient?: (serverId: string) => OpenCodeClient, ) { - this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces) + this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces, getServerClient) } async launch(request: LaunchMultiRunRequest): Promise { - let repo: Repo - try { - repo = requireReadyRepo(this.db, request.repoId) - } catch (error) { - throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 404) - } + const target = multiRunTargetFromRequest(request) + const resolved = await this.resolveTarget(target) const record = createMultiRunWithEntries( this.db, { - repoId: request.repoId, + repoId: target.kind === 'repo' ? target.repoId : null, + serverId: target.kind === 'server' ? target.serverId : LOCAL_OPENCODE_SERVER_ID, + directory: target.kind === 'server' ? target.directory : null, name: request.name, prompt: request.prompt, isolated: request.isolate, @@ -105,7 +138,7 @@ export class MultiRunService { const validations = await Promise.all( record.entries.map(async (entry) => { try { - await this.sessionLauncher.resolveModel(repo, entry.model) + await resolved.resolveModel(entry.model) return { entry, ok: true as const } } catch (error) { return { entry, ok: false as const, error } @@ -127,7 +160,7 @@ export class MultiRunService { }) const results = await Promise.allSettled( - launchable.map(({ entry, index }) => this.launchEntry(entry, index, request)), + launchable.map(({ entry, index }) => this.launchEntry(entry, index, request, target)), ) results.forEach((result, resultIndex) => { @@ -174,12 +207,11 @@ export class MultiRunService { }) } - let repo: Repo - try { - repo = requireReadyRepo(this.db, record.repoId) - } catch (error) { - throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 404) + const target = sessionTargetOfRun(record) + if (!target) { + throw new MultiRunError('Multi-run target is no longer available', 409) } + const resolved = await this.resolveTarget(target) const entriesById = new Map(record.entries.map((entry) => [entry.id, entry])) const selectedEntries = request.entryIds.map((entryId) => { @@ -197,7 +229,7 @@ export class MultiRunService { ) try { - await this.sessionLauncher.resolveModel(repo, request.model) + await resolved.resolveModel(request.model) } catch (error) { if (error instanceof SessionLaunchError) { throw new MultiRunError(error.message, error.status) @@ -205,9 +237,9 @@ export class MultiRunService { throw new MultiRunError(getErrorMessage(error) || 'Failed to resolve model', 502) } - await this.reconcileUncertainFusions(record) + await this.reconcileUncertainFusions(record, resolved.client) - const collection = await collectFusionSources(this.openCodeClient, selectedEntries) + const collection = await collectFusionSources(resolved.client, selectedEntries) if (collection.unavailable.length > 0) { throw new MultiRunError('Some selected results are not ready to fuse', 409, { code: 'FUSION_SOURCES_UNAVAILABLE', @@ -239,7 +271,7 @@ export class MultiRunService { let launched: LaunchedSession | null = null try { launched = await this.sessionLauncher.launch({ - repoId: record.repoId, + ...sessionTargetInput(target), prompt: built.prompt, model: request.model, title: `${record.name} · fusion`, @@ -269,7 +301,11 @@ export class MultiRunService { if (launched) { try { - await this.permissionModes.applyDefaultMode(launched.sessionId, launched.directory) + await this.permissionModes.applyDefaultMode( + launched.sessionId, + launched.directory, + target.kind === 'server' ? target.serverId : LOCAL_OPENCODE_SERVER_ID, + ) } catch (error) { logger.error(`Failed to apply the default permission mode to fusion session ${launched.sessionId}:`, error) } @@ -278,8 +314,8 @@ export class MultiRunService { return { run: this.reload(multiRunId), created: true } } - list(repoId: number): MultiRun[] { - return listMultiRuns(this.db, repoId, MULTI_RUN_LIST_LIMIT).map(toMultiRun) + list(target: MultiRunTarget): MultiRun[] { + return listMultiRuns(this.db, target, MULTI_RUN_LIST_LIMIT).map(toMultiRun) } async discard(multiRunId: number, entryId: number): Promise { @@ -303,18 +339,8 @@ export class MultiRunService { this.discardingEntries.add(entryId) try { - if (entry.isolated && entry.directory && existsSync(entry.directory)) { - const repo = getRepoById(this.db, record.repoId) - if (!repo) { - throw new MultiRunError('Repository not found', 404) - } - - try { - await this.repoWorkspaces.remove(repo, entry.directory) - } catch (error) { - const status = error instanceof RepoWorkspaceError ? error.status : 502 - throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', status) - } + if (entry.isolated && entry.directory) { + await this.removeEntryWorkspace(record, entry.directory) } const updated = updateMultiRunEntry(this.db, entryId, ['started', 'failed'], { status: 'discarded' }) @@ -328,13 +354,69 @@ export class MultiRunService { } } + private async removeEntryWorkspace(record: MultiRunRecord, directory: string): Promise { + const target = sessionTargetOfRun(record) + if (!target) { + throw new MultiRunError('Multi-run target is no longer available', 409) + } + + if (target.kind === 'repo') { + if (!existsSync(directory)) { + return + } + + const repo = getRepoById(this.db, target.repoId) + if (!repo) { + throw new MultiRunError('Repository not found', 404) + } + + try { + await this.repoWorkspaces.remove(repo, directory) + } catch (error) { + const status = error instanceof RepoWorkspaceError ? error.status : 502 + throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', status) + } + return + } + + try { + const resolved = await this.resolveTarget(target) + await resolved.removeWorkspace(directory) + } catch (error) { + if (error instanceof MultiRunError) { + throw error + } + throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', 502) + } + } + + private async resolveTarget(target: MultiRunTarget): Promise { + try { + return await resolveSessionTarget( + { + db: this.db, + openCodeClient: this.openCodeClient, + repoWorkspaces: this.repoWorkspaces, + ...(this.getServerClient ? { getServerClient: this.getServerClient } : {}), + }, + target, + ) + } catch (error) { + if (error instanceof SessionLaunchError || error instanceof ServiceError) { + throw new MultiRunError(error.message, error.status) + } + throw new MultiRunError(getErrorMessage(error) || 'Multi-run target is unavailable', 502) + } + } + private async launchEntry( entry: MultiRunEntryRecord, index: number, request: LaunchMultiRunRequest, + target: MultiRunTarget, ): Promise { return this.sessionLauncher.launch({ - repoId: request.repoId, + ...sessionTargetInput(target), prompt: request.prompt, model: entry.model, title: `${request.name} · ${entry.model}`, @@ -345,7 +427,7 @@ export class MultiRunService { }) } - private async reconcileUncertainFusions(record: MultiRunRecord): Promise { + private async reconcileUncertainFusions(record: MultiRunRecord, client: OpenCodeClient): Promise { const uncertain = record.fusions.filter( (fusion): fusion is MultiRunFusionRecord & { sessionId: string } => fusion.status === 'failed' && fusion.sessionId !== null, @@ -358,7 +440,7 @@ export class MultiRunService { await Promise.all( uncertain.map(async (fusion) => { try { - const response = await this.openCodeClient.api.message.list({ + const response = await client.api.message.list({ sessionID: fusion.sessionId, type: 'user', order: 'asc', diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 3ff7ecf49..1ae8b2a52 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -9,6 +9,8 @@ import type { import { NotificationEventType, DEFAULT_NOTIFICATION_PREFERENCES, + LOCAL_OPENCODE_SERVER_ID, + type OpenCodeServer, type SessionGoal, } from "@opencode-manager/shared/schemas"; import { @@ -23,10 +25,14 @@ import { sseAggregator, type SSEEvent } from "./sse-aggregator"; import { getRepoName } from "../db/queries"; import { getScheduleRunBySessionId } from "../db/schedules"; import type { Repo } from "../types/repo"; -import { buildSessionPath } from "@opencode-manager/shared/utils"; +import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; import { resolveRepoForDirectory } from "./repo"; +export interface NotificationServerRegistry { + get(serverId: string): Pick | null; +} + interface VapidConfig { publicKey: string; privateKey: string; @@ -123,6 +129,8 @@ export function buildEventNotificationPayload( context: { repoName?: string; repoId?: number; + serverId?: string; + serverName?: string; sessionId?: string; directory?: string; url: string; @@ -134,8 +142,9 @@ export function buildEventNotificationPayload( const title = config.titleFn(event.data); const detail = config.bodyFn(event.data); - const rawBody = context.repoName - ? `${context.repoName} · ${detail}` + const displayName = context.repoName ?? context.serverName; + const rawBody = displayName + ? `${displayName} · ${detail}` : detail; const body = truncateWithEllipsis(rawBody, MAX_BODY_LENGTH); @@ -151,19 +160,28 @@ export function buildEventNotificationPayload( directory: context.directory, repoId: context.repoId, repoName: context.repoName, + serverId: context.serverId, + serverName: context.serverName, url: context.url, }, }; } -type EventSuppressor = (event: SSEEvent, sessionId: string | undefined) => Promise; +type EventSuppressor = ( + event: SSEEvent, + sessionId: string | undefined, + serverId: string +) => Promise; export class NotificationService { private vapidConfig: VapidConfig | null = null; private settingsService: SettingsService; private eventSuppressors: EventSuppressor[] = []; - constructor(private db: Database) { + constructor( + private db: Database, + private serverRegistry: NotificationServerRegistry | null = null + ) { this.settingsService = new SettingsService(db); this.initializePushSubscriptionsTable(); } @@ -335,17 +353,29 @@ export class NotificationService { async handleSSEEvent( directory: string | null, - event: SSEEvent + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID ): Promise { const config = EVENT_CONFIG[event.type]; if (!config) return; const sessionId = resolveEventSessionId(event); - if (sessionId && sseAggregator.isSessionBeingViewed(sessionId)) return; - if (sessionId && sseAggregator.isSubagentSession(sessionId)) return; + if (sessionId && sseAggregator.isSessionBeingViewed(sessionId, serverId)) return; + if (sessionId && sseAggregator.isSubagentSession(sessionId, serverId)) return; for (const suppressor of this.eventSuppressors) { - if (await suppressor(event, sessionId)) return; + if (await suppressor(event, sessionId, serverId)) return; + } + + if (serverId !== LOCAL_OPENCODE_SERVER_ID) { + await this.deliverRemoteEvent( + serverId, + directory, + event, + sessionId, + config.preferencesKey + ); + return; } const repo = directory ? await resolveRepoForDirectory(this.db, directory) : null; @@ -371,19 +401,53 @@ export class NotificationService { ); } + private async deliverRemoteEvent( + serverId: string, + directory: string | null, + event: SSEEvent, + sessionId: string | undefined, + preferencesKey: keyof typeof DEFAULT_NOTIFICATION_PREFERENCES.events + ): Promise { + const server = this.serverRegistry?.get(serverId) ?? null; + if (!server || !server.enabled) return; + + const url = sessionId + ? buildServerSessionPath(serverId, sessionId) + : `/servers/${encodeURIComponent(serverId)}`; + + const payload = buildEventNotificationPayload(event, { + serverId, + serverName: server.name, + sessionId, + directory: directory ?? undefined, + url, + }); + if (!payload) return; + + await this.deliverToSubscribers( + payload, + (preferences) => + preferences.enabled && + preferences.events[preferencesKey] === true + ); + } + async notifyGoalOutcome(goal: SessionGoal): Promise { if (goal.stopReason === "turn_error") return; if (goal.stopReason === "cancelled" || goal.stopReason === "user_paused") return; - if (sseAggregator.isSessionBeingViewed(goal.sessionId)) return; + if (sseAggregator.isSessionBeingViewed(goal.sessionId, goal.serverId)) return; - const repo = goal.directory + const isRemote = goal.serverId !== LOCAL_OPENCODE_SERVER_ID; + const repo = !isRemote && goal.directory ? await resolveRepoForDirectory(this.db, goal.directory) : null; + const server = isRemote ? this.serverRegistry?.get(goal.serverId) ?? null : null; const repoName = repo ? getRepoName(repo) : undefined; + const displayName = repoName ?? server?.name; const payload: PushNotificationPayload = { title: getGoalOutcomeTitle(goal.status), - body: buildGoalOutcomeBody(goal, repoName), + body: buildGoalOutcomeBody(goal, displayName), tag: `session-goal-${goal.id}`, timestamp: Date.now(), renotify: true, @@ -393,7 +457,11 @@ export class NotificationService { directory: goal.directory, repoId: repo?.id, repoName, - url: buildNotificationUrl(repo, goal.sessionId), + serverId: goal.serverId, + serverName: server?.name, + url: isRemote + ? buildServerSessionPath(goal.serverId, goal.sessionId) + : buildNotificationUrl(repo, goal.sessionId), }, }; diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts index 2cb7e44d6..4231dd9ef 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -23,6 +23,18 @@ import type { SettingsService } from './settings' const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) +export const OPENCODE_STATE_IMPORT_EXCLUDED_ENTRIES: ReadonlySet = new Set([ + 'worktree', + 'snapshot', + 'repos', + 'bin', + 'log', + 'tool-output', + 'shell', +]) + +const OPENCODE_STATE_IMPORT_EXCLUDED_SUFFIXES = ['.log'] + export interface OpenCodeImportStatus { configSourcePath: string | null configSourcePaths: string[] @@ -93,7 +105,29 @@ function escapeSqliteValue(value: string): string { return value.replace(/'/g, "''") } -async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise { +function getTopLevelEntryContainingPath(sourcePath: string, targetPath: string): string | null { + const relativeTargetPath = path.relative(path.resolve(sourcePath), path.resolve(targetPath)) + + if (relativeTargetPath === '' || relativeTargetPath.startsWith('..') || path.isAbsolute(relativeTargetPath)) { + return null + } + + return relativeTargetPath.split(path.sep)[0] ?? null +} + +export function shouldImportOpenCodeStateEntry(entryName: string, sourcePath: string, targetPath: string): boolean { + if (OPENCODE_STATE_IMPORT_EXCLUDED_ENTRIES.has(entryName)) { + return false + } + + if (OPENCODE_STATE_IMPORT_EXCLUDED_SUFFIXES.some(suffix => entryName.endsWith(suffix))) { + return false + } + + return entryName !== getTopLevelEntryContainingPath(sourcePath, targetPath) +} + +async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string, destinationPath: string): Promise { const entries = await readdir(sourcePath, { withFileTypes: true }) for (const entry of entries) { @@ -101,6 +135,10 @@ async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): P continue } + if (!shouldImportOpenCodeStateEntry(entry.name, sourcePath, destinationPath)) { + continue + } + await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), { recursive: true, force: true, @@ -139,7 +177,7 @@ export async function importOpenCodeStateDirectory(sourcePath: string, targetPat const stagedTargetPath = await mkdtemp(path.join(targetParentPath, `${targetDirectoryName}-import-`)) try { - await copyOpenCodeStateFiles(resolvedSourcePath, stagedTargetPath) + await copyOpenCodeStateFiles(resolvedSourcePath, stagedTargetPath, resolvedTargetPath) snapshotOpenCodeDatabase(sourceDbPath, path.join(stagedTargetPath, 'opencode.db')) await rm(resolvedTargetPath, { recursive: true, force: true }) diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts index 0d48cc127..91dc01e3b 100644 --- a/backend/src/services/remote-server-events.ts +++ b/backend/src/services/remote-server-events.ts @@ -6,7 +6,7 @@ import { type OpenCodeServer, type OpenCodeServerStatus, } from '@opencode-manager/shared/schemas' -import { type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' import { logger } from '../utils/logger' import type { OpenCodeServerChange, @@ -32,6 +32,9 @@ export interface RemoteEventAggregator { sendRemoteResync(serverId: string): void hasClientDirectory(clientId: string, key: string): boolean setRemoteReplayHandler(handler: RemoteReplayHandler | null): void + notifyServerEvent(serverId: string, directory: string | null, event: V2Event): void + notifyServerConnected(serverId: string): void + setRemoteSubagentResolver(resolver: ((serverId: string, sessionId: string) => boolean) | null): void } export interface RemoteEventSource { @@ -60,6 +63,7 @@ interface RemoteServerStream { versionRequested: boolean versionProbeToken: number sessionDirectories: Map + subagentSessions: Set } function createDefaultEventSource(url: string, init?: RemoteEventSourceInit): RemoteEventSource { @@ -102,6 +106,7 @@ export class RemoteServerEventHub { this.started = true this.aggregator.setRemoteReplayHandler((clientId, serverId, directory) => this.replayPendingActions(clientId, serverId, directory)) + this.aggregator.setRemoteSubagentResolver((serverId, sessionId) => this.isSubagentSession(serverId, sessionId)) this.unsubscribeRegistry = this.registry.onChange(change => this.handleRegistryChange(change)) this.connectAll() } @@ -116,6 +121,11 @@ export class RemoteServerEventHub { this.forgetStream(serverId) } this.aggregator.setRemoteReplayHandler(null) + this.aggregator.setRemoteSubagentResolver(null) + } + + isSubagentSession(serverId: string, sessionId: string): boolean { + return this.streams.get(serverId)?.subagentSessions.has(sessionId) ?? false } getStatuses(): OpenCodeServerStatus[] { @@ -230,6 +240,7 @@ export class RemoteServerEventHub { stream.error = null stream.reconnectDelay = this.reconnectDelayMs logger.info(`Remote event stream connected: ${serverId}`) + this.aggregator.notifyServerConnected(serverId) this.aggregator.sendRemoteResync(serverId) void this.refreshVersion(serverId) void this.replaySessionStatuses(serverId, source) @@ -268,7 +279,16 @@ export class RemoteServerEventHub { if (!routed) return try { - this.aggregator.deliverRemoteEnvelope(serverId, routed.resolvedDirectory ?? null, routed.payloadJson) + const { event, resolvedDirectory, payloadJson } = routed + const sessionID = sessionIDFromEvent(event) + if (sessionID && event.type === 'session.deleted') { + stream.subagentSessions.delete(sessionID) + } else if (event.type === 'session.created' && event.data.parentID) { + stream.subagentSessions.add(event.data.sessionID) + } + + this.aggregator.notifyServerEvent(serverId, resolvedDirectory ?? null, event) + this.aggregator.deliverRemoteEnvelope(serverId, resolvedDirectory ?? null, payloadJson) } catch (error) { logger.error(`Remote event stream failed to handle ${routed.event.type} for ${serverId}:`, error) } @@ -393,6 +413,7 @@ export class RemoteServerEventHub { versionRequested: false, versionProbeToken: 0, sessionDirectories: new Map(), + subagentSessions: new Set(), } this.streams.set(serverId, stream) return stream diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts index 3a118fcf7..c6651b10f 100644 --- a/backend/src/services/schedule-config.ts +++ b/backend/src/services/schedule-config.ts @@ -28,6 +28,7 @@ export interface ScheduleJobPersistenceInput { mcpServers: ScheduleMcpServer[] branch: string | null workspaceMode: ScheduleWorkspaceMode + directory?: string | null nextRunAt: number | null } @@ -104,6 +105,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ skillMetadata: input.skillMetadata, branch: input.branch?.trim() || null, workspaceMode: input.workspaceMode ?? 'worktree', + ...(input.directory !== undefined ? { directory: input.directory?.trim() || null } : {}), } const scheduleConfig = input.scheduleMode === 'cron' @@ -166,6 +168,9 @@ export function buildUpdatedSchedulePersistenceInput( mcpServers: input.mcpServers ?? existing.mcpServers, branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null), workspaceMode: input.workspaceMode ?? existing.workspaceMode, + ...(input.directory !== undefined || existing.directory !== undefined + ? { directory: input.directory === undefined ? (existing.directory ?? null) : (input.directory?.trim() || null) } + : {}), nextRunAt, } } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 82b41da9d..f1e9ca729 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -8,11 +8,12 @@ import { type ScheduleRun, type ScheduleRunTriggerSource, type ScheduleRunWorktreesMode, + type ScheduleWorkspaceMode, type ScheduleWorktree, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' import { mcpStatusByName, openCodeLocation } from '@opencode-manager/shared/opencode' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' import { @@ -36,6 +37,7 @@ import { listEnabledScheduleJobs, listScheduleJobIdsByRepo, listScheduleJobsByRepo, + listScheduleJobsByServer, listRunningScheduleRuns, listScheduleRunsByJob, updateScheduleJob, @@ -53,6 +55,7 @@ import { import { resolveOpenCodeModel } from './opencode-models' import { isSessionBusy, readLatestAssistantReply, sessionSettleSignal, type AssistantReplyState } from './session-reply' import type { OpenCodeClient } from './opencode/client' +import type { OpenCodeServerRegistry } from './opencode-server-registry' import type { ScheduleWorktreeManager } from './schedule-worktree' import type { Repo } from '../types/repo' import { sseAggregator, type ScheduledSessionRef } from './sse-aggregator' @@ -72,6 +75,8 @@ class ScheduleServiceError extends Error { } } +class ServerUnavailableError extends ScheduleServiceError {} + 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 @@ -90,6 +95,7 @@ interface SessionMonitor { interface ActiveRun { runId: number abort: AbortController + sessionMonitor?: SessionMonitor } type CancelDecision = 'cancelled' | 'settled' | 'resume' @@ -175,7 +181,7 @@ function buildRunStartedLog(input: { ].join('\n') } -function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { +function createSessionMonitor(directory: string, sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): SessionMonitor { const queued: SessionSignal[] = [] let waiting: ((signal: SessionSignal) => void) | null = null let disposed = false @@ -190,15 +196,34 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni queued.push(signal) } - const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { - if (eventDirectory !== directory) { - return - } - + const handleEvent = (event: Parameters[0]): void => { const signal = sessionSettleSignal(event, sessionId) if (signal) { push({ errorText: signal.errorText, disposed: false }) } + } + + const unsubscribe = serverId === LOCAL_OPENCODE_SERVER_ID + ? sseAggregator.onEvent((eventDirectory, event) => { + if (eventDirectory !== directory) { + return + } + handleEvent(event) + }) + : sseAggregator.onServerEvent((eventServerId, eventDirectory, event) => { + if (eventServerId !== serverId) { + return + } + if (eventDirectory !== null && eventDirectory !== directory) { + return + } + handleEvent(event) + }) + + const unsubscribeConnected = sseAggregator.onServerConnected((connectedServerId) => { + if (connectedServerId === serverId) { + push({ errorText: null, disposed: false }) + } }) return { @@ -221,11 +246,24 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni } disposed = true unsubscribe() + unsubscribeConnected() push({ errorText: null, disposed: true }) }, } } +export type ScheduleJobTarget = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string; directory: string } + +interface ScheduleTargetContext { + repo: Repo | null + client: OpenCodeClient + directory: string + repoId: number | null + serverId: string +} + export class ScheduleService { private static activeRuns = new Map() private static pendingCancels = new Map() @@ -237,6 +275,7 @@ export class ScheduleService { private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly worktreeManager: ScheduleWorktreeManager, + private readonly serverRegistry: OpenCodeServerRegistry | null = null, ) {} setJobChangeHandler(handler: ((job: ScheduleJob | null, jobId: number) => void) | null): void { @@ -306,11 +345,28 @@ export class ScheduleService { } } + private static attachSessionMonitor(jobId: number, runId: number, sessionMonitor: SessionMonitor): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId === runId) { + active.sessionMonitor = sessionMonitor + } + } + + private static disposeActiveRunMonitor(jobId: number, runId: number): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId !== runId || !active.sessionMonitor) { + return + } + active.sessionMonitor.dispose() + active.sessionMonitor = undefined + } + getActiveRunSessions(): ScheduledSessionRef[] { const refs: ScheduledSessionRef[] = [] for (const run of listRunningScheduleRuns(this.db)) { if (!run.sessionId) continue + if (run.repoId === null) continue const directory = run.worktreePath ?? this.findRepoPath(run.repoId) if (!directory) continue @@ -329,6 +385,103 @@ export class ScheduleService { } } + private assertServer(serverId: string) { + const server = this.serverRegistry?.get(serverId) + if (!server) { + throw new ServerUnavailableError('OpenCode server not found', 404) + } + if (!server.enabled) { + throw new ServerUnavailableError(`OpenCode server "${server.name}" is disabled`, 400) + } + return server + } + + private safeServerClient(serverId: string): OpenCodeClient { + try { + return this.serverClient(serverId) + } catch { + return this.openCodeClient + } + } + + private serverClient(serverId: string): OpenCodeClient { + if (!this.serverRegistry) { + throw new ScheduleServiceError('Linked OpenCode servers are not available', 500) + } + try { + return this.serverRegistry.getClient(serverId) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error) || 'OpenCode server is not available', 400) + } + } + + private resolveTargetContext(target: ScheduleJobTarget, directory?: string | null): ScheduleTargetContext { + if (target.kind === 'repo') { + const repo = this.assertRepo(target.repoId) + return { + repo, + client: this.openCodeClient, + directory: repo.fullPath, + repoId: target.repoId, + serverId: LOCAL_OPENCODE_SERVER_ID, + } + } + + this.assertServer(target.serverId) + const resolvedDirectory = directory ?? target.directory + if (!resolvedDirectory) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return { + repo: null, + client: this.serverClient(target.serverId), + directory: resolvedDirectory, + repoId: null, + serverId: target.serverId, + } + } + + private resolveTargetPersistence(target: ScheduleJobTarget): { repo: Repo | null; repoId: number | null } { + if (target.kind === 'repo') { + return { repo: this.assertRepo(target.repoId), repoId: target.repoId } + } + return { repo: null, repoId: null } + } + + private targetForJob(job: ScheduleJob): ScheduleJobTarget { + if (job.repoId !== null) { + return { kind: 'repo', repoId: job.repoId } + } + return { + kind: 'server', + serverId: job.serverId ?? LOCAL_OPENCODE_SERVER_ID, + directory: job.directory ?? '', + } + } + + private loadJobForTarget(target: ScheduleJobTarget, jobId: number): ScheduleJob { + const job = getScheduleJobById(this.db, target.kind === 'repo' ? target.repoId : null, jobId) + if (!job) { + throw new ScheduleServiceError('Schedule not found', 404) + } + if (target.kind === 'server' && (job.serverId ?? LOCAL_OPENCODE_SERVER_ID) !== target.serverId) { + throw new ScheduleServiceError('Schedule not found', 404) + } + return job + } + + private assertInPlaceWorkspace(target: ScheduleJobTarget, workspaceMode: ScheduleWorkspaceMode | undefined): void { + if (target.kind === 'server') { + if (workspaceMode !== undefined && workspaceMode !== 'in-place') { + throw new ScheduleServiceError('Schedules on a linked server run in place; worktree workspace modes are only available for local repositories.', 400) + } + return + } + if (workspaceMode === 'in-place') { + throw new ScheduleServiceError('In-place workspace mode is only available for schedules on a linked server.', 400) + } + } + listAllEnabledJobs(): ScheduleJob[] { return listEnabledScheduleJobs(this.db) } @@ -449,16 +602,45 @@ export class ScheduleService { return listScheduleJobsByRepo(this.db, repoId) } + listServerJobs(serverId: string): ScheduleJob[] { + return listScheduleJobsByServer(this.db, serverId) + } + getJob(repoId: number, jobId: number): ScheduleJob | null { return getScheduleJobById(this.db, repoId, jobId) } + getServerJob(serverId: string, jobId: number): ScheduleJob | null { + const job = getScheduleJobById(this.db, null, jobId) + return job && job.serverId === serverId ? job : null + } + async createJob(repoId: number, input: CreateScheduleJobRequest): Promise { - const repo = this.assertRepo(repoId) - await this.assertAgentAvailable(repo.fullPath, input.agentSlug?.trim() || null) + return this.createJobForTarget({ kind: 'repo', repoId }, input) + } + + async createServerJob(serverId: string, directory: string, input: CreateScheduleJobRequest): Promise { + if (!path.isAbsolute(directory)) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return this.createJobForTarget({ kind: 'server', serverId, directory }, input) + } + + private async createJobForTarget(target: ScheduleJobTarget, input: CreateScheduleJobRequest): Promise { + this.assertInPlaceWorkspace(target, input.workspaceMode) + const context = this.resolveTargetContext(target) + await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) + const request = target.kind === 'server' + ? { ...input, workspaceMode: 'in-place' as const, directory: context.directory, branch: null } + : { ...input, directory: undefined } try { - const job = createScheduleJob(this.db, repoId, buildCreateSchedulePersistenceInput(input)) + const job = createScheduleJob( + this.db, + context.repoId, + buildCreateSchedulePersistenceInput(request), + context.serverId, + ) this.onJobChange?.(job, job.id) return job } catch (error) { @@ -467,23 +649,40 @@ export class ScheduleService { } async updateJob(repoId: number, jobId: number, input: UpdateScheduleJobRequest): Promise { - const repo = this.assertRepo(repoId) - const existing = this.assertJob(repoId, jobId) + return this.updateJobForTarget({ kind: 'repo', repoId }, jobId, input) + } + + async updateServerJob(serverId: string, jobId: number, input: UpdateScheduleJobRequest): Promise { + if (input.directory !== undefined && input.directory !== null && !path.isAbsolute(input.directory)) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return this.updateJobForTarget({ kind: 'server', serverId, directory: '' }, jobId, input) + } + + private async updateJobForTarget(target: ScheduleJobTarget, jobId: number, input: UpdateScheduleJobRequest): Promise { + const existing = this.loadJobForTarget(target, jobId) + this.assertInPlaceWorkspace(target, input.workspaceMode) + const context = this.resolveTargetContext(target, input.directory ?? existing.directory) + if (input.agentSlug !== undefined) { - await this.assertAgentAvailable(repo.fullPath, input.agentSlug?.trim() || null) + await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) } if (input.workspaceMode !== undefined && input.workspaceMode !== existing.workspaceMode) { - const running = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, repoId, jobId) !== null + const running = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, context.repoId, jobId) !== null if (running) { throw new ScheduleServiceError('Cannot change where runs execute while a run is in progress.', 409) } } + const request = target.kind === 'server' + ? { ...input, workspaceMode: 'in-place' as const, directory: input.directory ?? existing.directory ?? context.directory, branch: null } + : { ...input, directory: undefined } + let job: ScheduleJob | null try { - job = updateScheduleJob(this.db, repoId, jobId, buildUpdatedSchedulePersistenceInput(existing, input)) + job = updateScheduleJob(this.db, context.repoId, jobId, buildUpdatedSchedulePersistenceInput(existing, request)) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error), 400) } @@ -496,8 +695,16 @@ export class ScheduleService { } async deleteJob(repoId: number, jobId: number): Promise { - this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.deleteJobForTarget({ kind: 'repo', repoId }, jobId) + } + + async deleteServerJob(serverId: string, jobId: number): Promise { + return this.deleteJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + } + + private async deleteJobForTarget(target: ScheduleJobTarget, jobId: number): Promise { + const job = this.loadJobForTarget(target, jobId) + const { repoId } = this.resolveTargetPersistence(target) if (ScheduleService.activeRuns.has(jobId)) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) @@ -512,7 +719,9 @@ export class ScheduleService { ScheduleService.activeRuns.set(jobId, { runId: DELETION_RESERVATION_RUN_ID, abort: new AbortController() }) try { - await this.removeWorktrees(repoId, jobId) + if (target.kind === 'repo') { + await this.removeWorktrees(target.repoId, jobId) + } const deleted = deleteScheduleJob(this.db, repoId, jobId) if (!deleted) { @@ -564,6 +773,11 @@ export class ScheduleService { return listScheduleRunsByJob(this.db, repoId, jobId, limit) } + listServerRuns(serverId: string, jobId: number, limit: number = 20): ScheduleRun[] { + this.loadJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + return listScheduleRunsByJob(this.db, null, jobId, limit) + } + getRun(repoId: number, jobId: number, runId: number): ScheduleRun { this.assertJob(repoId, jobId) const run = getScheduleRunById(this.db, repoId, jobId, runId) @@ -573,6 +787,15 @@ export class ScheduleService { return run } + getServerRun(serverId: string, jobId: number, runId: number): ScheduleRun { + this.loadJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + const run = getScheduleRunById(this.db, null, jobId, runId) + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + return run + } + /** * 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 @@ -581,15 +804,25 @@ export class ScheduleService { * discarded along with its branch (`discard`). */ async clearRunHistory(repoId: number, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.clearRunHistoryForTarget({ kind: 'repo', repoId }, jobId, worktrees) + } + + async clearServerRunHistory(serverId: string, jobId: number): Promise<{ cleared: number }> { + return this.clearRunHistoryForTarget({ kind: 'server', serverId, directory: '' }, jobId) + } + + private async clearRunHistoryForTarget(target: ScheduleJobTarget, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { + const job = this.loadJobForTarget(target, jobId) + const { repo, repoId } = this.resolveTargetPersistence(target) const removable = listScheduleRunArtifactsByJob(this.db, repoId, jobId).filter((run) => run.status !== 'running') if (removable.length === 0) { return { cleared: 0 } } - await this.worktreeManager.pruneRunArtifacts(repo, job, removable, worktrees) + if (repo) { + await this.worktreeManager.pruneRunArtifacts(repo, job, removable, worktrees) + } const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id)) return { cleared } } @@ -601,15 +834,28 @@ export class ScheduleService { * discarded along with its branch (`discard`). */ async deleteRun(repoId: number, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) - const run = this.getRun(repoId, jobId, runId) + return this.deleteRunForTarget({ kind: 'repo', repoId }, jobId, runId, worktrees) + } + async deleteServerRun(serverId: string, jobId: number, runId: number): Promise { + return this.deleteRunForTarget({ kind: 'server', serverId, directory: '' }, jobId, runId) + } + + private async deleteRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { + const job = this.loadJobForTarget(target, jobId) + const { repo, repoId } = this.resolveTargetPersistence(target) + const run = getScheduleRunById(this.db, repoId, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } 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, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) + if (repo) { + 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) @@ -617,8 +863,21 @@ export class ScheduleService { } async runJob(repoId: number, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.runJobForTarget({ kind: 'repo', repoId }, jobId, triggerSource) + } + + async runServerJob(serverId: string, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { + return this.runJobForTarget({ kind: 'server', serverId, directory: '' }, jobId, triggerSource) + } + + async runJobForJob(job: ScheduleJob, triggerSource: ScheduleRunTriggerSource): Promise { + return this.runJobForTarget(this.targetForJob(job), job.id, triggerSource) + } + + private async runJobForTarget(target: ScheduleJobTarget, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { + const job = this.loadJobForTarget(target, jobId) + const repoId = target.kind === 'repo' ? target.repoId : null + const serverId = target.kind === 'repo' ? LOCAL_OPENCODE_SERVER_ID : target.serverId const existingRunningRun = getRunningScheduleRunByJob(this.db, repoId, jobId) if (existingRunningRun) { @@ -633,6 +892,7 @@ export class ScheduleService { const run = createScheduleRun(this.db, { jobId, repoId, + serverId, triggerSource, status: 'running', startedAt, @@ -641,42 +901,48 @@ export class ScheduleService { const abort = new AbortController() ScheduleService.activeRuns.set(jobId, { runId: run.id, abort }) + let repo: Repo | null = null try { - // Prepare worktree for isolated runs - const wt = await this.worktreeManager.prepare(repo, job, run.id) - const runDirectory = wt?.directory ?? repo.fullPath - if (wt) { - updateScheduleRunWorktree(this.db, repoId, jobId, run.id, { - worktreePath: wt.worktreePath, - runBranch: wt.runBranch, - }) + const context = this.resolveTargetContext(target, job.directory) + repo = context.repo + + let runDirectory = context.directory + if (repo) { + const wt = await this.worktreeManager.prepare(repo, job, run.id) + if (wt) { + runDirectory = wt.directory + updateScheduleRunWorktree(this.db, repoId, jobId, run.id, { + worktreePath: wt.worktreePath, + runBranch: wt.runBranch, + }) + } } if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } - const runJob = { ...job, agentSlug: await this.resolveRunAgent(runDirectory, job.agentSlug) } - const model = await resolveOpenCodeModel(this.openCodeClient, runDirectory, { + const runJob = { ...job, agentSlug: await this.resolveRunAgent(context.client, runDirectory, job.agentSlug) } + const model = await resolveOpenCodeModel(context.client, runDirectory, { preferredModel: job.model, signal: abort.signal, }) if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } - await this.connectMcpServers(runDirectory, job.mcpServers) + await this.connectMcpServers(context.client, runDirectory, job.mcpServers) if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } const sessionTitle = buildSessionTitle(job) let session: { id: string } try { - session = await this.openCodeClient.api.session.create({ + session = await context.client.api.session.create({ title: sessionTitle, agent: runJob.agentSlug ?? undefined, model: { providerID: model.providerID, id: model.id, variant: model.variant }, @@ -685,13 +951,13 @@ export class ScheduleService { }) } catch (error) { if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo, session.id) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo, session.id) } const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { @@ -709,10 +975,13 @@ export class ScheduleService { throw new ScheduleServiceError('Failed to attach session to run', 500) } - const sessionMonitor = createSessionMonitor(runDirectory, session.id) + const sessionMonitor = createSessionMonitor(runDirectory, session.id, context.serverId) + ScheduleService.attachSessionMonitor(jobId, run.id, sessionMonitor) void this.submitPromptAndMonitor({ repoId, + repo, + client: context.client, job: runJob, runId: run.id, sessionId: session.id, @@ -720,12 +989,20 @@ export class ScheduleService { triggerSource, sessionMonitor, directory: runDirectory, + serverId: context.serverId, }) return runWithSession } catch (error) { if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup( + target.kind === 'repo' ? this.openCodeClient : this.safeServerClient(target.serverId), + repoId, + jobId, + run, + job, + repo, + ) } const finishedAt = Date.now() @@ -753,7 +1030,6 @@ export class ScheduleService { logger.error(`Failed to update job state for job ${jobId}:`, updateError) } - // Teardown worktree if one was created before the error await this.teardownWorktree(repoId, jobId, run.id, job, repo) ScheduleService.releaseActiveRun(jobId, run.id) @@ -771,9 +1047,31 @@ export class ScheduleService { } async cancelRun(repoId: number, jobId: number, runId: number): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) - const run = this.getRun(repoId, jobId, runId) + return this.cancelRunForTarget({ kind: 'repo', repoId }, jobId, runId) + } + + async cancelServerRun(serverId: string, jobId: number, runId: number): Promise { + return this.cancelRunForTarget({ kind: 'server', serverId, directory: '' }, jobId, runId) + } + + private async cancelRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number): Promise { + const job = this.loadJobForTarget(target, jobId) + let context: ScheduleTargetContext + + try { + context = this.resolveTargetContext(target, job.directory) + } catch (error) { + if (target.kind !== 'server' || !(error instanceof ServerUnavailableError)) { + throw error + } + return this.cancelUnavailableServerRun(job, jobId, runId) + } + + const run = getScheduleRunById(this.db, context.repoId, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } if (run.status !== 'running') { throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) @@ -781,7 +1079,7 @@ export class ScheduleService { if (!run.sessionId) { ScheduleService.abortActiveRun(jobId, runId) - return this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + return this.finalizeCancelledRun(context.repo, job, run, context.repoId, jobId, runId) } ScheduleService.requestCancel(jobId, runId) @@ -789,7 +1087,7 @@ export class ScheduleService { try { let outcome: AssistantOutcome try { - outcome = await this.readAssistantOutcome(run.sessionId) + outcome = await this.readAssistantOutcome(context.client, run.sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } @@ -799,25 +1097,50 @@ export class ScheduleService { status: outcome.errorText ? 'failed' : 'completed', responseText: outcome.responseText, errorText: outcome.errorText, - }, repo) + }, context.repo) ScheduleService.resolveCancel(jobId, runId, 'settled') - return this.getRun(repoId, jobId, runId) + return this.getRunById(context.repoId, jobId, runId) } try { - await this.openCodeClient.api.session.interrupt({ sessionID: run.sessionId }) + await context.client.api.session.interrupt({ sessionID: run.sessionId }) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } - return await this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + return await this.finalizeCancelledRun(context.repo, job, run, context.repoId, jobId, runId) } catch (error) { - this.settleCancelFromState(repoId, jobId, runId) + this.settleCancelFromState(context.repoId, jobId, runId) throw error } } - private settleCancelFromState(repoId: number, jobId: number, runId: number): void { + private async cancelUnavailableServerRun(job: ScheduleJob, jobId: number, runId: number): Promise { + const run = getScheduleRunById(this.db, null, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + + if (run.status !== 'running') { + throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) + } + + ScheduleService.requestCancel(jobId, runId) + ScheduleService.abortActiveRun(jobId, runId) + ScheduleService.disposeActiveRunMonitor(jobId, runId) + return this.finalizeCancelledRun(null, job, run, null, jobId, runId) + } + + private getRunById(repoId: number | null, jobId: number, runId: number): ScheduleRun { + const run = getScheduleRunById(this.db, repoId, jobId, runId) + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + return run + } + + private settleCancelFromState(repoId: number | null, jobId: number, runId: number): void { let status: ScheduleRun['status'] | undefined try { status = getScheduleRunById(this.db, repoId, jobId, runId)?.status @@ -829,10 +1152,10 @@ export class ScheduleService { } private async finalizeCancelledRun( - repo: Repo, + repo: Repo | null, job: ScheduleJob, run: ScheduleRun, - repoId: number, + repoId: number | null, jobId: number, runId: number, ): Promise { @@ -873,7 +1196,10 @@ export class ScheduleService { } private async submitPromptAndMonitor(input: { - repoId: number + repoId: number | null + repo: Repo | null + client: OpenCodeClient + serverId: string job: ScheduleJob runId: number sessionId: string @@ -882,21 +1208,19 @@ export class ScheduleService { sessionMonitor: SessionMonitor directory: string }): Promise { - const repo = this.assertRepo(input.repoId) - try { if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return } - const skills = await this.resolveSkillAttachments(input.directory, input.job.skillMetadata) + const skills = await this.resolveSkillAttachments(input.client, input.directory, input.job.skillMetadata) if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return } const notes = input.job.skillMetadata?.notes?.trim() - await this.openCodeClient.api.session.prompt({ + await input.client.api.session.prompt({ sessionID: input.sessionId, text: notes ? `${input.job.prompt}\n\nSkill notes: ${notes}` : input.job.prompt, ...(skills.length > 0 ? { skills } : {}), @@ -911,6 +1235,9 @@ export class ScheduleService { await this.monitorRunCompletion({ sessionMonitor: input.sessionMonitor, repoId: input.repoId, + repo: input.repo, + client: input.client, + serverId: input.serverId, job: input.job, runId: input.runId, sessionId: input.sessionId, @@ -955,7 +1282,7 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, repo) + await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } @@ -963,7 +1290,10 @@ export class ScheduleService { private async monitorRunCompletion(input: { sessionMonitor: SessionMonitor - repoId: number + repoId: number | null + repo: Repo | null + client: OpenCodeClient + serverId: string job: ScheduleJob runId: number sessionId: string @@ -971,8 +1301,7 @@ export class ScheduleService { triggerSource: ScheduleRunTriggerSource }): Promise { try { - const repo = this.assertRepo(input.repoId) - const currentAssistantState = await this.readSettledAssistantState(input.sessionId) + const currentAssistantState = await this.readSettledAssistantState(input.client, input.sessionId) if (currentAssistantState) { if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return @@ -989,11 +1318,11 @@ export class ScheduleService { status: currentAssistantState.errorText ? 'failed' : 'completed', responseText: currentAssistantState.responseText, errorText: currentAssistantState.errorText, - }, repo) + }, input.repo) return } - const response = await this.waitForAssistantMessage(input.sessionId, input.sessionMonitor) + const response = await this.waitForAssistantMessage(input.client, input.sessionId, input.sessionMonitor) if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return @@ -1083,16 +1412,20 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, this.assertRepo(input.repoId)) + await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } } private async recoverRunningRun(job: ScheduleJob, run: ScheduleRun): Promise { + const target = this.targetForJob(job) + let repo: Repo | null = null + try { - const repo = this.assertRepo(job.repoId) - const runDirectory = run.worktreePath ?? repo.fullPath + const context = this.resolveTargetContext(target, job.directory) + repo = context.repo + const runDirectory = run.worktreePath ?? context.directory if (!run.sessionId) { await this.finalizeRecoveredRun(job, run, { @@ -1102,7 +1435,7 @@ export class ScheduleService { return } - const outcome = await this.readAssistantOutcome(run.sessionId) + const outcome = await this.readAssistantOutcome(context.client, run.sessionId) if (outcome.kind === 'settled') { await this.finalizeRecoveredRun(job, run, { @@ -1114,10 +1447,14 @@ export class ScheduleService { } if (outcome.kind === 'busy') { - const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId) + const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId, context.serverId) + ScheduleService.attachSessionMonitor(job.id, run.id, sessionMonitor) void this.monitorRunCompletion({ sessionMonitor, repoId: run.repoId, + repo, + client: context.client, + serverId: context.serverId, job, runId: run.id, sessionId: run.sessionId, @@ -1138,7 +1475,7 @@ export class ScheduleService { await this.finalizeRecoveredRun(job, run, { status: 'failed', errorText, - }, this.assertRepo(job.repoId)) + }, repo) } } @@ -1150,7 +1487,7 @@ export class ScheduleService { responseText?: string | null errorText?: string | null }, - repo: Repo, + repo: Repo | null, ): Promise { const finishedAt = Date.now() @@ -1181,28 +1518,29 @@ export class ScheduleService { ScheduleService.releaseActiveRun(job.id, run.id) } - private async interruptCancelledSession(sessionId: string): Promise { + private async interruptCancelledSession(client: OpenCodeClient, sessionId: string): Promise { try { - await this.openCodeClient.api.session.interrupt({ sessionID: sessionId }) + await client.api.session.interrupt({ sessionID: sessionId }) } catch (error) { logger.error(`Failed to interrupt cancelled schedule session ${sessionId}:`, error) } } - private loadRun(repoId: number, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { + private loadRun(repoId: number | null, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { return getScheduleRunById(this.db, repoId, jobId, runId) ?? fallback } private async abandonCancelledStartup( - repoId: number, + client: OpenCodeClient, + repoId: number | null, jobId: number, run: ScheduleRun, job: ScheduleJob, - repo: Repo, + repo: Repo | null, sessionId?: string, ): Promise { if (sessionId) { - await this.interruptCancelledSession(sessionId) + await this.interruptCancelledSession(client, sessionId) } ScheduleService.clearCancel(jobId, run.id) await this.teardownWorktree(repoId, jobId, run.id, job, repo) @@ -1210,7 +1548,8 @@ export class ScheduleService { return this.loadRun(repoId, jobId, run.id, run) } - private async teardownWorktree(repoId: number, jobId: number, runId: number, job: ScheduleJob, repo: Repo): Promise { + private async teardownWorktree(repoId: number | null, jobId: number, runId: number, job: ScheduleJob, repo: Repo | null): Promise { + if (!repo) return const key = `${repoId}:${jobId}:${runId}` if (ScheduleService.activeTeardowns.has(key)) return ScheduleService.activeTeardowns.add(key) @@ -1241,12 +1580,12 @@ export class ScheduleService { * settles one message per tool call. Only a session that has gone idle has * finished, and only then is the final message guaranteed to carry its content. */ - private async readAssistantOutcome(sessionId: string): Promise { - if (await this.isSessionActive(sessionId)) { + private async readAssistantOutcome(client: OpenCodeClient, sessionId: string): Promise { + if (await this.isSessionActive(client, sessionId)) { return { kind: 'busy' } } - const assistantState = await this.readAssistantReply(sessionId) + const assistantState = await this.readAssistantReply(client, sessionId) if (assistantState?.completed || assistantState?.errorText) { return { kind: 'settled', responseText: assistantState.responseText, errorText: assistantState.errorText } } @@ -1255,15 +1594,17 @@ export class ScheduleService { } private async readSettledAssistantState( + client: OpenCodeClient, sessionId: string, ): Promise<{ responseText: string | null; errorText: string | null } | null> { - const outcome = await this.readAssistantOutcome(sessionId) + const outcome = await this.readAssistantOutcome(client, sessionId) return outcome.kind === 'settled' ? { responseText: outcome.responseText, errorText: outcome.errorText } : null } private async waitForAssistantMessage( + client: OpenCodeClient, sessionId: string, sessionMonitor: SessionMonitor, ): Promise<{ responseText: string | null; errorText: string | null }> { @@ -1271,14 +1612,14 @@ export class ScheduleService { const signal = await sessionMonitor.nextSignal() if (signal.errorText || signal.disposed) { - const assistantState = await this.readAssistantReply(sessionId) + const assistantState = await this.readAssistantReply(client, sessionId) return { responseText: assistantState?.responseText ?? null, errorText: signal.errorText ?? SESSION_STOPPED_ERROR, } } - const outcome = await this.readAssistantOutcome(sessionId) + const outcome = await this.readAssistantOutcome(client, sessionId) if (outcome.kind === 'busy') { continue @@ -1292,25 +1633,25 @@ export class ScheduleService { } } - private async readAssistantReply(sessionId: string): Promise { + private async readAssistantReply(client: OpenCodeClient, sessionId: string): Promise { try { - return await readLatestAssistantReply(this.openCodeClient, sessionId) + return await readLatestAssistantReply(client, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch session messages', 502) } } - private async isSessionActive(sessionId: string): Promise { + private async isSessionActive(client: OpenCodeClient, sessionId: string): Promise { try { - return await isSessionBusy(this.openCodeClient, sessionId) + return await isSessionBusy(client, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch active sessions', 502) } } - private async listAgentIds(directory: string): Promise> { + private async listAgentIds(client: OpenCodeClient, directory: string): Promise> { try { - const response = await this.openCodeClient.api.agent.list(openCodeLocation(directory)) + const response = await client.api.agent.list(openCodeLocation(directory)) return new Set(response.data.map((agent) => agent.id)) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to list agents', 502) @@ -1321,12 +1662,12 @@ export class ScheduleService { * OpenCode admits a prompt for a session whose agent does not exist at its location * but never delivers it, so a scheduled run falls back to the default agent when its agent is gone. */ - private async resolveRunAgent(directory: string, agentSlug: string | null): Promise { + private async resolveRunAgent(client: OpenCodeClient, directory: string, agentSlug: string | null): Promise { if (!agentSlug) { return null } - if ((await this.listAgentIds(directory)).has(agentSlug)) { + if ((await this.listAgentIds(client, directory)).has(agentSlug)) { return agentSlug } @@ -1334,12 +1675,12 @@ export class ScheduleService { return null } - private async assertAgentAvailable(directory: string, agentSlug: string | null): Promise { + private async assertAgentAvailable(client: OpenCodeClient, directory: string, agentSlug: string | null): Promise { if (!agentSlug) { return } - if (!(await this.listAgentIds(directory)).has(agentSlug)) { + if (!(await this.listAgentIds(client, directory)).has(agentSlug)) { throw new ScheduleServiceError( `Agent "${agentSlug}" is not available in ${directory}. Choose an agent defined for this repo in the schedule settings, or clear the agent to use the default.`, 400, @@ -1352,12 +1693,12 @@ export class ScheduleService { * session starts. Schedule-only servers are added to that location, and configured servers * are connected even when their config disables them; both last until OpenCode restarts. */ - private async connectMcpServers(directory: string, servers: ScheduleJob['mcpServers']): Promise { + private async connectMcpServers(client: OpenCodeClient, directory: string, servers: ScheduleJob['mcpServers']): Promise { if (servers.length === 0) { return } - const api = this.openCodeClient.api + const api = client.api const location = openCodeLocation(directory) const readStatuses = async () => mcpStatusByName((await api.mcp.list(location)).data) @@ -1395,6 +1736,7 @@ export class ScheduleService { } private async resolveSkillAttachments( + client: OpenCodeClient, directory: string, skillMetadata: ScheduleJob['skillMetadata'], ): Promise { @@ -1405,7 +1747,7 @@ export class ScheduleService { let availableSkills: Set try { - const response = await this.openCodeClient.api.skill.list(openCodeLocation(directory)) + const response = await client.api.skill.list(openCodeLocation(directory)) availableSkills = new Set(response.data.map((skill) => skill.id)) } catch (error) { logger.warn('Failed to list skills for the scheduled run; continuing without skill attachments:', error) @@ -1521,7 +1863,7 @@ export class ScheduleRunner { } const cron = new Cron(job.cronExpression, options, () => { logger.info(`Cron triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) }) this.cronJobs.set(job.id, cron) logger.info(`Cron job created for ${job.id}: next run at ${cron.nextRun()?.toISOString()}`) @@ -1548,7 +1890,7 @@ export class ScheduleRunner { const now = new Date() if (nextRunDate <= now) { - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) } const cronOptions = { @@ -1557,7 +1899,7 @@ export class ScheduleRunner { } const cron = new Cron(cronExpression, cronOptions, () => { logger.info(`Cron triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) }) this.cronJobs.set(job.id, cron) } else { @@ -1573,7 +1915,7 @@ export class ScheduleRunner { isRunning = true logger.info(`Interval timer triggered for job ${job.id}: ${job.name}`) try { - await this.executeJob(job.repoId, job.id) + await this.executeJob(job) } finally { isRunning = false scheduleNext() @@ -1587,7 +1929,7 @@ export class ScheduleRunner { if (isStopped || isRunning) return isRunning = true logger.info(`Interval timer triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id).finally(() => { + void this.executeJob(job).finally(() => { isRunning = false scheduleNext() }) @@ -1595,7 +1937,7 @@ export class ScheduleRunner { } else { if (!isStopped) { isRunning = true - void this.executeJob(job.repoId, job.id).finally(() => { + void this.executeJob(job).finally(() => { isRunning = false scheduleNext() }) @@ -1619,11 +1961,11 @@ export class ScheduleRunner { } } - private async executeJob(repoId: number, jobId: number): Promise { + private async executeJob(job: ScheduleJob): Promise { try { - await this.scheduleService.runJob(repoId, jobId, 'schedule') + await this.scheduleService.runJobForJob(job, 'schedule') } catch (error) { - logger.error(`Scheduled run failed for job ${jobId}:`, error) + logger.error(`Scheduled run failed for job ${job.id}:`, error) } } diff --git a/backend/src/services/session-ancestry.ts b/backend/src/services/session-ancestry.ts index ea893cf9b..7adcf1018 100644 --- a/backend/src/services/session-ancestry.ts +++ b/backend/src/services/session-ancestry.ts @@ -1,25 +1,42 @@ +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { OpenCodeClient } from './opencode/client' const MAX_PARENT_HOPS = 10 +export type SessionClientResolver = (serverId: string) => OpenCodeClient + +function sessionKey(serverId: string, sessionId: string): string { + return `${serverId}:${sessionId}` +} + export class SessionAncestry { private readonly parentBySession = new Map() + private readonly resolveClient: SessionClientResolver - constructor(private readonly openCodeClient: OpenCodeClient) {} + constructor(clientOrResolver: OpenCodeClient | SessionClientResolver) { + this.resolveClient = typeof clientOrResolver === 'function' ? clientOrResolver : () => clientOrResolver + } - rememberParent(sessionId: string, parentId: string | null | undefined): void { - this.parentBySession.set(sessionId, parentId ?? null) + rememberParent( + sessionId: string, + parentId: string | null | undefined, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): void { + this.parentBySession.set(sessionKey(serverId, sessionId), parentId ?? null) } - forget(sessionId: string): void { - this.parentBySession.delete(sessionId) + forget(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): void { + this.parentBySession.delete(sessionKey(serverId, sessionId)) } - async resolveRootSessionId(sessionId: string): Promise { + async resolveRootSessionId( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { let current = sessionId for (let hops = 0; hops < MAX_PARENT_HOPS; hops += 1) { - const parentId = await this.getParentId(current) + const parentId = await this.getParentId(current, serverId) if (!parentId) { return current } @@ -29,14 +46,15 @@ export class SessionAncestry { return current } - private async getParentId(sessionId: string): Promise { - if (this.parentBySession.has(sessionId)) { - return this.parentBySession.get(sessionId) ?? null + private async getParentId(sessionId: string, serverId: string): Promise { + const key = sessionKey(serverId, sessionId) + if (this.parentBySession.has(key)) { + return this.parentBySession.get(key) ?? null } - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const session = await this.resolveClient(serverId).api.session.get({ sessionID: sessionId }) const parentId = session.parentID ?? null - this.parentBySession.set(sessionId, parentId) + this.parentBySession.set(key, parentId) return parentId } } diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts index d1a32feca..c17e0e98b 100644 --- a/backend/src/services/session-goals.ts +++ b/backend/src/services/session-goals.ts @@ -1,6 +1,7 @@ import type { Database } from 'bun:sqlite' import { DEFAULT_SESSION_DEFAULTS, + LOCAL_OPENCODE_SERVER_ID, type SessionGoal, type SessionGoalStatus, type SessionGoalStopReason, @@ -43,10 +44,17 @@ const RECOVERY_RETRY_MS = 5000 type PrerequisiteResult = { ok: true; value: T } | { ok: false } +export interface SessionServerRegistry { + getClient(serverId: string): OpenCodeClient + get(serverId: string): { enabled: boolean } | null +} + +export type SessionServerSource = OpenCodeClient | SessionServerRegistry + export interface SessionGoalServiceOptions { quietMs?: number onOutcome?: (goal: SessionGoal) => void - resolveSessionLock?: (sessionId: string) => Promise + resolveSessionLock?: (sessionId: string, serverId: string) => Promise } export class SessionGoalError extends Error { @@ -62,10 +70,29 @@ export function sessionTokenTotal(tokens: { input: number; output: number; reaso return tokens.input + tokens.output + tokens.reasoning } +function goalKey(serverId: string, sessionId: string): string { + return `${serverId}:${sessionId}` +} + +function isServerRegistry(source: SessionServerSource): source is SessionServerRegistry { + return typeof (source as SessionServerRegistry).getClient === 'function' +} + +function toServerRegistry(source: SessionServerSource): SessionServerRegistry { + if (isServerRegistry(source)) { + return source + } + return { + getClient: () => source, + get: (serverId) => (serverId === LOCAL_OPENCODE_SERVER_ID ? { enabled: true } : null), + } +} + function toSessionGoal(record: SessionGoalRecord): SessionGoal { return { id: record.id, sessionId: record.sessionId, + serverId: record.serverId, directory: record.directory, objective: record.objective, status: record.status, @@ -91,16 +118,18 @@ export class SessionGoalService { private readonly auditing = new Set() private readonly auditRequested = new Set() private readonly auditGeneration = new Map() + private readonly servers: SessionServerRegistry private readonly quietMs: number private readonly onOutcome: (goal: SessionGoal) => void - private readonly resolveSessionLock: (sessionId: string) => Promise + private readonly resolveSessionLock: (sessionId: string, serverId: string) => Promise constructor( private readonly db: Database, - private readonly openCodeClient: OpenCodeClient, + servers: SessionServerSource, private readonly settingsService: SettingsService, options: SessionGoalServiceOptions = {}, ) { + this.servers = toServerRegistry(servers) this.quietMs = options.quietMs ?? DEFAULT_QUIET_MS this.onOutcome = options.onOutcome ?? (() => {}) this.resolveSessionLock = options.resolveSessionLock ?? (async () => null) @@ -109,7 +138,7 @@ export class SessionGoalService { loadOpenGoals(): void { this.goalBySession.clear() for (const record of listOpenSessionGoals(this.db)) { - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) } } @@ -118,6 +147,9 @@ export class SessionGoalService { if (record.status !== 'active' || record.turnState !== 'running') { continue } + if (!this.isServerAvailable(record.serverId)) { + continue + } try { await this.recoverRunningGoal(record) } catch (error) { @@ -127,73 +159,93 @@ export class SessionGoalService { } private async recoverRunningGoal(record: SessionGoalRecord): Promise { - const generation = this.currentAuditGeneration(record.sessionId) + const key = goalKey(record.serverId, record.sessionId) + const generation = this.currentAuditGeneration(key) + const client = this.servers.getClient(record.serverId) try { - await this.openCodeClient.api.session.get({ sessionID: record.sessionId }) + await client.api.session.get({ sessionID: record.sessionId }) } catch (error) { if (isSessionNotFoundError(error)) { this.finishGoal(record.id, 'stopped', 'session_deleted') return } logger.error(`Session goal recovery could not read session ${record.sessionId}:`, error) - this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + this.scheduleRecoveryRetry(key, record.id, generation) return } const busy = await this.readPrerequisite(record.id, 'busy check', () => - isSessionBusy(this.openCodeClient, record.sessionId), + isSessionBusy(client, record.sessionId), ) if (!busy.ok) { - this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + this.scheduleRecoveryRetry(key, record.id, generation) return } if (busy.value) { return } - this.scheduleAudit(record.sessionId, record.id, 0) + this.scheduleAudit(key, record.id, 0) + } + + private isServerAvailable(serverId: string): boolean { + if (serverId === LOCAL_OPENCODE_SERVER_ID) { + return true + } + try { + const server = this.servers.get(serverId) + return Boolean(server?.enabled) + } catch (error) { + logger.error(`Failed to resolve OpenCode server ${serverId}:`, error) + return false + } } - private scheduleRecoveryRetry(sessionId: string, goalId: number, generation: number): void { - this.clearRecoveryTimer(sessionId) + private scheduleRecoveryRetry(key: string, goalId: number, generation: number): void { + this.clearRecoveryTimer(key) const timer = setTimeout(() => { - this.recoveryTimers.delete(sessionId) - void this.retryRecovery(sessionId, goalId, generation).catch((error) => { + this.recoveryTimers.delete(key) + void this.retryRecovery(key, goalId, generation).catch((error) => { logger.error(`Session goal recovery retry failed for goal ${goalId}:`, error) }) }, RECOVERY_RETRY_MS) - this.recoveryTimers.set(sessionId, timer) + this.recoveryTimers.set(key, timer) } - private async retryRecovery(sessionId: string, goalId: number, generation: number): Promise { + private async retryRecovery(key: string, goalId: number, generation: number): Promise { const record = getSessionGoalById(this.db, goalId) if (!record || record.status !== 'active' || record.turnState !== 'running') { return } - if (this.currentAuditGeneration(sessionId) !== generation) { + if (!this.isServerAvailable(record.serverId)) { + return + } + if (this.currentAuditGeneration(key) !== generation) { return } await this.recoverRunningGoal(record) } - private clearRecoveryTimer(sessionId: string): void { - const timer = this.recoveryTimers.get(sessionId) + private clearRecoveryTimer(key: string): void { + const timer = this.recoveryTimers.get(key) if (timer !== undefined) { clearTimeout(timer) - this.recoveryTimers.delete(sessionId) + this.recoveryTimers.delete(key) } } async start(input: StartSessionGoalRequest): Promise { - await this.assertSessionCanStartGoal(input.sessionId) + const serverId = input.serverId ?? LOCAL_OPENCODE_SERVER_ID + await this.assertSessionCanStartGoal(input.sessionId, serverId) const defaults = this.settingsService.getSettings().preferences.sessionDefaults const maxContinuations = input.maxContinuations ?? defaults?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations const tokenBudget = input.tokenBudget ?? defaults?.goalTokenBudget ?? null - const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId) + const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId, serverId) const record = insertSessionGoal(this.db, { sessionId: input.sessionId, + serverId, directory: input.directory, objective: input.objective, maxContinuations, @@ -205,19 +257,19 @@ export class SessionGoalService { throw new SessionGoalError('This session already has an open goal', 409) } - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) return toSessionGoal(record) } - getLatest(sessionId: string): SessionGoal | null { - const record = getLatestSessionGoal(this.db, sessionId) + getLatest(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): SessionGoal | null { + const record = getLatestSessionGoal(this.db, sessionId, serverId) return record ? toSessionGoal(record) : null } - private async assertSessionCanStartGoal(sessionId: string): Promise { + private async assertSessionCanStartGoal(sessionId: string, serverId: string): Promise { let lock: SessionLockReason | null try { - lock = await this.resolveSessionLock(sessionId) + lock = await this.resolveSessionLock(sessionId, serverId) } catch (error) { logger.error(`Failed to resolve session lock for ${sessionId}:`, error) return @@ -256,41 +308,46 @@ export class SessionGoalService { }) } - async handleEvent(directory: string | null, event: SSEEvent): Promise { + async handleEvent( + directory: string | null, + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { const sessionId = sessionIDFromEvent(event) if (!sessionId) { return } - const goalId = this.goalBySession.get(sessionId) + const key = goalKey(serverId, sessionId) + const goalId = this.goalBySession.get(key) if (goalId === undefined) { return } switch (event.type) { case 'session.execution.started': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.applyTransition(goalId, ['active'], { turnState: 'running' }) return case 'session.idle': case 'session.execution.succeeded': - this.scheduleAuditFromEvent(sessionId, goalId) + this.scheduleAuditFromEvent(key, goalId) return case 'session.status': if (event.data.status.type === 'idle') { - this.scheduleAuditFromEvent(sessionId, goalId) + this.scheduleAuditFromEvent(key, goalId) } return case 'session.execution.failed': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'stopped', 'turn_error') return case 'session.execution.interrupted': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'paused', 'interrupted') return case 'session.deleted': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'stopped', 'session_deleted', ['active', 'paused']) return default: @@ -298,50 +355,51 @@ export class SessionGoalService { } } - private scheduleAuditFromEvent(sessionId: string, goalId: number): void { + private scheduleAuditFromEvent(key: string, goalId: number): void { const record = getSessionGoalById(this.db, goalId) if (!record || record.status !== 'active' || record.turnState !== 'running') { return } - this.scheduleAudit(sessionId, goalId, this.quietMs) + this.scheduleAudit(key, goalId, this.quietMs) } private async scheduleAuditWhenIdle(goal: SessionGoal): Promise { - const generation = this.currentAuditGeneration(goal.sessionId) + const key = goalKey(goal.serverId, goal.sessionId) + const generation = this.currentAuditGeneration(key) const busy = await this.readPrerequisite(goal.id, 'busy check', () => - isSessionBusy(this.openCodeClient, goal.sessionId), + isSessionBusy(this.servers.getClient(goal.serverId), goal.sessionId), ) if (!busy.ok) { - this.failAudit(goal.id, goal.sessionId, generation) + this.failAudit(goal.id, key, generation) return } - if (!this.isAuditCurrent(goal.id, goal.sessionId, generation)) { + if (!this.isAuditCurrent(goal.id, key, generation)) { return } if (busy.value) { return } - this.scheduleAudit(goal.sessionId, goal.id, 0) + this.scheduleAudit(key, goal.id, 0) } - private scheduleAudit(sessionId: string, goalId: number, delay: number): void { - this.clearRecoveryTimer(sessionId) - this.clearAuditTimer(sessionId) + private scheduleAudit(key: string, goalId: number, delay: number): void { + this.clearRecoveryTimer(key) + this.clearAuditTimer(key) const timer = setTimeout(() => { - this.auditTimers.delete(sessionId) + this.auditTimers.delete(key) void this.runAudit(goalId).catch((error) => { logger.error(`Session goal audit failed for goal ${goalId}:`, error) }) }, delay) - this.auditTimers.set(sessionId, timer) + this.auditTimers.set(key, timer) } - private clearAuditTimer(sessionId: string): void { - const timer = this.auditTimers.get(sessionId) + private clearAuditTimer(key: string): void { + const timer = this.auditTimers.get(key) if (timer !== undefined) { clearTimeout(timer) - this.auditTimers.delete(sessionId) + this.auditTimers.delete(key) } } @@ -371,27 +429,29 @@ export class SessionGoalService { } const sessionId = goal.sessionId - const generation = this.currentAuditGeneration(sessionId) + const key = goalKey(goal.serverId, sessionId) + const generation = this.currentAuditGeneration(key) + const client = this.servers.getClient(goal.serverId) const busy = await this.readPrerequisite(goalId, 'busy check', () => - isSessionBusy(this.openCodeClient, sessionId), + isSessionBusy(client, sessionId), ) if (!busy.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation) || busy.value) { + if (!this.isAuditCurrent(goalId, key, generation) || busy.value) { return } const total = await this.readPrerequisite(goalId, 'token read', () => - this.readSessionTokenTotal(sessionId), + this.readSessionTokenTotal(sessionId, goal.serverId), ) if (!total.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -407,13 +467,13 @@ export class SessionGoalService { } const reply = await this.readPrerequisite(goalId, 'reply read', () => - readLatestAssistantReply(this.openCodeClient, sessionId), + readLatestAssistantReply(client, sessionId), ) if (!reply.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -423,7 +483,7 @@ export class SessionGoalService { } const verdict = await this.evaluateAudit(updated, reply.value) - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } if (!verdict) { @@ -431,16 +491,17 @@ export class SessionGoalService { return } - await this.applyVerdict(goalId, sessionId, generation, updated, verdict) + await this.applyVerdict(goalId, key, generation, updated, verdict) } private async evaluateAudit(goal: SessionGoalRecord, reply: AssistantReplyState | null): Promise { const prompt = buildGoalAuditPrompt({ objective: goal.objective, reply: reply?.responseText ?? null }) const model = this.resolveAuditorModel() + const localClient = this.servers.getClient(LOCAL_OPENCODE_SERVER_ID) for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { try { - const result = await this.openCodeClient.api.generate.text(model ? { prompt, model } : { prompt }) + const result = await localClient.api.generate.text(model ? { prompt, model } : { prompt }) return parseGoalVerdict(result.text) ?? { verdict: 'continue', reason: GOAL_AUDIT_UNPARSED_REASON } } catch (error) { logger.error(`Goal auditor call failed for goal ${goal.id} (attempt ${attempt + 1}):`, error) @@ -457,12 +518,12 @@ export class SessionGoalService { private async applyVerdict( goalId: number, - sessionId: string, + key: string, generation: number, goal: SessionGoalRecord, verdict: GoalVerdict, ): Promise { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -487,11 +548,11 @@ export class SessionGoalService { return } - await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, patch) + await this.continueGoal(goalId, key, generation, goal, verdict.reason, patch) return } - await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, { + await this.continueGoal(goalId, key, generation, goal, verdict.reason, { consecutiveBlocked: 0, lastVerdict: 'continue', lastReason: verdict.reason, @@ -500,13 +561,13 @@ export class SessionGoalService { private async continueGoal( goalId: number, - sessionId: string, + key: string, generation: number, goal: SessionGoalRecord, reason: string, patch: SessionGoalPatch, ): Promise { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -525,13 +586,13 @@ export class SessionGoalService { } try { - await this.openCodeClient.api.session.prompt({ + await this.servers.getClient(record.serverId).api.session.prompt({ sessionID: record.sessionId, text: buildGoalContinuationPrompt({ objective: record.objective, reason }), }) } catch (error) { logger.error(`Failed to send continuation for goal ${goalId}:`, error) - if (this.isAuditCurrent(goalId, sessionId, generation, 'waiting')) { + if (this.isAuditCurrent(goalId, key, generation, 'waiting')) { this.finishGoal(goalId, 'stopped', 'turn_error') } } @@ -555,8 +616,8 @@ export class SessionGoalService { } } - private failAudit(goalId: number, sessionId: string, generation: number): void { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + private failAudit(goalId: number, key: string, generation: number): void { + if (!this.isAuditCurrent(goalId, key, generation)) { return } this.finishGoal(goalId, 'paused', 'audit_failed') @@ -579,19 +640,19 @@ export class SessionGoalService { private isAuditCurrent( goalId: number, - sessionId: string, + key: string, generation: number, turnState: SessionGoalTurnState = 'running', ): boolean { - if (this.currentAuditGeneration(sessionId) !== generation) { + if (this.currentAuditGeneration(key) !== generation) { return false } const record = getSessionGoalById(this.db, goalId) return Boolean(record && record.status === 'active' && record.turnState === turnState) } - private currentAuditGeneration(sessionId: string): number { - return this.auditGeneration.get(sessionId) ?? 0 + private currentAuditGeneration(key: string): number { + return this.auditGeneration.get(key) ?? 0 } private applyTransition( @@ -632,14 +693,15 @@ export class SessionGoalService { } private syncGoalIndex(record: SessionGoalRecord): void { + const key = goalKey(record.serverId, record.sessionId) if (record.status === 'active' || record.status === 'paused') { - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(key, record.id) } else { - this.goalBySession.delete(record.sessionId) - this.clearAuditTimer(record.sessionId) + this.goalBySession.delete(key) + this.clearAuditTimer(key) } if (record.status !== 'active') { - this.clearRecoveryTimer(record.sessionId) + this.clearRecoveryTimer(key) } } @@ -647,18 +709,19 @@ export class SessionGoalService { if (patch.status === undefined && patch.turnState !== 'running') { return } - this.auditGeneration.set(record.sessionId, this.currentAuditGeneration(record.sessionId) + 1) + const key = goalKey(record.serverId, record.sessionId) + this.auditGeneration.set(key, this.currentAuditGeneration(key) + 1) this.auditRequested.delete(record.id) } - private async readSessionTokenTotal(sessionId: string): Promise { - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + private async readSessionTokenTotal(sessionId: string, serverId: string): Promise { + const session = await this.servers.getClient(serverId).api.session.get({ sessionID: sessionId }) return sessionTokenTotal(session.tokens) } - private async readSessionTokenTotalOrDefault(sessionId: string): Promise { + private async readSessionTokenTotalOrDefault(sessionId: string, serverId: string): Promise { try { - return await this.readSessionTokenTotal(sessionId) + return await this.readSessionTokenTotal(sessionId, serverId) } catch (error) { logger.error(`Failed to read token usage for session ${sessionId}:`, error) return 0 diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts index 21202f359..f0bbdc928 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -1,17 +1,18 @@ import type { Database } from 'bun:sqlite' import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { LOCAL_OPENCODE_SERVER_ID, type MultiRunTarget } from '@opencode-manager/shared/schemas' import { sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' import { getRepoById } from '../db/queries' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' import type { OpenCodeClient } from './opencode/client' import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models' +import { resolveRepoProjectId } from './repo' import type { RepoWorkspaceService } from './repo-workspace' type SessionCreateInput = NonNullable[0]> -interface LaunchSessionInput { - repoId: number +interface LaunchSessionCommon { prompt: string title?: string model?: string @@ -20,9 +21,12 @@ interface LaunchSessionInput { permissions?: SessionCreateInput['permissions'] } +export type LaunchSessionInput = LaunchSessionCommon & + ({ repoId: number } | { serverId: string; directory: string }) + export interface LaunchedSession { sessionId: string - repoId: number + repoId: number | null directory: string workspaceDirectory: string | null model: string @@ -48,11 +52,31 @@ export class SessionLaunchError extends Error { } } +export interface SessionTargetDeps { + db: Database + openCodeClient: OpenCodeClient + repoWorkspaces: RepoWorkspaceService + getServerClient?: (serverId: string) => OpenCodeClient +} + +/** + * A resolved place to run sessions: the local repository checkout or a directory on a linked + * OpenCode server. Workspaces and models are resolved through the same client the sessions run on. + */ +export interface ResolvedSessionTarget { + client: OpenCodeClient + directory: string + repo: Repo | null + resolveModel(requestedModel?: string): Promise + createWorkspace(options?: { name?: string; ref?: string }): Promise + removeWorkspace(directory: string): Promise +} + function withWorkspace(message: string, workspaceDirectory: string | null): string { return workspaceDirectory ? `${message} (workspace: ${workspaceDirectory})` : message } -export function requireReadyRepo(db: Database, repoId: number): Repo { +function requireReadyRepo(db: Database, repoId: number): Repo { const repo = getRepoById(db, repoId) if (!repo || repo.cloneStatus !== 'ready') { throw new SessionLaunchError('Repository not found or not ready', 404) @@ -60,48 +84,137 @@ export function requireReadyRepo(db: Database, repoId: number): Repo { return repo } +export function sessionTargetInput( + target: MultiRunTarget, +): { repoId: number } | { serverId: string; directory: string } { + return target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory } +} + +export function sessionTargetOfRun(run: { + repoId: number | null + serverId: string + directory: string | null +}): MultiRunTarget | null { + if (run.repoId !== null) { + return { kind: 'repo', repoId: run.repoId } + } + if (run.serverId === LOCAL_OPENCODE_SERVER_ID || run.directory === null) { + return null + } + return { kind: 'server', serverId: run.serverId, directory: run.directory } +} + +async function resolveModelIn( + client: OpenCodeClient, + directory: string, + requestedModel?: string, +): Promise { + let resolved: ResolvedOpenCodeModel + try { + resolved = await resolveOpenCodeModel(client, directory, { preferredModel: requestedModel }) + } catch (error) { + throw new SessionLaunchError(getErrorMessage(error) || 'Failed to resolve OpenCode model', 502) + } + + if (requestedModel) { + const requestedRef = parseOpenCodeModelRef(requestedModel) + if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { + throw new SessionLaunchError(`Model ${requestedModel} is not available`, 400) + } + } + + return resolved +} + +function repoSessionTarget(deps: SessionTargetDeps, repoId: number): ResolvedSessionTarget { + const repo = requireReadyRepo(deps.db, repoId) + const client = deps.openCodeClient + + return { + client, + directory: repo.fullPath, + repo, + resolveModel: (requestedModel) => resolveModelIn(client, repo.fullPath, requestedModel), + createWorkspace: async (options) => (await deps.repoWorkspaces.create(repo, options)).directory, + removeWorkspace: (directory) => deps.repoWorkspaces.remove(repo, directory), + } +} + +function serverSessionTarget( + deps: SessionTargetDeps, + serverId: string, + directory: string, +): ResolvedSessionTarget { + if (!deps.getServerClient) { + throw new SessionLaunchError('Remote OpenCode servers are not available', 400) + } + + const client = deps.getServerClient(serverId) + + return { + client, + directory, + repo: null, + resolveModel: (requestedModel) => resolveModelIn(client, directory, requestedModel), + createWorkspace: async (options) => { + const projectID = await resolveRepoProjectId(client, directory) + const worktree = await client.api.worktree.create({ + projectID, + ...(options?.name ? { name: options.name } : {}), + ...(options?.ref ? { branch: options.ref } : {}), + }) + return worktree.directory + }, + removeWorkspace: async (workspaceDirectory) => { + const projectID = await resolveRepoProjectId(client, directory) + await client.api.worktree.remove({ projectID, directory: workspaceDirectory, force: true }) + }, + } +} + +export async function resolveSessionTarget( + deps: SessionTargetDeps, + target: MultiRunTarget, +): Promise { + return target.kind === 'repo' + ? repoSessionTarget(deps, target.repoId) + : serverSessionTarget(deps, target.serverId, target.directory) +} + export class SessionLauncher { constructor( private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, + private readonly getServerClient?: (serverId: string) => OpenCodeClient, ) {} - async resolveModel(repo: Repo, requestedModel?: string): Promise { - let resolved: ResolvedOpenCodeModel - try { - resolved = await resolveOpenCodeModel(this.openCodeClient, repo.fullPath, { - preferredModel: requestedModel, - }) - } catch (error) { - throw new SessionLaunchError(getErrorMessage(error) || 'Failed to resolve OpenCode model', 502) - } - - if (requestedModel) { - const requestedRef = parseOpenCodeModelRef(requestedModel) - if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { - throw new SessionLaunchError(`Model ${requestedModel} is not available`, 400) - } - } - - return resolved - } - async launch(input: LaunchSessionInput): Promise { - const repo = requireReadyRepo(this.db, input.repoId) - const model = await this.resolveModel(repo, input.model) + const target = await resolveSessionTarget( + { + db: this.db, + openCodeClient: this.openCodeClient, + repoWorkspaces: this.repoWorkspaces, + ...(this.getServerClient ? { getServerClient: this.getServerClient } : {}), + }, + 'repoId' in input + ? { kind: 'repo', repoId: input.repoId } + : { kind: 'server', serverId: input.serverId, directory: input.directory }, + ) + const model = await target.resolveModel(input.model) - let directory = repo.fullPath + let directory = target.directory let workspaceDirectory: string | null = null if (input.workspace) { try { - const workspace = await this.repoWorkspaces.create(repo, { + directory = await target.createWorkspace({ name: sanitizeRepoDirectoryName(input.workspace.name ?? '', 'session'), ...(input.workspace.ref ? { ref: input.workspace.ref } : {}), }) - directory = workspace.directory - workspaceDirectory = workspace.directory + workspaceDirectory = directory } catch (error) { throw new SessionLaunchError(getErrorMessage(error) || 'Failed to create workspace', 502) } @@ -109,7 +222,7 @@ export class SessionLauncher { let session: { id: string; title?: string | null } try { - session = await this.openCodeClient.api.session.create({ + session = await target.client.api.session.create({ ...(input.title ? { title: input.title } : {}), ...(input.agent ? { agent: input.agent } : {}), ...(input.permissions ? { permissions: input.permissions } : {}), @@ -129,7 +242,7 @@ export class SessionLauncher { } try { - await this.openCodeClient.api.session.prompt({ sessionID: session.id, text: input.prompt }) + await target.client.api.session.prompt({ sessionID: session.id, text: input.prompt }) } catch (error) { throw new SessionLaunchError( withWorkspace(getErrorMessage(error) || 'Failed to create OpenCode session', workspaceDirectory), @@ -141,7 +254,7 @@ export class SessionLauncher { return { sessionId: session.id, - repoId: repo.id, + repoId: target.repo?.id ?? null, directory, workspaceDirectory, model: model.model, diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index c7ff9ace0..4514364dd 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -1,8 +1,9 @@ import type { Database } from 'bun:sqlite' -import type { - SessionLockReason, - SessionPermissionMode, - SessionPermissionModeState, +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionLockReason, + type SessionPermissionMode, + type SessionPermissionModeState, } from '@opencode-manager/shared/schemas' import { openCodeLocation, type PermissionRequest } from '@opencode-manager/shared/opencode' import { getScheduleRunBySessionId } from '../db/schedules' @@ -18,6 +19,16 @@ import type { SessionAncestry } from './session-ancestry' import type { SSEEvent } from './sse-aggregator' import type { SettingsService } from './settings' +export interface SessionClientProvider { + getClient(serverId: string): OpenCodeClient +} + +export type SessionClientSource = OpenCodeClient | SessionClientProvider + +function isClientProvider(source: SessionClientSource): source is SessionClientProvider { + return typeof (source as SessionClientProvider).getClient === 'function' +} + export class SessionPermissionModeError extends Error { status: number @@ -28,55 +39,71 @@ export class SessionPermissionModeError extends Error { } export class SessionPermissionModeService { + private readonly clients: SessionClientProvider + constructor( private readonly db: Database, - private readonly openCodeClient: OpenCodeClient, + clients: SessionClientSource, private readonly settingsService: SettingsService, private readonly ancestry: SessionAncestry, - ) {} + ) { + this.clients = isClientProvider(clients) ? clients : { getClient: () => clients } + } - async handleEvent(directory: string | null, event: SSEEvent): Promise { + async handleEvent( + directory: string | null, + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { switch (event.type) { case 'session.created': { const { sessionID, parentID, permissions } = event.data - this.ancestry.rememberParent(sessionID, parentID) + this.ancestry.rememberParent(sessionID, parentID, serverId) const hasExplicitPermissions = Array.isArray(permissions) && permissions.length > 0 if (!parentID && !hasExplicitPermissions && this.defaultMode() === 'auto') { - insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto', serverId) } return } case 'session.forked': { const { sessionID, parentID } = event.data - this.ancestry.rememberParent(sessionID, null) - const sourceMode = await this.getEffectiveMode(parentID) + this.ancestry.rememberParent(sessionID, null, serverId) + const sourceMode = await this.getEffectiveMode(parentID, serverId) if (sourceMode.mode === 'auto') { - insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto', serverId) } return } case 'permission.asked': { const { sessionID, id } = event.data - await this.autoAcceptRequest(sessionID, id) + await this.autoAcceptRequest(sessionID, id, serverId) return } case 'session.deleted': { const { sessionID } = event.data - this.ancestry.forget(sessionID) - deleteSessionPermissionMode(this.db, sessionID) + this.ancestry.forget(sessionID, serverId) + deleteSessionPermissionMode(this.db, sessionID, serverId) return } } } - async getEffectiveMode(sessionId: string): Promise { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) - return this.effectiveModeForRoot(sessionId, rootSessionId) + async getEffectiveMode( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId, serverId) + return this.effectiveModeForRoot(sessionId, rootSessionId, serverId) } - async setMode(sessionId: string, mode: SessionPermissionMode, directory: string): Promise { - const rootSessionId = await this.ancestry.resolveRootSessionId(sessionId) - const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + async setMode( + sessionId: string, + mode: SessionPermissionMode, + directory: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + const rootSessionId = await this.ancestry.resolveRootSessionId(sessionId, serverId) + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId, serverId) if (lockedReason === 'schedule') { throw new SessionPermissionModeError( 'Scheduled runs use their own permission configuration', @@ -91,15 +118,15 @@ export class SessionPermissionModeService { ) } - setSessionPermissionMode(this.db, sessionId, mode) + setSessionPermissionMode(this.db, sessionId, mode, serverId) if (mode === 'auto') { - await this.acceptPendingRequestsInDirectory(directory, sessionId) + await this.acceptPendingRequestsInDirectory(directory, serverId, sessionId) } return { sessionId, rootSessionId, mode, lockedReason: null } } - pinAsk(sessionId: string): void { - setSessionPermissionMode(this.db, sessionId, 'ask') + pinAsk(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): void { + setSessionPermissionMode(this.db, sessionId, 'ask', serverId) } defaultMode(): SessionPermissionMode { @@ -112,66 +139,93 @@ export class SessionPermissionModeService { * * Does nothing when the default mode is `ask`, since that needs no stored row. */ - async applyDefaultMode(sessionId: string, directory: string): Promise { + async applyDefaultMode( + sessionId: string, + directory: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { if (this.defaultMode() !== 'auto') { return } - insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto') - await this.acceptPendingRequestsInDirectory(directory, sessionId) + insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto', serverId) + await this.acceptPendingRequestsInDirectory(directory, serverId, sessionId) } - async acceptPendingRequestsForActiveSessions(): Promise { + async acceptPendingRequestsForActiveSessions(serverId: string = LOCAL_OPENCODE_SERVER_ID): Promise { try { - const active = await this.openCodeClient.api.session.active() + const client = this.clients.getClient(serverId) + const active = await client.api.session.active() const directories = new Set() for (const sessionId of Object.keys(active ?? {})) { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId, serverId) if (!rootSessionId) continue - if (this.effectiveModeForRoot(sessionId, rootSessionId).mode !== 'auto') continue - const directory = await this.resolveSessionDirectory(sessionId) + if (this.effectiveModeForRoot(sessionId, rootSessionId, serverId).mode !== 'auto') continue + const directory = await this.resolveSessionDirectory(sessionId, serverId) if (directory) directories.add(directory) } for (const directory of directories) { - await this.acceptPendingRequestsInDirectory(directory) + await this.acceptPendingRequestsInDirectory(directory, serverId) } } catch (error) { - logger.error('Failed to accept pending permission requests for active sessions:', error) + logger.error(`Failed to accept pending permission requests for server ${serverId}:`, error) } } + async suppressesPermissionNotification( + event: SSEEvent, + sessionId: string | undefined, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + if (event.type !== 'permission.asked' || !sessionId) { + return false + } + return (await this.getEffectiveMode(sessionId, serverId)).mode === 'auto' + } + private effectiveModeForRoot( sessionId: string, rootSessionId: string | null, + serverId: string, ): SessionPermissionModeState { if (!rootSessionId) { return { sessionId, rootSessionId: sessionId, mode: 'ask', lockedReason: null } } - const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId, serverId) if (lockedReason === 'schedule') { return { sessionId, rootSessionId, mode: 'ask', lockedReason } } - const stored = getSessionPermissionMode(this.db, rootSessionId) + const stored = getSessionPermissionMode(this.db, rootSessionId, serverId) return { sessionId, rootSessionId, mode: stored ?? 'ask', lockedReason } } - private lockReasonForRoot(sessionId: string, rootSessionId: string): SessionLockReason | null { - if (getScheduleRunBySessionId(this.db, rootSessionId)) { + private lockReasonForRoot( + sessionId: string, + rootSessionId: string, + serverId: string, + ): SessionLockReason | null { + if (getScheduleRunBySessionId(this.db, rootSessionId, serverId)) { return 'schedule' } return rootSessionId !== sessionId ? 'child' : null } - private async acceptPendingRequestsInDirectory(directory: string, expectedRootSessionId?: string): Promise { + private async acceptPendingRequestsInDirectory( + directory: string, + serverId: string, + expectedRootSessionId?: string, + ): Promise { let requests: PermissionRequest[] try { - const result = await this.openCodeClient.api.permission.request.list(openCodeLocation(directory)) + const result = await this.clients + .getClient(serverId) + .api.permission.request.list(openCodeLocation(directory)) requests = result.data } catch (error) { logger.error(`Failed to list pending permission requests for directory ${directory}:`, error) @@ -179,13 +233,13 @@ export class SessionPermissionModeService { } for (const request of requests) { - await this.autoAcceptRequest(request.sessionID, request.id, expectedRootSessionId) + await this.autoAcceptRequest(request.sessionID, request.id, serverId, expectedRootSessionId) } } - private async resolveSessionDirectory(sessionId: string): Promise { + private async resolveSessionDirectory(sessionId: string, serverId: string): Promise { try { - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const session = await this.clients.getClient(serverId).api.session.get({ sessionID: sessionId }) return session.location.directory } catch (error) { logger.error(`Failed to resolve directory for session ${sessionId}:`, error) @@ -196,9 +250,10 @@ export class SessionPermissionModeService { private async autoAcceptRequest( sessionID: string, requestID: string, + serverId: string, expectedRootSessionId?: string, ): Promise { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionID) + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionID, serverId) if (!rootSessionId) { return } @@ -207,25 +262,27 @@ export class SessionPermissionModeService { return } - if (this.effectiveModeForRoot(sessionID, rootSessionId).mode !== 'auto') { + if (this.effectiveModeForRoot(sessionID, rootSessionId, serverId).mode !== 'auto') { return } - await this.replyOnce(sessionID, requestID) + await this.replyOnce(sessionID, requestID, serverId) } - private async replyOnce(sessionID: string, requestID: string): Promise { + private async replyOnce(sessionID: string, requestID: string, serverId: string): Promise { try { - await this.openCodeClient.api.permission.reply({ sessionID, requestID, decision: 'once' }) + await this.clients + .getClient(serverId) + .api.permission.reply({ sessionID, requestID, decision: 'once' }) logger.info(`Auto-accepted permission request ${requestID} for session ${sessionID}`) } catch (error) { logger.error(`Failed to auto-accept permission request ${requestID} for session ${sessionID}:`, error) } } - private async resolveRootSessionIdOrNull(sessionId: string): Promise { + private async resolveRootSessionIdOrNull(sessionId: string, serverId: string): Promise { try { - return await this.ancestry.resolveRootSessionId(sessionId) + return await this.ancestry.resolveRootSessionId(sessionId, serverId) } catch { return null } diff --git a/backend/src/services/session-reply.ts b/backend/src/services/session-reply.ts index 1b6920162..6118170f2 100644 --- a/backend/src/services/session-reply.ts +++ b/backend/src/services/session-reply.ts @@ -5,6 +5,7 @@ import { type SessionMessageAssistant, type SessionMessageInfo, } from '@opencode-manager/shared/opencode' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { truncateText } from '../utils/text-truncate' import type { OpenCodeClient } from './opencode/client' import { sseAggregator, type SSEEvent } from './sse-aggregator' @@ -47,7 +48,12 @@ export function sessionSettleSignal(event: SSEEvent, sessionId: string): Session } } -export function waitForSessionSettled(client: OpenCodeClient, sessionId: string, timeoutMs: number): Promise { +export function waitForSessionSettled( + client: OpenCodeClient, + sessionId: string, + timeoutMs: number, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { if (timeoutMs <= 0) { return Promise.resolve() } @@ -69,7 +75,10 @@ export function waitForSessionSettled(client: OpenCodeClient, sessionId: string, resolve() } - unsubscribe = sseAggregator.onEvent((_directory, event) => { + unsubscribe = sseAggregator.onServerEvent((eventServerId, _directory, event) => { + if (eventServerId !== serverId) { + return + } if (sessionSettleSignal(event, sessionId)) { finish() } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 097b24459..5e75e9d59 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -19,6 +19,9 @@ import { type SSEClientCallback = (event: string, data: string) => void type SSEClientFrameWriter = (frame: Uint8Array) => void type SSEEventListener = (directory: string | null, event: SSEEvent) => void +type ServerEventListener = (serverId: string, directory: string | null, event: SSEEvent) => void +type ServerConnectedListener = (serverId: string) => void +type RemoteSubagentResolver = (serverId: string, sessionId: string) => boolean export type RemoteReplayHandler = (clientId: string, serverId: string, directory: string) => void | Promise @@ -29,6 +32,7 @@ interface SSEClient { directories: Set visible: boolean activeSessionId: string | null + activeServerId: string | null } export type SSEEvent = V2Event @@ -57,7 +61,10 @@ class SSEAggregator { private activeSessions: Map> = new Map() private sessionDirectories: Map = new Map() private eventListeners: Set = new Set() + private serverEventListeners: Set = new Set() private upstreamConnectedListeners: Set<() => void> = new Set() + private serverConnectedListeners: Set = new Set() + private remoteSubagentResolver: RemoteSubagentResolver | null = null private subagentSessions: Map> = new Map() private upstream: EventSource | null = null private reconnectTimeout: ReturnType | null = null @@ -125,7 +132,8 @@ class SSEAggregator { writeFrame, directories: new Set(directories), visible: false, - activeSessionId: null + activeSessionId: null, + activeServerId: null } this.clients.set(id, client) directories.forEach(dir => this.indexClientDirectory(id, dir)) @@ -405,6 +413,7 @@ class SSEAggregator { this.upstreamConnectedListeners.forEach(listener => { try { listener() } catch { /* ignore listener errors */ } }) + this.notifyServerConnected(LOCAL_OPENCODE_SERVER_ID) } private broadcastResync(): void { @@ -425,11 +434,37 @@ class SSEAggregator { return () => { this.eventListeners.delete(listener) } } + onServerEvent(listener: ServerEventListener): () => void { + this.serverEventListeners.add(listener) + return () => { this.serverEventListeners.delete(listener) } + } + onUpstreamConnected(listener: () => void): () => void { this.upstreamConnectedListeners.add(listener) return () => { this.upstreamConnectedListeners.delete(listener) } } + onServerConnected(listener: ServerConnectedListener): () => void { + this.serverConnectedListeners.add(listener) + return () => { this.serverConnectedListeners.delete(listener) } + } + + notifyServerEvent(serverId: string, directory: string | null, event: SSEEvent): void { + this.serverEventListeners.forEach(listener => { + try { listener(serverId, directory, event) } catch { /* ignore listener errors */ } + }) + } + + notifyServerConnected(serverId: string): void { + this.serverConnectedListeners.forEach(listener => { + try { listener(serverId) } catch { /* ignore listener errors */ } + }) + } + + setRemoteSubagentResolver(resolver: RemoteSubagentResolver | null): void { + this.remoteSubagentResolver = resolver + } + private handleUpstreamMessage(data: string): void { const routed = parseOpenCodeEvent(data, this.sessionDirectories) if (!routed) return @@ -481,14 +516,20 @@ class SSEAggregator { this.eventListeners.forEach(listener => { try { listener(directory, event) } catch { /* ignore listener errors */ } }) + this.notifyServerEvent(LOCAL_OPENCODE_SERVER_ID, directory, event) } - private writeEnvelopeToClients(clientIds: Iterable, directory: string | null, payloadJson: string): void { + private writeEnvelopeToClients( + clientIds: Iterable, + directory: string | null, + payloadJson: string, + serverId?: string, + ): void { let frame: Uint8Array | null = null for (const clientId of clientIds) { const client = this.clients.get(clientId) if (!client) continue - frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson)) + frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) this.writeFrame(client, frame, 'Failed to send to client') } } @@ -506,16 +547,7 @@ class SSEAggregator { } deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void { - const clientIds = directory === null - ? this.clientIdsForServer(serverId) - : Array.from(this.directoryClients.get(buildServerDirectoryKey(serverId, directory)) ?? []) - if (clientIds.length === 0) return - const frame = encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) - for (const clientId of clientIds) { - const client = this.clients.get(clientId) - if (!client) continue - this.writeFrame(client, frame, 'Failed to send remote event to client') - } + this.writeEnvelopeToClients(this.clients.keys(), directory, payloadJson, serverId) } deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void { @@ -639,7 +671,12 @@ class SSEAggregator { return this.clients.size } - setClientVisibility(id: string, visible: boolean, activeSessionId: string | null = null): boolean { + setClientVisibility( + id: string, + visible: boolean, + activeSessionId: string | null = null, + activeServerId: string | null = null + ): boolean { const client = this.clients.get(id) if (!client) { logger.warn(`setClientVisibility: client ${id} not found`) @@ -647,19 +684,23 @@ class SSEAggregator { } client.visible = visible client.activeSessionId = visible ? activeSessionId : null + client.activeServerId = visible ? (activeServerId ?? LOCAL_OPENCODE_SERVER_ID) : null return true } - isSessionBeingViewed(sessionId: string): boolean { + isSessionBeingViewed(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): boolean { for (const client of this.clients.values()) { - if (client.visible && client.activeSessionId === sessionId) { + if (client.visible && client.activeSessionId === sessionId && client.activeServerId === serverId) { return true } } return false } - isSubagentSession(sessionId: string): boolean { + isSubagentSession(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): boolean { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) { + return this.remoteSubagentResolver?.(serverId, sessionId) ?? false + } for (const sessions of this.subagentSessions.values()) { if (sessions.has(sessionId)) { return true @@ -707,7 +748,10 @@ class SSEAggregator { this.directoryClients.clear() this.clients.clear() this.eventListeners.clear() + this.serverEventListeners.clear() this.upstreamConnectedListeners.clear() + this.serverConnectedListeners.clear() + this.remoteSubagentResolver = null this.remoteReplayHandler = null } diff --git a/backend/src/services/terminal.ts b/backend/src/services/terminal.ts index d32f42fdd..d96def17e 100644 --- a/backend/src/services/terminal.ts +++ b/backend/src/services/terminal.ts @@ -23,6 +23,12 @@ export interface TerminalSize { rows: number } +export interface TerminalTarget { + client: OpenCodeClient + baseUrl: () => string + includeLocalCredentials: boolean +} + export class TerminalNotFoundError extends Error { constructor(ptyID: string) { super(`Terminal not found: ${ptyID}`) @@ -32,24 +38,30 @@ export class TerminalNotFoundError extends Error { export class TerminalService { constructor( - private readonly openCodeClient: OpenCodeClient, + private readonly target: TerminalTarget, private readonly credentialProvider: CredentialProvider, - private readonly upstreamBaseUrl: () => string, private readonly openSocket: (url: string) => WebSocket = (url) => new WebSocket(url), ) {} + forTarget(target: TerminalTarget): TerminalService { + return new TerminalService(target, this.credentialProvider, this.openSocket) + } + async list(directory: string): Promise { - const result = await this.openCodeClient.api.pty.list({ location: { directory } }) + const result = await this.target.client.api.pty.list({ location: { directory } }) return result.data.map((pty) => this.toTerminalInfo(pty)) } async create(directory: string, input: CreateTerminalInput): Promise { - const result = await this.openCodeClient.api.pty.create({ + const result = await this.target.client.api.pty.create({ location: { directory }, title: formatTerminalTitle({ kind: input.kind, name: input.name, actionId: input.actionId }), ...(input.command !== undefined ? { command: input.command } : {}), ...(input.args !== undefined ? { args: input.args } : {}), - env: { ...this.credentialProvider.getGhCliEnv({ cwd: directory }), ...input.env }, + env: { + ...(this.target.includeLocalCredentials ? this.credentialProvider.getGhCliEnv({ cwd: directory }) : {}), + ...input.env, + }, }) return this.toTerminalInfo(result.data) } @@ -64,11 +76,11 @@ export class TerminalService { } async resize(directory: string, ptyID: string, size: TerminalSize): Promise { - await this.openCodeClient.api.pty.update({ ptyID, location: { directory }, size }) + await this.target.client.api.pty.update({ ptyID, location: { directory }, size }) } async remove(directory: string, ptyID: string): Promise { - await this.openCodeClient.api.pty.remove({ ptyID, location: { directory } }) + await this.target.client.api.pty.remove({ ptyID, location: { directory } }) } async removeAll(directory: string): Promise { @@ -92,13 +104,13 @@ export class TerminalService { ): Promise { await this.requireTerminal(directory, ptyID) - const result = await this.openCodeClient.api.pty.connect.token({ + const result = await this.target.client.api.pty.connect.token({ ptyID, location: { directory }, 'x-opencode-ticket': '1', }) - const base = new URL(this.upstreamBaseUrl()) + const base = new URL(this.target.baseUrl()) if (!base.pathname.endsWith('/')) base.pathname += '/' const socketUrl = new URL(`api/pty/${encodeURIComponent(ptyID)}/connect`, base) socketUrl.searchParams.set('ticket', result.data.ticket) @@ -111,7 +123,7 @@ export class TerminalService { private async removePersistentTerminals(directory: string): Promise { let cursor: string | undefined do { - const page = await this.openCodeClient.api.session.list({ directory, cursor }) + const page = await this.target.client.api.session.list({ directory, cursor }) for (const session of page.data) { await this.removeSessionPersistentTerminals(session.id) } @@ -122,7 +134,7 @@ export class TerminalService { private async removeSessionPersistentTerminals(sessionID: string): Promise { let terminals: OpenCodePersistentPty[] try { - terminals = await this.openCodeClient.api.experimental.persistentPty.list({ sessionID }) + terminals = await this.target.client.api.experimental.persistentPty.list({ sessionID }) } catch (error: unknown) { if (openCodeErrorStatus(error) === 404) return throw error @@ -130,7 +142,7 @@ export class TerminalService { for (const terminal of terminals) { try { - await this.openCodeClient.api.experimental.persistentPty.remove({ ptyID: terminal.id }) + await this.target.client.api.experimental.persistentPty.remove({ ptyID: terminal.id }) } catch (error: unknown) { if (openCodeErrorStatus(error) === 404) continue throw error diff --git a/backend/src/utils/terminal-cursor.ts b/backend/src/utils/terminal-cursor.ts new file mode 100644 index 000000000..dd57cc701 --- /dev/null +++ b/backend/src/utils/terminal-cursor.ts @@ -0,0 +1,7 @@ +export function parseTerminalCursor(raw: string | undefined): number | undefined | null { + if (raw === undefined) return undefined + if (!/^-?\d+$/.test(raw)) return null + const parsed = Number(raw) + if (!Number.isSafeInteger(parsed) || parsed < -1) return null + return parsed +} diff --git a/backend/test/db/multi-run-fusions.test.ts b/backend/test/db/multi-run-fusions.test.ts index 24aa281f5..dd0bbcae6 100644 --- a/backend/test/db/multi-run-fusions.test.ts +++ b/backend/test/db/multi-run-fusions.test.ts @@ -53,7 +53,7 @@ describe('multi-run fusions', () => { function createRun(models = ['openai/a', 'openai/b']): number { const run = createMultiRunWithEntries( db, - { repoId: REPO_ID, name: 'Sweep', prompt: 'go', isolated: true, baseRef: null }, + { repoId: REPO_ID, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: true, baseRef: null }, models, ) return run.id @@ -167,7 +167,7 @@ describe('multi-run fusions', () => { truncated: false, }) - const listed = listMultiRuns(db, REPO_ID, 20) + const listed = listMultiRuns(db, { kind: 'repo', repoId: REPO_ID }, 20) expect(listed[0]?.fusions).toHaveLength(1) expect(listed[0]?.fusions[0]?.sources).toHaveLength(2) }) @@ -175,7 +175,7 @@ describe('multi-run fusions', () => { it('returns an empty fusion list for runs without fusions', () => { const multiRunId = createRun() expect(getMultiRun(db, multiRunId)?.fusions).toEqual([]) - expect(listMultiRuns(db, REPO_ID, 20)[0]?.fusions).toEqual([]) + expect(listMultiRuns(db, { kind: 'repo', repoId: REPO_ID }, 20)[0]?.fusions).toEqual([]) }) it('reads a fusion by its request id', () => { diff --git a/backend/test/db/multi-run-targets-migration.test.ts b/backend/test/db/multi-run-targets-migration.test.ts new file mode 100644 index 000000000..3481baa38 --- /dev/null +++ b/backend/test/db/multi-run-targets-migration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import migration from '../../src/db/migrations/202610181310-multi-run-targets' +import { ensureMultiRunTables } from '../../src/db/multi-runs' + +interface ColumnInfo { + name: string + notnull: number + dflt_value: string | null +} + +function createLegacyTable(db: Database): void { + db.run(` + CREATE TABLE multi_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL + ) + `) + db.run('CREATE INDEX idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') +} + +function columns(db: Database): ColumnInfo[] { + return db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] +} + +describe('202610181310-multi-run-targets migration', () => { + let db: Database + + afterEach(() => { + db.close() + }) + + it('makes repo_id nullable and adds the server target columns', () => { + db = new Database(':memory:') + createLegacyTable(db) + + migration.up(db) + + const byName = new Map(columns(db).map((column) => [column.name, column])) + expect(byName.get('repo_id')?.notnull).toBe(0) + expect(byName.get('server_id')).toMatchObject({ notnull: 1, dflt_value: "'local'" }) + expect(byName.has('directory')).toBe(true) + }) + + it('preserves existing rows and their ids', () => { + db = new Database(':memory:') + createLegacyTable(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, name, prompt, isolated, base_ref, created_at) + VALUES(7, 3, 'Sweep', 'go', 1, 'main', 1700000000000)`, + ) + + migration.up(db) + + expect( + db.prepare('SELECT id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at FROM multi_runs').get(), + ).toEqual({ + id: 7, + repo_id: 3, + server_id: 'local', + directory: null, + name: 'Sweep', + prompt: 'go', + isolated: 1, + base_ref: 'main', + created_at: 1700000000000, + }) + }) + + it('recreates the repo and server indexes', () => { + db = new Database(':memory:') + createLegacyTable(db) + + migration.up(db) + + const indexes = ( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'multi_runs'").all() as Array<{ + name: string + }> + ).map((index) => index.name) + expect(indexes).toEqual(expect.arrayContaining(['idx_multi_runs_repo', 'idx_multi_runs_server'])) + }) + + it('is idempotent', () => { + db = new Database(':memory:') + createLegacyTable(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, name, prompt, isolated, base_ref, created_at) + VALUES(1, 2, 'Sweep', 'go', 0, NULL, 1)`, + ) + + migration.up(db) + migration.up(db) + + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get()).toEqual({ count: 1 }) + expect(columns(db).filter((column) => column.name === 'server_id')).toHaveLength(1) + }) + + it('skips a table that already has the new schema', () => { + db = new Database(':memory:') + ensureMultiRunTables(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(4, NULL, 'srv_kitchen', '/srv/app', 'Remote sweep', 'go', 1, NULL, 2)`, + ) + + migration.up(db) + + expect(db.prepare('SELECT id, repo_id, server_id, directory FROM multi_runs').get()).toEqual({ + id: 4, + repo_id: null, + server_id: 'srv_kitchen', + directory: '/srv/app', + }) + }) + + it('drops remote rows and restores the repo-only schema on down', () => { + db = new Database(':memory:') + ensureMultiRunTables(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(1, 5, 'local', NULL, 'Local', 'go', 0, NULL, 1), + (2, NULL, 'srv_kitchen', '/srv/app', 'Remote', 'go', 0, NULL, 2)`, + ) + + migration.down(db) + + expect(db.prepare('SELECT id, repo_id FROM multi_runs').all()).toEqual([{ id: 1, repo_id: 5 }]) + expect(columns(db).map((column) => column.name)).toEqual([ + 'id', + 'repo_id', + 'name', + 'prompt', + 'isolated', + 'base_ref', + 'created_at', + ]) + }) +}) diff --git a/backend/test/db/schedule-remote-targets.test.ts b/backend/test/db/schedule-remote-targets.test.ts new file mode 100644 index 000000000..8d0a66be6 --- /dev/null +++ b/backend/test/db/schedule-remote-targets.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import migration from '../../src/db/migrations/202610181320-remote-schedule-targets' + +interface ColumnInfo { + name: string + notnull: number +} + +const MIGRATION_ID = '202610181320-remote-schedule-targets' + +function columns(db: Database, table: string): ColumnInfo[] { + return db.prepare(`PRAGMA table_info(${table})`).all() as ColumnInfo[] +} + +describe('remote schedule target migration', () => { + it('preserves jobs and runs while making repo_id nullable', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + migrate(db, allMigrations.filter((entry) => entry.id !== MIGRATION_ID)) + + const now = Date.now() + db.exec( + `INSERT INTO repos (id, repo_url, local_path, branch, default_branch, clone_status, cloned_at) + VALUES (1, 'https://github.com/test/my-repo', 'repos/my-repo', 'main', 'main', 'ready', ${now})`, + ) + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, name, enabled, schedule_mode, prompt, workspace_mode, created_at, updated_at) + VALUES (7, 1, 'Local job', 1, 'interval', 'Run it', 'shared-worktree', ?, ?)`, + ).run(now, now) + db.prepare( + `INSERT INTO schedule_runs (id, job_id, repo_id, trigger_source, status, started_at, created_at, session_id) + VALUES (5, 7, 1, 'manual', 'completed', ?, ?, 'ses_local')`, + ).run(now, now) + + migration.up(db) + + const jobColumns = columns(db, 'schedule_jobs') + expect(jobColumns.find((column) => column.name === 'repo_id')?.notnull).toBe(0) + expect(jobColumns.find((column) => column.name === 'server_id')?.notnull).toBe(1) + expect(jobColumns.some((column) => column.name === 'directory')).toBe(true) + + const runColumns = columns(db, 'schedule_runs') + expect(runColumns.find((column) => column.name === 'repo_id')?.notnull).toBe(0) + expect(runColumns.some((column) => column.name === 'server_id')).toBe(true) + + const job = db.prepare('SELECT * FROM schedule_jobs WHERE id = 7').get() as Record + expect(job).toMatchObject({ + repo_id: 1, + server_id: 'local', + directory: null, + name: 'Local job', + workspace_mode: 'shared-worktree', + }) + + const run = db.prepare('SELECT * FROM schedule_runs WHERE id = 5').get() as Record + expect(run).toMatchObject({ repo_id: 1, server_id: 'local', session_id: 'ses_local', status: 'completed' }) + + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, server_id, directory, name, enabled, schedule_mode, prompt, created_at, updated_at) + VALUES (8, NULL, 'srv_kitchen', '/srv/project', 'Remote job', 1, 'interval', 'Run remotely', ?, ?)`, + ).run(now, now) + db.prepare( + `INSERT INTO schedule_runs (id, job_id, repo_id, server_id, trigger_source, status, started_at, created_at) + VALUES (6, 8, NULL, 'srv_kitchen', 'manual', 'running', ?, ?)`, + ).run(now, now) + + expect(db.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()).toEqual({ count: 2 }) + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) + + it('is idempotent', () => { + const db = new Database(':memory:') + migrate(db, allMigrations) + + const now = Date.now() + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, server_id, directory, name, enabled, schedule_mode, prompt, created_at, updated_at) + VALUES (3, NULL, 'srv_kitchen', '/srv/project', 'Remote job', 1, 'interval', 'Run remotely', ?, ?)`, + ).run(now, now) + + migration.up(db) + + expect(db.prepare('SELECT COUNT(*) AS count FROM schedule_jobs').get()).toEqual({ count: 1 }) + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) +}) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 3a2ded491..7f8e51e6c 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -66,7 +66,7 @@ describe('schedule database queries', () => { const jobs = schedulesDb.listScheduleJobsByRepo(mockDb, 42) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') expect(jobs[0]).toMatchObject({ id: 7, repoId: 42, @@ -86,7 +86,7 @@ describe('schedule database queries', () => { const jobIds = schedulesDb.listScheduleJobIdsByRepo(mockDb, 42) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT id FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT id FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') expect(stmt.all).toHaveBeenCalledWith(42) expect(jobIds).toEqual([7, 8]) }) @@ -122,6 +122,8 @@ describe('schedule database queries', () => { expect(insertStmt.run).toHaveBeenCalledWith( 42, + 'local', + null, 'Weekly engineering summary', 'Summarize repo health and recent changes.', 1, @@ -196,6 +198,7 @@ describe('schedule database queries', () => { null, null, 'worktree', + null, expect.any(Number), null, 42, @@ -222,9 +225,9 @@ describe('schedule database queries', () => { const deleted = schedulesDb.deleteScheduleJob(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ?') expect(deleteRunsStmt.run).toHaveBeenCalledWith(42, 7) - expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(deleteJobStmt.run).toHaveBeenCalledWith(42, 7) expect(deleted).toBe(true) }) @@ -341,7 +344,7 @@ describe('schedule database queries', () => { createdAt: Date.UTC(2026, 2, 9, 12, 0, 0), }) - expect(insertStmt.run).toHaveBeenCalledWith(7, 42, 'manual', 'running', expect.any(Number), expect.any(Number)) + expect(insertStmt.run).toHaveBeenCalledWith(7, 42, 'local', 'manual', 'running', expect.any(Number), expect.any(Number)) expect(run.id).toBe(5) }) @@ -416,7 +419,7 @@ describe('schedule database queries', () => { const job = schedulesDb.getScheduleJobById(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(stmt.get).toHaveBeenCalledWith(42, 7) expect(job).toMatchObject({ id: 7, repoId: 42, name: 'Weekly engineering summary', branch: null }) }) @@ -451,7 +454,7 @@ describe('schedule database queries', () => { const result = schedulesDb.deleteScheduleJob(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(42, 7) expect(result).toBe(true) }) @@ -477,7 +480,7 @@ describe('schedule database queries', () => { schedulesDb.updateScheduleJobRunState(mockDb, 42, 7, { lastRunAt, nextRunAt }) - expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id IS ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(lastRunAt, nextRunAt, expect.any(Number), 42, 7) }) @@ -525,7 +528,7 @@ describe('schedule database queries', () => { const run = schedulesDb.getScheduleRunById(mockDb, 42, 7, 5) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') expect(stmt.get).toHaveBeenCalledWith(42, 7, 5) expect(run).toMatchObject({ id: 5, jobId: 7, repoId: 42, status: 'running', runBranch: null, commitHash: null, worktreePath: null }) }) @@ -541,6 +544,33 @@ describe('schedule database queries', () => { expect(run).toBeNull() }) + it('scopes the run lookup by session id and server id', () => { + const stmt = { + get: vi.fn().mockReturnValue(makeRunRow({ server_id: 'srv_kitchen' })), + } + mockDb.prepare.mockReturnValue(stmt) + + const run = schedulesDb.getScheduleRunBySessionId(mockDb, 'ses-1', 'srv_kitchen') + + expect(mockDb.prepare).toHaveBeenCalledWith( + 'SELECT * FROM schedule_runs WHERE server_id = ? AND session_id = ? ORDER BY started_at DESC LIMIT 1', + ) + expect(stmt.get).toHaveBeenCalledWith('srv_kitchen', 'ses-1') + expect(run).toMatchObject({ id: 5, sessionId: 'ses-1' }) + }) + + it('defaults the run lookup to the local server', () => { + const stmt = { + get: vi.fn().mockReturnValue(undefined), + } + mockDb.prepare.mockReturnValue(stmt) + + const run = schedulesDb.getScheduleRunBySessionId(mockDb, 'ses-1') + + expect(stmt.get).toHaveBeenCalledWith('local', 'ses-1') + expect(run).toBeNull() + }) + it('lists all runs with context and no filters', () => { const row = { ...makeRunRow(), @@ -629,7 +659,7 @@ describe('schedule database queries', () => { expect(sql).toContain("sj.name LIKE ? ESCAPE '\\'") expect(sql).toContain("r.local_path LIKE ? ESCAPE '\\'") const pattern = '%50\\%\\_off%' - expect(stmt.all).toHaveBeenCalledWith(pattern, pattern, pattern, pattern, pattern, pattern, 50, 0) + expect(stmt.all).toHaveBeenCalledWith(pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, 50, 0) }) it('ignores a blank search term', () => { @@ -696,7 +726,7 @@ describe('schedule database queries', () => { const deleted = schedulesDb.deleteScheduleRunById(mockDb, 42, 7, 5) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(42, 7, 5) expect(deleted).toBe(true) }) @@ -707,7 +737,7 @@ describe('schedule database queries', () => { const cleared = schedulesDb.deleteScheduleRunsByIds(mockDb, 42, 7, [3, 1]) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id IN (?, ?)') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id IN (?, ?)') expect(stmt.run).toHaveBeenCalledWith(42, 7, 3, 1) expect(cleared).toBe(2) }) diff --git a/backend/test/db/session-server-scope-migration.test.ts b/backend/test/db/session-server-scope-migration.test.ts new file mode 100644 index 000000000..2025aed30 --- /dev/null +++ b/backend/test/db/session-server-scope-migration.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import migration from '../../src/db/migrations/202610181300-session-server-scope' +import { getSessionGoalById, insertSessionGoal } from '../../src/db/session-goals' +import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createLegacyTables(db: Database): void { + db.run(` + CREATE TABLE session_permission_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + db.run(` + CREATE TABLE session_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + directory TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), + stop_reason TEXT, + turn_state TEXT NOT NULL DEFAULT 'waiting', + continuation_count INTEGER NOT NULL DEFAULT 0, + max_continuations INTEGER NOT NULL, + token_budget INTEGER, + tokens_at_start INTEGER NOT NULL DEFAULT 0, + tokens_used INTEGER NOT NULL DEFAULT 0, + consecutive_blocked INTEGER NOT NULL DEFAULT 0, + last_verdict TEXT, + last_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + db.run(` + CREATE UNIQUE INDEX idx_session_goals_open + ON session_goals(session_id) + WHERE status IN ('active','paused') + `) +} + +function tableColumns(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((column) => column.name) +} + +function primaryKeyColumns(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string; pk: number }>) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name) +} + +function openGoalIndexColumns(db: Database): string[] { + return (db.prepare("PRAGMA index_info('idx_session_goals_open')").all() as Array<{ name: string }>).map( + (column) => column.name, + ) +} + +function countRows(db: Database, table: string): number { + return (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count +} + +const GOAL_INPUT = { + sessionId: 'ses_shared', + directory: '/repo', + objective: 'Ship it', + maxContinuations: 1, + tokenBudget: null, + tokensAtStart: 0, +} + +describe('202610181300 session server scope migration', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('adds server_id to both session tables on a fresh database', () => { + expect(tableColumns(db, 'session_permission_modes')).toContain('server_id') + expect(tableColumns(db, 'session_goals')).toContain('server_id') + expect(openGoalIndexColumns(db)).toEqual(['server_id', 'session_id']) + }) + + it('scopes the open goal index by server', () => { + const local = insertSessionGoal(db, GOAL_INPUT) + const remote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) + const duplicateRemote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) + + expect(local).not.toBeNull() + expect(remote).not.toBeNull() + expect(duplicateRemote).toBeNull() + }) + + it('keys permission modes by server and session', () => { + expect(primaryKeyColumns(db, 'session_permission_modes')).toEqual(['server_id', 'session_id']) + + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + + expect(getSessionPermissionMode(db, 'ses_shared')).toBe('auto') + expect(getSessionPermissionMode(db, 'ses_shared', 'srv_x')).toBe('ask') + expect(countRows(db, 'session_permission_modes')).toBe(2) + }) + + it('updates only the mode of the matching server and session', () => { + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + setSessionPermissionMode(db, 'ses_shared', 'ask') + + expect(getSessionPermissionMode(db, 'ses_shared')).toBe('ask') + expect(getSessionPermissionMode(db, 'ses_shared', 'srv_x')).toBe('ask') + expect(countRows(db, 'session_permission_modes')).toBe(2) + }) + + it('applies without the session_goals table', () => { + const standaloneDb = new Database(':memory:') + standaloneDb.run(` + CREATE TABLE session_permission_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + + expect(() => migration.up(standaloneDb)).not.toThrow() + expect(primaryKeyColumns(standaloneDb, 'session_permission_modes')).toEqual(['server_id', 'session_id']) + + standaloneDb.close() + }) + + it('removes remote rows and restores session-scoped keys when rolled back', () => { + insertSessionGoal(db, GOAL_INPUT) + insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_x' }) + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + + expect(() => migration.down(db)).not.toThrow() + + expect(openGoalIndexColumns(db)).toEqual(['session_id']) + expect(tableColumns(db, 'session_goals')).not.toContain('server_id') + expect(primaryKeyColumns(db, 'session_permission_modes')).toEqual(['session_id']) + expect(countRows(db, 'session_goals')).toBe(1) + expect(countRows(db, 'session_permission_modes')).toBe(1) + expect(db.prepare('SELECT session_id, mode FROM session_permission_modes').all()).toEqual([ + { session_id: 'ses_shared', mode: 'auto' }, + ]) + }) + + it('upgrades legacy tables, keeps existing rows local and tolerates a second run', () => { + const legacyDb = new Database(':memory:') + createLegacyTables(legacyDb) + legacyDb + .prepare('INSERT INTO session_permission_modes(session_id, mode, updated_at) VALUES(?,?,?)') + .run('ses_root', 'auto', Date.now()) + legacyDb + .prepare(` + INSERT INTO session_goals(session_id, directory, objective, status, turn_state, max_continuations, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?) + `) + .run('ses_root', '/repo', 'Ship it', 'active', 'waiting', 1, Date.now(), Date.now()) + const legacyGoalId = (legacyDb.prepare('SELECT id FROM session_goals LIMIT 1').get() as { id: number }).id + + migration.up(legacyDb) + setSessionPermissionMode(legacyDb, 'ses_root', 'ask', 'srv_x') + migration.up(legacyDb) + + expect(tableColumns(legacyDb, 'session_permission_modes')).toContain('server_id') + expect(tableColumns(legacyDb, 'session_goals')).toContain('server_id') + expect(openGoalIndexColumns(legacyDb)).toEqual(['server_id', 'session_id']) + expect(primaryKeyColumns(legacyDb, 'session_permission_modes')).toEqual(['server_id', 'session_id']) + expect(getSessionPermissionMode(legacyDb, 'ses_root')).toBe('auto') + expect(getSessionPermissionMode(legacyDb, 'ses_root', 'srv_x')).toBe('ask') + expect(countRows(legacyDb, 'session_permission_modes')).toBe(2) + expect(getSessionGoalById(legacyDb, legacyGoalId)).toMatchObject({ serverId: 'local' }) + + legacyDb.close() + }) +}) diff --git a/backend/test/helpers/raw-websocket-handshake.ts b/backend/test/helpers/raw-websocket-handshake.ts new file mode 100644 index 000000000..1f9fa5a28 --- /dev/null +++ b/backend/test/helpers/raw-websocket-handshake.ts @@ -0,0 +1,42 @@ +import { randomBytes } from 'node:crypto' +import { request as httpRequest } from 'node:http' + +export interface RawHandshakeResult { + upgraded: boolean + status?: number +} + +export function rawWebSocketHandshake(options: { + port: number + path: string + origin?: string + cookie?: string +}): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), + 'Sec-WebSocket-Version': '13', + } + if (options.origin !== undefined) headers.Origin = options.origin + if (options.cookie) headers.Cookie = options.cookie + + const request = httpRequest({ host: '127.0.0.1', port: options.port, path: options.path, method: 'GET', headers }) + let settled = false + request.on('upgrade', (_res, socket) => { + settled = true + socket.destroy() + resolve({ upgraded: true }) + }) + request.on('response', (res) => { + settled = true + res.resume() + resolve({ upgraded: false, status: res.statusCode }) + }) + request.on('error', (error) => { + if (!settled) reject(error) + }) + request.end() + }) +} diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index 2b4fa3cdb..2772bab6f 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -122,11 +122,16 @@ vi.mock('../src/services/skills', () => ({ const sseAggregatorMock = vi.hoisted(() => ({ onEvent: vi.fn(), + onServerEvent: vi.fn(), onUpstreamConnected: vi.fn(), + onServerConnected: vi.fn(), setPendingActionsFetcher: vi.fn(), setPasswordResolver: vi.fn(), setScheduledSessionsResolver: vi.fn(), setRemoteReplayHandler: vi.fn(), + setRemoteSubagentResolver: vi.fn(), + notifyServerEvent: vi.fn(), + notifyServerConnected: vi.fn(), start: vi.fn(), shutdown: vi.fn(), reconnect: vi.fn(), diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts index abe5ac39c..f55815a88 100644 --- a/backend/test/routes/internal-multi-runs.test.ts +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -20,6 +20,8 @@ function createRun(overrides: Record = {}) { return { id: 1, repoId: 1, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -69,7 +71,7 @@ describe('internal-multi-runs routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ runs: [] }) - expect(multiRuns.list).toHaveBeenCalledWith(1) + expect(multiRuns.list).toHaveBeenCalledWith({ kind: 'repo', repoId: 1 }) }) it('POST /api/internal/multi-runs/3/entries/4/discard discards the entry', async () => { diff --git a/backend/test/routes/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts index 2287153f8..b0b0500c5 100644 --- a/backend/test/routes/multi-runs.test.ts +++ b/backend/test/routes/multi-runs.test.ts @@ -8,6 +8,8 @@ function createRun(overrides: Partial = {}): MultiRun { return { id: 1, repoId: 1, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -61,7 +63,27 @@ describe('multi-run routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ runs: [createRun()] }) - expect(service.list).toHaveBeenCalledWith(1) + expect(service.list).toHaveBeenCalledWith({ kind: 'repo', repoId: 1 }) + }) + + it('GET returns the runs of a server directory', async () => { + service.list.mockReturnValue([]) + + const res = await app.request('/multi-runs?serverId=srv_kitchen&directory=%2Fsrv%2Fapp') + + expect(res.status).toBe(200) + expect(service.list).toHaveBeenCalledWith({ + kind: 'server', + serverId: 'srv_kitchen', + directory: '/srv/app', + }) + }) + + it('GET rejects a server target without a directory with 400', async () => { + const res = await app.request('/multi-runs?serverId=srv_kitchen') + + expect(res.status).toBe(400) + expect(service.list).not.toHaveBeenCalled() }) it('GET rejects a missing repoId with 400', async () => { @@ -91,6 +113,61 @@ describe('multi-run routes', () => { }) }) + it('POST launches a run for a server directory', async () => { + service.launch.mockResolvedValue(createRun({ repoId: null, serverId: 'srv_kitchen', directory: '/srv/app' })) + + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }), + }) + + expect(res.status).toBe(201) + expect(service.launch).toHaveBeenCalledWith({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + }) + + it('POST rejects a launch with both a repository and a server target', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody({ serverId: 'srv_kitchen', directory: '/srv/app' }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + + it('POST rejects a server target without a directory', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: 'srv_kitchen', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + it('POST rejects more than five models with 400', async () => { const res = await app.request('/multi-runs', { method: 'POST', diff --git a/backend/test/routes/opencode-server-terminal-socket.test.ts b/backend/test/routes/opencode-server-terminal-socket.test.ts new file mode 100644 index 000000000..e881b81fb --- /dev/null +++ b/backend/test/routes/opencode-server-terminal-socket.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' +import { Hono } from 'hono' +import { createNodeWebSocket } from '@hono/node-ws' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { + createOpenCodeServerTerminalSocketRoutes, + type ServerTerminalServiceResolver, +} from '../../src/routes/opencode-server-terminals' +import { TerminalService } from '../../src/services/terminal' +import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { CredentialProvider } from '../../src/services/credential-provider' +import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' +import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' + +const TRUSTED_ORIGIN = 'http://localhost:5003' +const SERVER_ID = 'srv_kitchen' +const UNKNOWN_SERVER_ID = 'srv_missing' +const DIRECTORY = '/home/me/app' + +const ptyList = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: [ + { id: 'pty-1', title: 'Terminal', command: 'sh', args: [], cwd: DIRECTORY, status: 'running' as const, pid: 1 }, + ], +})) +const connectToken = vi.fn(async () => ({ + location: { directory: DIRECTORY }, + data: { ticket: 'remote-ticket', expires_in: 60 }, +})) + +const remoteClient = { + api: { pty: { list: ptyList, connect: { token: connectToken } } }, +} as unknown as OpenCodeClient + +const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as CredentialProvider + +let upstream: WebSocketTestServer +let manager: WebSocketTestServer +let managerPort = 0 +let capturedUrl: URL | undefined + +const upstreamApp = new Hono() +const upstreamWs = createNodeWebSocket({ app: upstreamApp }) + +upstreamApp.get( + '/api/pty/:ptyID/connect', + upstreamWs.upgradeWebSocket((c) => { + capturedUrl = new URL(c.req.url) + return { + onMessage(event, ws) { + if (typeof event.data === 'string') ws.send(event.data) + }, + } + }), +) + +const managerApp = new Hono() +const managerWs = createNodeWebSocket({ app: managerApp }) + +const terminalService = new TerminalService( + { client: remoteClient, baseUrl: () => `http://127.0.0.1:${upstream.port}`, includeLocalCredentials: false }, + credentialProvider, +) + +const resolveTerminalService: ServerTerminalServiceResolver = (serverId) => { + if (serverId === UNKNOWN_SERVER_ID) throw new OpenCodeServerNotFoundError(serverId) + return terminalService +} + +managerApp.route( + '/', + createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, managerWs.upgradeWebSocket, [TRUSTED_ORIGIN]), +) + +function clientUrl(ptyID: string, directory = DIRECTORY, cursor?: number): string { + const url = new URL(`ws://127.0.0.1:${managerPort}/${SERVER_ID}/terminals/${ptyID}/connect`) + url.searchParams.set('directory', directory) + if (cursor !== undefined) url.searchParams.set('cursor', String(cursor)) + return url.toString() +} + +function openEvent(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState === WebSocket.OPEN) { + resolve() + return + } + socket.addEventListener('open', () => resolve(), { once: true }) + socket.addEventListener('error', () => reject(new Error('WebSocket error before open')), { once: true }) + }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting for condition') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +beforeAll(async () => { + upstream = await startWebSocketServer({ app: upstreamApp, injectWebSocket: upstreamWs.injectWebSocket }) + manager = await startWebSocketServer({ app: managerApp, injectWebSocket: managerWs.injectWebSocket }) + managerPort = manager.port +}) + +afterAll(async () => { + upstreamWs.wss.close() + managerWs.wss.close() + await upstream.close() + await manager.close() +}) + +describe('OpenCode Server Terminal Socket Route', () => { + it('bridges to the remote base URL with a ticket, directory and cursor', async () => { + const socket = new WebSocket(clientUrl('pty-1', DIRECTORY, 3)) + socket.binaryType = 'arraybuffer' + const messages: Array = [] + socket.addEventListener('message', (event) => { + messages.push(event.data as string | ArrayBuffer) + }) + + await openEvent(socket) + socket.send('echo remote') + await waitFor(() => messages.includes('echo remote')) + + expect(ptyList).toHaveBeenCalledWith({ location: { directory: DIRECTORY } }) + expect(connectToken).toHaveBeenCalledWith({ + ptyID: 'pty-1', + location: { directory: DIRECTORY }, + 'x-opencode-ticket': '1', + }) + expect(capturedUrl?.searchParams.get('ticket')).toBe('remote-ticket') + expect(capturedUrl?.searchParams.get('location[directory]')).toBe(DIRECTORY) + expect(capturedUrl?.searchParams.get('cursor')).toBe('3') + + socket.close() + }) + + it('rejects an untrusted origin before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + origin: 'http://localhost:5004', + }) + + expect(outcome).toEqual({ upgraded: false, status: 403 }) + }) + + it('rejects the local server before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${LOCAL_OPENCODE_SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 400 }) + }) + + it('returns 404 for an unknown server before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${UNKNOWN_SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 404 }) + }) + + it('rejects a relative directory before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${SERVER_ID}/terminals/pty-1/connect?directory=relative/path`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 400 }) + }) +}) diff --git a/backend/test/routes/opencode-server-terminals.test.ts b/backend/test/routes/opencode-server-terminals.test.ts new file mode 100644 index 000000000..069b24a08 --- /dev/null +++ b/backend/test/routes/opencode-server-terminals.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from 'vitest' +import { Hono } from 'hono' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { + createOpenCodeServerTerminalRoutes, + type ServerTerminalServiceResolver, +} from '../../src/routes/opencode-server-terminals' +import { TerminalService } from '../../src/services/terminal' +import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { CredentialProvider } from '../../src/services/credential-provider' + +const DIRECTORY = '/home/me/app' +const SERVER_ID = 'srv_kitchen' +const UNKNOWN_SERVER_ID = 'srv_missing' +const jsonHeaders = { 'Content-Type': 'application/json' } + +function createPty(overrides: Record = {}) { + return { + id: 'pty-1', + title: 'Terminal', + command: 'sh', + args: [], + cwd: DIRECTORY, + status: 'running' as const, + pid: 1, + ...overrides, + } +} + +function createRemoteClient(terminals: Array> = [createPty()]) { + const list = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: terminals, + })) + const create = vi.fn(async (input: { location: { directory: string }; title: string; env: Record }) => ({ + location: input.location, + data: createPty({ id: 'pty-new', title: input.title }), + })) + const update = vi.fn(async () => ({ location: { directory: DIRECTORY }, data: createPty() })) + const remove = vi.fn(async () => undefined) + const client = { api: { pty: { list, create, update, remove } } } as unknown as OpenCodeClient + return { client, list, create, update, remove } +} + +function createService(includeLocalCredentials = false, terminals?: Array>) { + const remote = createRemoteClient(terminals) + const credentialProvider = { + getGhCliEnv: vi.fn(() => ({ GH_TOKEN: 'local-token' })), + } as unknown as CredentialProvider + const service = new TerminalService( + { client: remote.client, baseUrl: () => 'https://remote.internal', includeLocalCredentials }, + credentialProvider, + ) + return { service, credentialProvider, ...remote } +} + +function createResolver(service: TerminalService): ServerTerminalServiceResolver { + return (serverId) => { + if (serverId === UNKNOWN_SERVER_ID) throw new OpenCodeServerNotFoundError(serverId) + return service + } +} + +function createApp(service: TerminalService): Hono { + const app = new Hono() + app.route('/', createOpenCodeServerTerminalRoutes(createResolver(service))) + return app +} + +describe('OpenCode Server Terminal Routes', () => { + describe('GET /:serverId/terminals', () => { + it('lists the terminals of the remote directory', async () => { + const { service, list } = createService() + const res = await createApp(service).request( + `/${SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + terminals: [{ id: 'pty-1', title: 'Terminal', kind: 'shell', cwd: DIRECTORY, status: 'running' }], + }) + expect(list).toHaveBeenCalledWith({ location: { directory: DIRECTORY } }) + }) + + it('rejects the local server', async () => { + const { service } = createService() + const res = await createApp(service).request( + `/${LOCAL_OPENCODE_SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(400) + }) + + it('returns 404 for an unknown server', async () => { + const { service } = createService() + const res = await createApp(service).request( + `/${UNKNOWN_SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: `OpenCode server not found: ${UNKNOWN_SERVER_ID}` }) + }) + + it('requires an absolute directory', async () => { + const { service } = createService() + const app = createApp(service) + + for (const path of [`/${SERVER_ID}/terminals`, `/${SERVER_ID}/terminals?directory=relative/path`]) { + const res = await app.request(path) + expect(res.status).toBe(400) + } + }) + }) + + describe('POST /:serverId/terminals', () => { + it('creates a shell without local credentials', async () => { + const { service, create, credentialProvider } = createService() + const res = await createApp(service).request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, title: 'Remote shell' }), + }) + + expect(res.status).toBe(200) + expect(credentialProvider.getGhCliEnv).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledWith({ + location: { directory: DIRECTORY }, + title: 'Remote shell', + env: {}, + }) + expect(await res.json()).toEqual({ + id: 'pty-new', + title: 'Remote shell', + kind: 'shell', + cwd: DIRECTORY, + status: 'running', + }) + }) + + it('defaults the title and rejects a missing directory', async () => { + const { service, create } = createService() + const app = createApp(service) + + const created = await app.request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY }), + }) + expect(created.status).toBe(200) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ title: 'Terminal' })) + + const missing = await app.request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({}), + }) + expect(missing.status).toBe(400) + }) + }) + + describe('PATCH /:serverId/terminals/:ptyID', () => { + it('resizes a terminal of the remote server', async () => { + const { service, update } = createService() + const res = await createApp(service).request(`/${SERVER_ID}/terminals/pty-1`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, cols: 120, rows: 40 }), + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(update).toHaveBeenCalledWith({ + ptyID: 'pty-1', + location: { directory: DIRECTORY }, + size: { cols: 120, rows: 40 }, + }) + }) + + it('returns 404 for an unknown PTY id', async () => { + const { service } = createService(false, []) + const res = await createApp(service).request(`/${SERVER_ID}/terminals/missing`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, cols: 80, rows: 24 }), + }) + + expect(res.status).toBe(404) + }) + }) + + describe('DELETE /:serverId/terminals/:ptyID', () => { + it('removes a terminal of the remote server', async () => { + const { service, remove } = createService() + const res = await createApp(service).request( + `/${SERVER_ID}/terminals/pty-1?directory=${encodeURIComponent(DIRECTORY)}`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(remove).toHaveBeenCalledWith({ ptyID: 'pty-1', location: { directory: DIRECTORY } }) + }) + + it('returns 404 for an unknown PTY id', async () => { + const { service } = createService(false, []) + const res = await createApp(service).request( + `/${SERVER_ID}/terminals/missing?directory=${encodeURIComponent(DIRECTORY)}`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index 3531c55b4..9ec905a7f 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' -import { randomBytes } from 'node:crypto' -import { request as httpRequest } from 'node:http' import { Hono } from 'hono' import { cors } from 'hono/cors' import { createNodeWebSocket } from '@hono/node-ws' @@ -12,6 +10,7 @@ import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' +import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' vi.mock('../../src/services/repo', () => ({ resolveRepoOrAssistant: vi.fn(), @@ -79,7 +78,10 @@ upstreamApp.get( }), ) -const terminalService = new TerminalService(openCodeClient, credentialProvider, () => upstreamBase) +const terminalService = new TerminalService( + { client: openCodeClient, baseUrl: () => upstreamBase, includeLocalCredentials: true }, + credentialProvider, +) const managerApp = new Hono() const managerWs = createNodeWebSocket({ app: managerApp }) @@ -119,41 +121,6 @@ authenticatedApp.route( let authenticatedPort = 0 -interface RawHandshakeResult { - upgraded: boolean - status?: number -} - -function rawHandshake(options: { port: number; path: string; origin?: string; cookie?: string }): Promise { - return new Promise((resolve, reject) => { - const headers: Record = { - Connection: 'Upgrade', - Upgrade: 'websocket', - 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), - 'Sec-WebSocket-Version': '13', - } - if (options.origin !== undefined) headers.Origin = options.origin - if (options.cookie) headers.Cookie = options.cookie - - const request = httpRequest({ host: '127.0.0.1', port: options.port, path: options.path, method: 'GET', headers }) - let settled = false - request.on('upgrade', (_res, socket) => { - settled = true - socket.destroy() - resolve({ upgraded: true }) - }) - request.on('response', (res) => { - settled = true - res.resume() - resolve({ upgraded: false, status: res.statusCode }) - }) - request.on('error', (error) => { - if (!settled) reject(error) - }) - request.end() - }) -} - function clientUrl(ptyID: string, directory = '/tmp/repo'): string { const url = new URL(`ws://127.0.0.1:${managerPort}/1/terminals/${ptyID}/connect`) url.searchParams.set('directory', directory) @@ -267,7 +234,7 @@ describe('Repo Terminal Socket Origin validation', () => { const path = `/1/terminals/pty-1/connect?directory=${encodeURIComponent('/tmp/repo')}` it('accepts a trusted Origin with a valid session cookie', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN, @@ -278,13 +245,13 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('allows a non-browser client without an Origin header', async () => { - const outcome = await rawHandshake({ port: authenticatedPort, path, cookie: 'session=valid' }) + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, cookie: 'session=valid' }) expect(outcome).toEqual({ upgraded: true }) }) it('rejects an untrusted same-site Origin before the upgrade', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: 'http://localhost:5004', @@ -295,7 +262,7 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('rejects a null Origin before the upgrade', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: 'null', @@ -306,7 +273,7 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('rejects the upgrade without a session cookie', async () => { - const outcome = await rawHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN }) + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN }) expect(outcome).toEqual({ upgraded: false, status: 401 }) }) diff --git a/backend/test/routes/server-schedules.test.ts b/backend/test/routes/server-schedules.test.ts new file mode 100644 index 000000000..c0e82039b --- /dev/null +++ b/backend/test/routes/server-schedules.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { ScheduleServiceError } from '../../src/services/schedules' + +const scheduleService = { + listServerJobs: vi.fn(), + createServerJob: vi.fn(), + getServerJob: vi.fn(), + updateServerJob: vi.fn(), + deleteServerJob: vi.fn(), + runServerJob: vi.fn(), + listServerRuns: vi.fn(), + getServerRun: vi.fn(), + cancelServerRun: vi.fn(), + clearServerRunHistory: vi.fn(), + deleteServerRun: vi.fn(), +} + +vi.mock('../../src/services/schedules', async () => { + const actual = await vi.importActual('../../src/services/schedules') + return { + ...actual, + ScheduleService: vi.fn().mockImplementation(() => scheduleService), + } +}) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createServerScheduleRoutes } from '../../src/routes/schedules' + +describe('Server schedule routes', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + app = new Hono() + app.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService as unknown as import('../../src/services/schedules').ScheduleService)) + }) + + it('lists jobs for a linked server', async () => { + scheduleService.listServerJobs.mockReturnValue([{ id: 7 }]) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules') + const body = await response.json() as { jobs: Array<{ id: number }> } + + expect(response.status).toBe(200) + expect(body.jobs).toHaveLength(1) + expect(scheduleService.listServerJobs).toHaveBeenCalledWith('srv_kitchen') + }) + + it('creates a remote schedule with its target directory', async () => { + scheduleService.createServerJob.mockResolvedValue({ id: 7, serverId: 'srv_kitchen' }) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly audit', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + directory: '/srv/projects/audit', + }), + }) + + expect(response.status).toBe(201) + expect(scheduleService.createServerJob).toHaveBeenCalledWith( + 'srv_kitchen', + '/srv/projects/audit', + expect.objectContaining({ directory: '/srv/projects/audit' }), + ) + }) + + it('rejects a remote schedule without a directory', async () => { + const response = await app.request('/opencode-servers/srv_kitchen/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly audit', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + }), + }) + + expect(response.status).toBe(400) + expect(scheduleService.createServerJob).not.toHaveBeenCalled() + }) + + it('updates, runs, and cancels a remote schedule', async () => { + scheduleService.updateServerJob.mockResolvedValue({ id: 7 }) + scheduleService.runServerJob.mockResolvedValue({ id: 11, status: 'running' }) + scheduleService.cancelServerRun.mockResolvedValue({ id: 11, status: 'cancelled' }) + + const updateResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated audit' }), + }) + expect(updateResponse.status).toBe(200) + expect(scheduleService.updateServerJob).toHaveBeenCalledWith('srv_kitchen', 7, { name: 'Updated audit' }) + + const runResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/run', { method: 'POST' }) + expect(runResponse.status).toBe(200) + expect(scheduleService.runServerJob).toHaveBeenCalledWith('srv_kitchen', 7, 'manual') + + const cancelResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11/cancel', { method: 'POST' }) + expect(cancelResponse.status).toBe(200) + expect(scheduleService.cancelServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + }) + + it('lists and deletes runs for a remote schedule', async () => { + scheduleService.listServerRuns.mockReturnValue([{ id: 11 }]) + scheduleService.getServerRun.mockReturnValue({ id: 11 }) + scheduleService.clearServerRunHistory.mockResolvedValue({ cleared: 1 }) + scheduleService.deleteServerRun.mockResolvedValue(undefined) + + const listResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs') + expect(listResponse.status).toBe(200) + expect(scheduleService.listServerRuns).toHaveBeenCalledWith('srv_kitchen', 7, 20) + + const getResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11') + expect(getResponse.status).toBe(200) + expect(scheduleService.getServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + + const clearResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs', { method: 'DELETE' }) + expect(clearResponse.status).toBe(200) + expect(scheduleService.clearServerRunHistory).toHaveBeenCalledWith('srv_kitchen', 7) + + const deleteResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11', { method: 'DELETE' }) + expect(deleteResponse.status).toBe(200) + expect(scheduleService.deleteServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + }) + + it('maps service errors to their status', async () => { + scheduleService.runServerJob.mockRejectedValue(new ScheduleServiceError('OpenCode server "Kitchen" is disabled', 400)) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules/7/run', { method: 'POST' }) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'OpenCode server "Kitchen" is disabled' }) + }) +}) diff --git a/backend/test/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts index f7178d6b6..dbe672219 100644 --- a/backend/test/routes/session-goals.test.ts +++ b/backend/test/routes/session-goals.test.ts @@ -190,4 +190,23 @@ describe('session goal routes', () => { expect(res.status).toBe(404) }) + + it('scopes goals by serverId', async () => { + const app = createTestApp(db) + + const postRes = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody({ serverId: 'srv_remote' }), + }) + expect(postRes.status).toBe(201) + const created = await postRes.json() as { goal: { serverId: string } } + expect(created.goal.serverId).toBe('srv_remote') + + const remoteRes = await app.request('/session-goals?sessionId=ses_1&serverId=srv_remote') + await expect(remoteRes.json()).resolves.toMatchObject({ goal: { serverId: 'srv_remote' } }) + + const localRes = await app.request('/session-goals?sessionId=ses_1') + await expect(localRes.json()).resolves.toEqual({ goal: null }) + }) }) diff --git a/backend/test/routes/session-permission-modes.test.ts b/backend/test/routes/session-permission-modes.test.ts index 05536e374..4f6639ab8 100644 --- a/backend/test/routes/session-permission-modes.test.ts +++ b/backend/test/routes/session-permission-modes.test.ts @@ -115,4 +115,21 @@ describe('session permission mode routes', () => { }) expect(res.status).toBe(400) }) + + it('scopes PUT and GET by serverId', async () => { + const app = createTestApp(db, { ses_root: null }) + + const putRes = await app.request('/session-permission-modes/ses_root', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/abs/repo', mode: 'auto', serverId: 'srv_remote' }), + }) + expect(putRes.status).toBe(200) + + const remoteRes = await app.request('/session-permission-modes/ses_root?serverId=srv_remote') + await expect(remoteRes.json()).resolves.toMatchObject({ mode: 'auto' }) + + const localRes = await app.request('/session-permission-modes/ses_root') + await expect(localRes.json()).resolves.toMatchObject({ mode: 'ask' }) + }) }) diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts index ed5f7dd4c..95ed9f488 100644 --- a/backend/test/routes/sse.test.ts +++ b/backend/test/routes/sse.test.ts @@ -240,7 +240,7 @@ describe('SSE Routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ success: true }) - expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9') + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9', 'local') }) it('defaults the active session id to null when omitted', async () => { @@ -254,7 +254,20 @@ describe('SSE Routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ success: true }) - expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', false, null) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', false, null, 'local') + }) + + it('forwards an explicit active server id', async () => { + sseMocks.setClientVisibility.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', visible: true, activeSessionId: 'session-9', activeServerId: 'srv_a' }), + })) + + expect(res.status).toBe(200) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9', 'srv_a') }) it('returns 400 for an invalid visibility body', async () => { diff --git a/backend/test/services/multi-runs.remote.test.ts b/backend/test/services/multi-runs.remote.test.ts new file mode 100644 index 000000000..fcc0e861b --- /dev/null +++ b/backend/test/services/multi-runs.remote.test.ts @@ -0,0 +1,326 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { getMultiRun } from '../../src/db/multi-runs' +import { MultiRunService } from '../../src/services/multi-runs' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { assistantMessage } from '../helpers/stub-schedule-api' + +const SERVER_ID = 'srv_kitchen' +const REMOTE_DIR = '/srv/projects/app' +const FUSE_REQUEST_ID = '11111111-1111-4111-8111-111111111111' + +const mocks = vi.hoisted(() => ({ + resolveOpenCodeModel: vi.fn(), + existsSync: vi.fn(), + executeCommand: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync: mocks.existsSync } +}) + +vi.mock('../../src/utils/process', () => ({ + executeCommand: mocks.executeCommand, +})) + +interface FakeRemoteClient { + client: OpenCodeClient + sessionCreate: ReturnType + sessionPrompt: ReturnType + locationGet: ReturnType + worktreeCreate: ReturnType + worktreeRemove: ReturnType +} + +function createRemoteClient(options: { worktreeDirectory?: string } = {}): FakeRemoteClient { + let sessionCounter = 0 + let worktreeCounter = 0 + + const locationGet = vi.fn(async () => ({ project: { id: 'proj_remote' } })) + const worktreeCreate = vi.fn(async () => { + worktreeCounter += 1 + return { directory: options.worktreeDirectory ?? `/srv/worktrees/app-${worktreeCounter}` } + }) + const worktreeRemove = vi.fn(async () => undefined) + + const sessionCreate = vi.fn(async (input: { title?: string }) => { + sessionCounter += 1 + return { id: `ses_remote_${sessionCounter}`, title: input?.title } + }) + const sessionPrompt = vi.fn(async () => ({})) + const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + outcome: 'succeeded', + })) + const sessionActive = vi.fn(async () => ({})) + const sessionDiff = vi.fn(async () => []) + const messageList = vi.fn(async (input: { sessionID: string; type?: string }) => { + if (input.type === 'user') { + return { data: [{ id: `${input.sessionID}-user`, type: 'user', time: { created: 0 }, text: 'hi' }], cursor: {} } + } + return { data: [assistantMessage('remote reply', { completed: true })], cursor: {} } + }) + + const client = { + api: { + location: { get: locationGet }, + worktree: { create: worktreeCreate, remove: worktreeRemove }, + session: { + create: sessionCreate, + prompt: sessionPrompt, + get: sessionGet, + active: sessionActive, + diff: sessionDiff, + }, + message: { list: messageList }, + }, + } as unknown as OpenCodeClient + + return { client, sessionCreate, sessionPrompt, locationGet, worktreeCreate, worktreeRemove } +} + +describe('MultiRunService on a linked server', () => { + let db: Database + let localClient: OpenCodeClient + let localSessionCreate: ReturnType + let repoWorkspaces: { create: ReturnType; remove: ReturnType } + let applyDefaultMode: ReturnType + let remote: FakeRemoteClient + + beforeEach(() => { + vi.clearAllMocks() + db = new Database(':memory:') + migrate(db, allMigrations) + + localSessionCreate = vi.fn(async () => ({ id: 'ses_local' })) + localClient = { + api: { + session: { create: localSessionCreate, prompt: vi.fn(), get: vi.fn(), active: vi.fn(), diff: vi.fn() }, + message: { list: vi.fn() }, + }, + } as unknown as OpenCodeClient + + repoWorkspaces = { create: vi.fn(), remove: vi.fn() } + applyDefaultMode = vi.fn(async () => undefined) + remote = createRemoteClient() + + mocks.resolveOpenCodeModel.mockImplementation(async (_client, directory, options) => { + const preferred = options?.preferredModel + if (!preferred) { + return { providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5', directory } + } + if (preferred === 'openai/retired') { + return { providerID: 'openai', id: 'retired-x', model: 'openai/retired-x' } + } + const [providerID, ...rest] = preferred.split('/') + return { providerID, id: rest.join('/'), model: preferred } + }) + }) + + afterEach(() => { + db.close() + }) + + function createService(): MultiRunService { + const permissionModes = { applyDefaultMode } as unknown as SessionPermissionModeService + + return new MultiRunService( + db, + localClient, + repoWorkspaces as unknown as RepoWorkspaceService, + permissionModes, + () => remote.client, + ) + } + + function launchRemote(overrides: Record = {}) { + return createService().launch({ + serverId: SERVER_ID, + directory: REMOTE_DIR, + name: 'Remote sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + ...overrides, + }) + } + + it('launches every model through the remote client and remote worktrees', async () => { + const run = await launchRemote({ models: ['openai/a', 'openai/b'], baseRef: 'develop' }) + + expect(run).toMatchObject({ + repoId: null, + serverId: SERVER_ID, + directory: REMOTE_DIR, + name: 'Remote sweep', + isolated: true, + }) + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'started']) + expect(run.entries.map((entry) => entry.directory)).toEqual([ + '/srv/worktrees/app-1', + '/srv/worktrees/app-2', + ]) + + expect(remote.locationGet).toHaveBeenCalledWith({ location: { directory: REMOTE_DIR } }) + expect(remote.worktreeCreate).toHaveBeenCalledTimes(2) + expect(remote.worktreeCreate).toHaveBeenCalledWith({ + projectID: 'proj_remote', + name: 'Remote-sweep-1', + branch: 'develop', + }) + expect(remote.sessionCreate).toHaveBeenCalledTimes(2) + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ location: { directory: '/srv/worktrees/app-1' } }), + ) + expect(remote.sessionPrompt).toHaveBeenCalledWith({ sessionID: 'ses_remote_1', text: 'go' }) + + expect(localSessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(mocks.existsSync).not.toHaveBeenCalled() + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(remote.client, REMOTE_DIR, { preferredModel: 'openai/a' }) + }) + + it('runs in the target directory without a worktree when isolation is off', async () => { + const run = await launchRemote({ isolate: false }) + + expect(run.entries[0]!.directory).toBe(REMOTE_DIR) + expect(remote.worktreeCreate).not.toHaveBeenCalled() + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ location: { directory: REMOTE_DIR } }), + ) + }) + + it('fails an entry whose model the remote catalog does not offer', async () => { + const run = await launchRemote({ models: ['openai/a', 'openai/retired'] }) + + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'failed']) + expect(run.entries[1]!.error).toBe('Model openai/retired is not available') + expect(remote.worktreeCreate).toHaveBeenCalledTimes(1) + }) + + it('lists remote runs by server and directory without mixing in repository runs', async () => { + const service = createService() + await launchRemote({ name: 'Remote one' }) + + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: REMOTE_DIR }).map((run) => run.name)).toEqual([ + 'Remote one', + ]) + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: '/srv/other' })).toEqual([]) + expect(service.list({ kind: 'repo', repoId: 1 })).toEqual([]) + }) + + it('removes a remote worktree on discard without touching the local filesystem', async () => { + const service = createService() + const run = await launchRemote() + const entry = run.entries[0]! + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(remote.worktreeRemove).toHaveBeenCalledWith({ + projectID: 'proj_remote', + directory: '/srv/worktrees/app-1', + force: true, + }) + expect(mocks.existsSync).not.toHaveBeenCalled() + expect(repoWorkspaces.remove).not.toHaveBeenCalled() + expect(mocks.executeCommand).not.toHaveBeenCalled() + }) + + it('keeps a remote entry retryable when worktree removal fails', async () => { + const service = createService() + const run = await launchRemote() + remote.worktreeRemove.mockRejectedValueOnce(new Error('worktree busy')) + + const error = await service.discard(run.id, run.entries[0]!.id).catch((caught: unknown) => caught) + + expect(error).toMatchObject({ status: 502 }) + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: REMOTE_DIR })[0]!.entries[0]!.status).toBe( + 'started', + ) + }) + + it('fuses remote results in a remote worktree and reads sources from the remote client', async () => { + const service = createService() + const run = await launchRemote({ models: ['openai/a', 'openai/b'] }) + const entryIds = run.entries.map((entry) => entry.id) + remote.sessionCreate.mockClear() + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + baseRef: 'develop', + }) + + expect(created).toBe(true) + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('started') + expect(fusion.directory).toBe('/srv/worktrees/app-3') + expect(fusion.sources.map((source) => source.sessionId)).toEqual(['ses_remote_1', 'ses_remote_2']) + + expect(remote.worktreeCreate).toHaveBeenLastCalledWith({ + projectID: 'proj_remote', + name: 'Remote-sweep-fusion-1', + branch: 'develop', + }) + expect(remote.sessionCreate).toHaveBeenCalledTimes(1) + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + location: { directory: '/srv/worktrees/app-3' }, + permissions: [ + ...buildSchedulePermissionRuleset(null), + { action: 'external_directory', resource: '/srv/worktrees/app-1/*', effect: 'allow' }, + { action: 'edit', resource: '/srv/worktrees/app-1/*', effect: 'deny' }, + { action: 'external_directory', resource: '/srv/worktrees/app-2/*', effect: 'allow' }, + { action: 'edit', resource: '/srv/worktrees/app-2/*', effect: 'deny' }, + ], + }), + ) + expect(localSessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(applyDefaultMode).toHaveBeenCalledWith('ses_remote_3', '/srv/worktrees/app-3', 'srv_kitchen') + }) + + it('records a failed remote fusion without a local fallback', async () => { + const service = createService() + const run = await launchRemote({ models: ['openai/a', 'openai/b'] }) + remote.sessionCreate.mockRejectedValueOnce(new Error('remote boom')) + + const { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds: run.entries.map((entry) => entry.id), + model: 'openai/c', + }) + + expect(fused.fusions[0]!.status).toBe('failed') + expect(fused.fusions[0]!.error).toContain('remote boom') + expect(fused.fusions[0]!.directory).toBe('/srv/worktrees/app-3') + expect(localSessionCreate).not.toHaveBeenCalled() + }) + + it('persists the server target on the run row', async () => { + const run = await launchRemote() + + expect(getMultiRun(db, run.id)).toMatchObject({ + repoId: null, + serverId: SERVER_ID, + directory: REMOTE_DIR, + }) + }) +}) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index 314478966..81483ff4b 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { buildSchedulePermissionRuleset, LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createRepo, deleteRepo } from '../../src/db/queries' import { migrate } from '../../src/db/migration-runner' @@ -332,6 +332,30 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 404 }) }) + it('rejects a local server target because it stores no repository', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const error = await service + .launch({ + serverId: LOCAL_OPENCODE_SERVER_ID, + directory: REPO_DIR, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: false, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 400 }) + expect(sessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(service.list({ kind: 'repo', repoId })).toHaveLength(0) + }) + it('removes the workspace when discarding an isolated entry', async () => { const repoId = readyRepo() const { client } = createClient() @@ -473,7 +497,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(MultiRunError) expect(error).toMatchObject({ status: 400 }) expect(repoWorkspaces.remove).toHaveBeenCalledTimes(1) - expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + expect(service.list({ kind: 'repo', repoId })[0]!.entries[0]!.status).toBe('started') }) it('records the workspace of a failed isolated launch when prompting fails and removes it on discard', async () => { @@ -585,7 +609,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(MultiRunError) expect(error).toMatchObject({ status: 502 }) - expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + expect(service.list({ kind: 'repo', repoId })[0]!.entries[0]!.status).toBe('started') const discarded = await service.discard(run.id, entry.id) @@ -602,7 +626,7 @@ describe('MultiRunService', () => { await service.launch({ repoId, name: 'First', prompt: 'go', models: ['openai/a'], isolate: false }) await service.launch({ repoId, name: 'Second', prompt: 'go', models: ['openai/b'], isolate: false }) - const runs = service.list(repoId) + const runs = service.list({ kind: 'repo', repoId }) expect(runs.map((run) => run.name)).toEqual(['Second', 'First']) }) @@ -846,7 +870,7 @@ describe('MultiRunService', () => { unavailableSources: [expect.objectContaining({ reason: 'running' })], }) expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('rejects fusion when the prompt exceeds the context limit', async () => { @@ -872,7 +896,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(FusionContextLimitError) expect(error).toMatchObject({ status: 413, code: 'FUSION_CONTEXT_LIMIT' }) expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('rejects fusion with an unavailable synthesis model before creating a fusion', async () => { @@ -899,7 +923,7 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 400 }) expect((error as Error).message).toContain('not available') expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('records a failed fusion with the workspace directory when the launch fails', async () => { @@ -1006,7 +1030,7 @@ describe('MultiRunService', () => { }) expect(sessionCreate).not.toHaveBeenCalled() - const recovered = service.list(repoId)[0]!.fusions[0]! + const recovered = service.list({ kind: 'repo', repoId })[0]!.fusions[0]! expect(recovered.status).toBe('started') expect(recovered.error).toBeNull() }) @@ -1053,7 +1077,7 @@ describe('MultiRunService', () => { const service = createService(client, repoWorkspaces.service) const record = createMultiRunWithEntries( db, - { repoId: ASSISTANT_REPO_ID, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, + { repoId: ASSISTANT_REPO_ID, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, ['openai/a', 'openai/b'], ) @@ -1096,7 +1120,7 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 400 }) expect((error as Error).message).toBe('Unknown multi-run entry 9999') expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('persists the group and its entries in one transaction', () => { @@ -1104,10 +1128,11 @@ describe('MultiRunService', () => { db.run(`CREATE TRIGGER fail_entry BEFORE INSERT ON multi_run_entries BEGIN SELECT RAISE(ABORT, 'nope'); END`) expect(() => - createMultiRunWithEntries(db, { repoId, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, [ - 'openai/a', - 'openai/b', - ]), + createMultiRunWithEntries( + db, + { repoId, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, + ['openai/a', 'openai/b'], + ), ).toThrow() const count = db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get() as { count: number } diff --git a/backend/test/services/notification-remote.test.ts b/backend/test/services/notification-remote.test.ts new file mode 100644 index 000000000..87a7c7428 --- /dev/null +++ b/backend/test/services/notification-remote.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' +import * as repoService from '../../src/services/repo' +import type { PushNotificationPayload } from '@opencode-manager/shared/types' + +vi.mock('../../src/services/repo', () => ({ + resolveRepoForDirectory: vi.fn(async () => null), +})) + +const USER_ID = 'user-1' +const SERVER_ID = 'srv_a' +const DIRECTORY = '/remote/repo' + +const sendResult = { delivered: 0, expired: 0, failed: 0, total: 0 } + +function permissionAskedEvent(sessionID: string): SSEEvent { + return { + id: 'evt_perm_1', + created: 1700000000000, + type: 'permission.asked', + location: { directory: DIRECTORY }, + data: { + id: 'perm-1', + sessionID, + action: 'shell', + resources: ['ls -la'], + }, + } +} + +function createService(server: { name: string; enabled: boolean } | null = { name: 'Studio', enabled: true }) { + const db = new Database(':memory:') + migrate(db, allMigrations) + const registry = { get: vi.fn(() => server) } + const service = new NotificationService(db, registry) + vi.spyOn(service, 'isConfigured').mockReturnValue(true) + service.saveSubscription(USER_ID, 'https://push.example.com/sub-1', 'p256dh-key', 'auth-key') + new SettingsService(db).updateSettings( + { + notifications: { + enabled: true, + events: { permissionAsked: true, questionAsked: true, sessionError: true, sessionIdle: true }, + }, + }, + USER_ID, + ) + return { service, registry } +} + +describe('NotificationService remote server events', () => { + beforeEach(() => { + sseAggregator.shutdown() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('builds a server session URL with the server name and skips repo resolution', async () => { + const { service, registry } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(repoService.resolveRepoForDirectory).not.toHaveBeenCalled() + expect(registry.get).toHaveBeenCalledWith(SERVER_ID) + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Run Command') + expect(payload.body).toBe('Studio · ls -la') + expect(payload.data?.url).toBe('/servers/srv_a/sessions/ses_remote') + expect(payload.data?.serverId).toBe(SERVER_ID) + expect(payload.data?.serverName).toBe('Studio') + expect(payload.data?.repoId).toBeUndefined() + expect(payload.data?.repoName).toBeUndefined() + }) + + it('drops events for a disabled server', async () => { + const { service } = createService({ name: 'Studio', enabled: false }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(send).not.toHaveBeenCalled() + }) + + it('drops events for a deleted server', async () => { + const { service } = createService(null) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(send).not.toHaveBeenCalled() + }) + + it('suppresses a remote subagent session using the server-scoped check', async () => { + const { service } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const isSubagent = vi.spyOn(sseAggregator, 'isSubagentSession').mockReturnValue(true) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_sub'), SERVER_ID) + + expect(isSubagent).toHaveBeenCalledWith('ses_sub', SERVER_ID) + expect(send).not.toHaveBeenCalled() + }) + + it('suppresses a remote session the user is viewing on that server', async () => { + const { service } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const viewed = vi.spyOn(sseAggregator, 'isSessionBeingViewed').mockReturnValue(true) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(viewed).toHaveBeenCalledWith('ses_remote', SERVER_ID) + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index 9a61c7532..ff522fb3b 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -4,7 +4,7 @@ import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' -import { NotificationService } from '../../src/services/notification' +import { NotificationService, type NotificationServerRegistry } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' @@ -47,7 +47,10 @@ function permissionAskedEvent(sessionID: string): SSEEvent { } } -function createService(db = new Database(':memory:')): NotificationService { +function createService( + db = new Database(':memory:'), + registry: NotificationServerRegistry | null = null, +): NotificationService { migrate(db, allMigrations) createRepo(db, { localPath: 'repo-one', @@ -58,7 +61,7 @@ function createService(db = new Database(':memory:')): NotificationService { isLocal: true, }) - const service = new NotificationService(db) + const service = new NotificationService(db, registry) vi.spyOn(service, 'isConfigured').mockReturnValue(true) service.saveSubscription(USER_ID, 'https://push.example.com/sub-1', 'p256dh-key', 'auth-key') new SettingsService(db).updateSettings( @@ -168,7 +171,7 @@ describe('NotificationService.handleSSEEvent session routing', () => { await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_perm')) - expect(suppressor).toHaveBeenCalledWith(expect.objectContaining({ type: 'permission.asked' }), 'ses_perm') + expect(suppressor).toHaveBeenCalledWith(expect.objectContaining({ type: 'permission.asked' }), 'ses_perm', 'local') expect(send).not.toHaveBeenCalled() }) @@ -195,6 +198,7 @@ describe('NotificationService goal outcomes', () => { const completedGoal: SessionGoal = { id: 1, sessionId: 'ses_goal', + serverId: 'local', directory: DIRECTORY, objective: 'Ship the feature', status: 'completed', @@ -229,6 +233,27 @@ describe('NotificationService goal outcomes', () => { expect(payload.data?.url).toBe('/repos/1/sessions/ses_goal') }) + it('notifies a remote goal outcome with its server in the payload', async () => { + const service = createService(new Database(':memory:'), { + get: (serverId) => (serverId === 'srv_kitchen' ? { name: 'Kitchen', enabled: true } : null), + }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + sessionId: 'ses_remote', + serverId: 'srv_kitchen', + directory: '/home/me/app', + }) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.body).toContain('Kitchen') + expect(payload.data?.serverId).toBe('srv_kitchen') + expect(payload.data?.serverName).toBe('Kitchen') + expect(payload.data?.url).toBe('/servers/srv_kitchen/sessions/ses_remote') + }) + it('reports the stop reason instead of a stale verdict reason', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index a36e9ff8b..756d805d9 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -455,6 +455,72 @@ describe('opencode-import service', () => { expect(mockRename).toHaveBeenCalledWith(stagedPath, targetPath) }) + it('imports only durable host state entries and keeps nested directories named like excluded caches', async () => { + const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') + const actualFs = await vi.importActual('fs/promises') + const rootPath = await actualFs.mkdtemp(path.join(os.tmpdir(), 'opencode-import-test-')) + const sourcePath = path.join(rootPath, 'source') + const targetPath = path.join(rootPath, 'workspace', '.opencode', 'state', 'opencode') + + const writeSourceFile = async (relativePath: string) => { + const filePath = path.join(sourcePath, relativePath) + await actualFs.mkdir(path.dirname(filePath), { recursive: true }) + await actualFs.writeFile(filePath, relativePath) + } + const mockRm = vi.mocked(rm) + + try { + await actualFs.mkdir(path.dirname(targetPath), { recursive: true }) + for (const relativePath of [ + 'opencode.db', + 'auth.json', + 'storage/x', + 'storage/log/y', + 'project/project.json', + 'plans/plan.json', + 'forge/forge.json', + 'forge.db', + 'worktree/checkout/file', + 'snapshot/snapshot', + 'repos/repo', + 'bin/binary', + 'log/server', + 'tool-output/output', + 'shell/command', + 'debug.log', + ]) { + await writeSourceFile(relativePath) + } + + mockFileExists.mockImplementation(async (candidate: string) => candidate === path.join(sourcePath, 'opencode.db')) + mockEnsureDirectoryExists.mockResolvedValue(undefined) + mockReaddir.mockImplementation(actualFs.readdir) + mockMkdtemp.mockImplementation(actualFs.mkdtemp) + mockRename.mockImplementation(actualFs.rename) + mockRm.mockImplementation(actualFs.rm) + vi.mocked(cp).mockImplementation(actualFs.cp) + + const result = await importOpenCodeStateDirectory(sourcePath, targetPath) + + expect(result).toBe(true) + expect((await actualFs.readdir(targetPath)).sort()).toEqual( + ['auth.json', 'forge', 'forge.db', 'plans', 'project', 'storage'].sort() + ) + expect(await actualFs.readFile(path.join(targetPath, 'storage', 'log', 'y'), 'utf8')).toBe('storage/log/y') + expect(MockSQLiteDatabase).toHaveBeenCalledWith(path.join(sourcePath, 'opencode.db')) + } finally { + await actualFs.rm(rootPath, { recursive: true, force: true }) + } + }) + + it('skips the top-level entry that contains the import destination', async () => { + const { shouldImportOpenCodeStateEntry } = await import('../../src/services/opencode-import') + + expect(shouldImportOpenCodeStateEntry('.opencode', '/host/opencode', '/host/opencode/.opencode/state/opencode')).toBe(false) + expect(shouldImportOpenCodeStateEntry('storage', '/host/opencode', '/host/opencode/.opencode/state/opencode')).toBe(true) + expect(shouldImportOpenCodeStateEntry('storage', '/host/opencode', '/elsewhere/.opencode/state/opencode')).toBe(true) + }) + it('cleans up the staged import directory when snapshotting fails', async () => { const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts index afe98cd84..cfe464484 100644 --- a/backend/test/services/remote-server-events.test.ts +++ b/backend/test/services/remote-server-events.test.ts @@ -238,7 +238,7 @@ describe('RemoteServerEventHub event delivery', () => { expect(sources.sources[1]?.init).toBeUndefined() }) - it('delivers a located event only to clients subscribed to that server and directory, with a serverId envelope', () => { + it('broadcasts a located event to every client with a serverId envelope', () => { const remoteClient = createCapturingClient() const otherServerClient = createCapturingClient() const localClient = createCapturingClient() @@ -256,7 +256,7 @@ describe('RemoteServerEventHub event delivery', () => { source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) - expect(remoteClient.frames.map(parseFrame)).toEqual([{ + const envelope = { directory: '/repo', serverId: 'srv_a', payload: { @@ -266,9 +266,10 @@ describe('RemoteServerEventHub event delivery', () => { location: { directory: '/repo' }, data: { sessionID: 'ses_1' }, }, - }]) - expect(otherServerClient.frames).toHaveLength(0) - expect(localClient.frames).toHaveLength(0) + } + expect(remoteClient.frames.map(parseFrame)).toEqual([envelope]) + expect(otherServerClient.frames.map(parseFrame)).toEqual([envelope]) + expect(localClient.frames.map(parseFrame)).toEqual([envelope]) expect(listener).not.toHaveBeenCalled() expect(sseAggregator.getActiveSessions()).toEqual({}) }) @@ -325,7 +326,7 @@ describe('RemoteServerEventHub event delivery', () => { }]) }) - it('falls back to every client holding any key of the server when the directory is unknown', () => { + it('broadcasts to every client when the directory is unknown', () => { const subscribedA = createCapturingClient() const otherDirectoryA = createCapturingClient() const subscribedB = createCapturingClient() @@ -343,7 +344,7 @@ describe('RemoteServerEventHub event delivery', () => { } expect(subscribedA.frames.map(parseFrame)).toEqual([envelope]) expect(otherDirectoryA.frames.map(parseFrame)).toEqual([envelope]) - expect(subscribedB.frames).toHaveLength(0) + expect(subscribedB.frames.map(parseFrame)).toEqual([envelope]) }) it('keeps the raw upstream payload in the remote envelope and re-serializes multi-line payloads', () => { @@ -375,6 +376,51 @@ describe('RemoteServerEventHub event delivery', () => { expect(client.frames).toHaveLength(0) }) + + it('notifies server event listeners with the remote server id and resolved directory', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + hub.start() + const source = sources.sources[0]! + source.message(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) + source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + + expect(seen).toEqual([ + { serverId: 'srv_a', directory: '/repo', type: 'session.created' }, + { serverId: 'srv_a', directory: '/repo', type: 'session.execution.succeeded' }, + ]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + }) + + it('tracks remote subagent sessions per server', () => { + hub.start() + const source = sources.sources[0]! + + source.message(remoteEvent('session.created', { sessionID: 'ses_sub', parentID: 'ses_root' }, '/repo')) + + expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(true) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(true) + expect(sseAggregator.isSubagentSession('ses_sub')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_b')).toBe(false) + + source.message(remoteEvent('session.deleted', { sessionID: 'ses_sub' })) + + expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(false) + }) + + it('notifies server connected listeners when a remote stream opens', () => { + const seen: string[] = [] + sseAggregator.onServerConnected(serverId => { seen.push(serverId) }) + + hub.start() + sources.sources[1]!.open() + + expect(seen).toEqual(['srv_b']) + }) }) describe('RemoteServerEventHub reconnection', () => { diff --git a/backend/test/services/schedules-remote.test.ts b/backend/test/services/schedules-remote.test.ts new file mode 100644 index 000000000..14c42621a --- /dev/null +++ b/backend/test/services/schedules-remote.test.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { getScheduleJobById, createScheduleRun, getScheduleRunById, listScheduleRunsByJob } from '../../src/db/schedules' +import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { ScheduleService } from '../../src/services/schedules' +import { sseAggregator } from '../../src/services/sse-aggregator' +import { createStubOpenCodeClient, stubLoadedModelCatalog } from '../helpers/stub-opencode-client' +import { assistantMessage, createStubScheduleApi } from '../helpers/stub-schedule-api' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +const REMOTE_SERVER = { + id: 'srv_kitchen', + name: 'Kitchen server', + baseUrl: 'http://kitchen.local:5551', + username: 'opencode', + enabled: true, + createdAt: 0, + updatedAt: 0, +} + +function createDatabase(): Database { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = OFF') + migrate(db, allMigrations) + const now = Date.now() + db.prepare( + `INSERT INTO opencode_servers (id, name, base_url, username, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(REMOTE_SERVER.id, REMOTE_SERVER.name, REMOTE_SERVER.baseUrl, REMOTE_SERVER.username, 1, now, now) + return db +} + +function createClient() { + const stub = createStubScheduleApi() + const api = { ...stub.api, ...stubLoadedModelCatalog() } as unknown as OpenCodeApi + return { client: createStubOpenCodeClient({ api }), api: stub.api, state: stub.state } +} + +function activeRunMonitor(jobId: number): { dispose(): void } | undefined { + const activeRuns = (ScheduleService as unknown as { activeRuns: Map }).activeRuns + return activeRuns.get(jobId)?.sessionMonitor +} + +function hasCancelInProgress(jobId: number): boolean { + return (ScheduleService as unknown as { pendingCancels: Map }).pendingCancels.has(jobId) +} + +function hasActiveRun(jobId: number): boolean { + return (ScheduleService as unknown as { activeRuns: Map }).activeRuns.has(jobId) +} + +function createService(db: Database, localClient: ReturnType, remoteClient: ReturnType, enabled = true, registered = true) { + const worktreeManager = { + 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), + } + const registry = { + get: vi.fn(() => (registered ? { ...REMOTE_SERVER, enabled } : undefined)), + getClient: vi.fn(() => remoteClient), + } + const service = new ScheduleService(db, localClient, worktreeManager as never, registry as never) + return { service, worktreeManager, registry } +} + +const baseRequest = { + name: 'Nightly audit', + enabled: true, + scheduleMode: 'interval' as const, + intervalMinutes: 60, + prompt: 'Audit the project.', +} + +describe('remote schedule targets', () => { + let db: Database + + beforeEach(() => { + db = createDatabase() + const staticState = ScheduleService as unknown as { activeRuns: Map; pendingCancels: Map } + staticState.activeRuns.clear() + staticState.pendingCancels.clear() + }) + + it('creates a remote job with an in-place workspace mode', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + expect(job.repoId).toBeNull() + expect(job.serverId).toBe(REMOTE_SERVER.id) + expect(job.directory).toBe('/srv/projects/audit') + expect(job.workspaceMode).toBe('in-place') + expect(localClient.api.session.create).not.toHaveBeenCalled() + }) + + it('rejects worktree workspace modes for a remote job', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + + await expect( + service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', { ...baseRequest, workspaceMode: 'worktree' }), + ).rejects.toMatchObject({ status: 400 }) + expect(getScheduleJobById(db, null, 1)).toBeNull() + }) + + it('runs a remote job on the remote client without touching local git', async () => { + const localClient = createClient().client + const { client, api } = createClient() + const { service, worktreeManager } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + + expect(run.repoId).toBeNull() + expect(run.serverId).toBe(REMOTE_SERVER.id) + expect(run.status).toBe('running') + expect(api.session.create).toHaveBeenCalledWith(expect.objectContaining({ + location: { directory: '/srv/projects/audit' }, + })) + expect(localClient.api.session.create).not.toHaveBeenCalled() + expect(worktreeManager.prepare).not.toHaveBeenCalled() + expect(worktreeManager.finalize).not.toHaveBeenCalled() + }) + + it('completes a remote run after a server reconnect when the settle event was missed', async () => { + const localClient = createClient().client + const { client, api, state } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + state.active = { 'ses-run-1': { type: 'running' } } + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + await vi.waitFor(() => expect(api.session.active).toHaveBeenCalled()) + + state.active = {} + state.messages = [assistantMessage('Remote audit complete.', { completed: true })] + sseAggregator.notifyServerConnected(REMOTE_SERVER.id) + + await vi.waitFor(() => { + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ + status: 'completed', + responseText: 'Remote audit complete.', + }) + }) + }) + + it('keeps waiting after a remote server reconnect while the session is still busy', async () => { + const localClient = createClient().client + const { client, api, state } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + state.active = { 'ses-run-1': { type: 'running' } } + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + await vi.waitFor(() => expect(api.session.active).toHaveBeenCalled()) + const activeChecks = vi.mocked(api.session.active).mock.calls.length + + sseAggregator.notifyServerConnected(REMOTE_SERVER.id) + + await vi.waitFor(() => { + expect(vi.mocked(api.session.active).mock.calls.length).toBeGreaterThan(activeChecks) + }) + expect(getScheduleRunById(db, null, job.id, run.id)?.status).toBe('running') + + activeRunMonitor(job.id)?.dispose() + await vi.waitFor(() => expect(hasActiveRun(job.id)).toBe(false)) + }) + + it('records a failed run when the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const disabled = createService(db, localClient, client, false) + + await expect(disabled.service.runServerJob(REMOTE_SERVER.id, job.id, 'manual')).rejects.toMatchObject({ status: 400 }) + + const runs = listScheduleRunsByJob(db, null, job.id) + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ status: 'failed', serverId: REMOTE_SERVER.id }) + expect(runs[0]?.errorText).toContain('disabled') + }) + + it('lists remote jobs and runs with the linked server name', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + + const jobs = service.listAllJobsWithRepos() + expect(jobs.find((entry) => entry.id === job.id)).toMatchObject({ + repoId: null, + serverId: REMOTE_SERVER.id, + directory: '/srv/projects/audit', + serverName: REMOTE_SERVER.name, + repoName: REMOTE_SERVER.name, + }) + + const runs = service.listAllRuns() + expect(runs[0]).toMatchObject({ serverId: REMOTE_SERVER.id, serverName: REMOTE_SERVER.name }) + }) + + it('lists remote jobs for a disabled server and for an unregistered server', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + const disabled = createService(db, localClient, client, false) + expect(disabled.service.listServerJobs(REMOTE_SERVER.id).map((entry) => entry.id)).toEqual([job.id]) + + const unregistered = createService(db, localClient, client, true, false) + expect(unregistered.service.listServerJobs(REMOTE_SERVER.id).map((entry) => entry.id)).toEqual([job.id]) + }) + + it('deletes a remote job while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await createService(db, localClient, client, false).service.deleteServerJob(REMOTE_SERVER.id, job.id) + + expect(getScheduleJobById(db, null, job.id)).toBeNull() + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote job when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await createService(db, localClient, client, true, false).service.deleteServerJob(REMOTE_SERVER.id, job.id) + + expect(getScheduleJobById(db, null, job.id)).toBeNull() + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('clears remote run history while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + seedCompletedRun(db, job.id) + + await expect(createService(db, localClient, client, false).service.clearServerRunHistory(REMOTE_SERVER.id, job.id)) + .resolves.toEqual({ cleared: 2 }) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('clears remote run history when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await expect(createService(db, localClient, client, true, false).service.clearServerRunHistory(REMOTE_SERVER.id, job.id)) + .resolves.toEqual({ cleared: 1 }) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote run while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const runId = seedCompletedRun(db, job.id) + + await createService(db, localClient, client, false).service.deleteServerRun(REMOTE_SERVER.id, job.id, runId) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote run when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const runId = seedCompletedRun(db, job.id) + + await createService(db, localClient, client, true, false).service.deleteServerRun(REMOTE_SERVER.id, job.id, runId) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('cancels a running remote run while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + const disabled = createService(db, localClient, client, false) + + const cancelled = await disabled.service.cancelServerRun(REMOTE_SERVER.id, job.id, run.id) + + expect(cancelled).toMatchObject({ id: run.id, status: 'cancelled', errorText: 'Run cancelled by user.' }) + expect(hasActiveRun(job.id)).toBe(false) + + await vi.waitFor(() => expect(hasCancelInProgress(job.id)).toBe(false)) + + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ status: 'cancelled' }) + }) + + it('cancels a running remote run when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + const unregistered = createService(db, localClient, client, true, false) + + const cancelled = await unregistered.service.cancelServerRun(REMOTE_SERVER.id, job.id, run.id) + + expect(cancelled).toMatchObject({ id: run.id, status: 'cancelled', errorText: 'Run cancelled by user.' }) + expect(hasActiveRun(job.id)).toBe(false) + + await vi.waitFor(() => expect(hasCancelInProgress(job.id)).toBe(false)) + + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ status: 'cancelled' }) + }) +}) + +function seedCompletedRun(db: Database, jobId: number): number { + return createScheduleRun(db, { + jobId, + repoId: null, + serverId: REMOTE_SERVER.id, + triggerSource: 'schedule', + status: 'completed', + startedAt: Date.now(), + createdAt: Date.now(), + }).id +} diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index 12342e5b9..014017487 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenCodeModel: vi.fn(), onEvent: vi.fn(), + onServerConnected: vi.fn(), loggerError: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { @@ -78,6 +79,7 @@ vi.mock('../../src/services/opencode-models', () => ({ vi.mock('../../src/services/sse-aggregator', () => ({ sseAggregator: { onEvent: mocks.onEvent, + onServerConnected: mocks.onServerConnected, }, })) @@ -161,6 +163,7 @@ describe('ScheduleService permission ruleset in session creation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) }) it('sends the default permission ruleset when job.permissionConfig is null', async () => { diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 6448b592d..4a463b508 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -3,6 +3,7 @@ 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' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' const mocks = vi.hoisted(() => ({ getRepoById: vi.fn(), @@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenCodeModel: vi.fn(), onEvent: vi.fn(), + onServerConnected: vi.fn(), loggerError: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { @@ -86,6 +88,7 @@ vi.mock('../../src/services/opencode-models', () => ({ vi.mock('../../src/services/sse-aggregator', () => ({ sseAggregator: { onEvent: mocks.onEvent, + onServerConnected: mocks.onServerConnected, }, })) @@ -130,6 +133,15 @@ function captureEventListener(): (directory: string, event: unknown) => void { return mocks.onEvent.mock.calls[0]?.[0] as (directory: string, event: unknown) => void } +function captureServerConnectedListener(): (serverId: string) => void { + return mocks.onServerConnected.mock.calls[0]?.[0] as (serverId: string) => void +} + +function activeRunMonitor(jobId: number): { dispose(): void } | undefined { + const activeRuns = Reflect.get(ScheduleService, 'activeRuns') as Map + return activeRuns.get(jobId)?.sessionMonitor +} + const repo = { id: 42, fullPath: '/workspace/repos/sample-project', @@ -195,6 +207,7 @@ describe('ScheduleService', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.getScheduleRunById.mockReturnValue({ ...baseRun, sessionId: 'ses-run-1', @@ -253,6 +266,70 @@ describe('ScheduleService', () => { ) }) + it('completes a run after the server reconnects when the settle event was missed', async () => { + const stub = createStubScheduleApi({ + active: { 'ses-run-1': { type: 'running' } }, + messages: [assistantMessage('Recovered summary.', { completed: true })], + }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + stub.state.active = {} + + captureServerConnectedListener()(LOCAL_OPENCODE_SERVER_ID) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Recovered summary.', + }), + ) + }) + }) + + it('keeps waiting after the server reconnects while the session is still busy', async () => { + const stub = createStubScheduleApi({ active: { 'ses-run-1': { type: 'running' } } }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + const activeChecks = vi.mocked(stub.api.session.active).mock.calls.length + + captureServerConnectedListener()(LOCAL_OPENCODE_SERVER_ID) + + await vi.waitFor(() => { + expect(vi.mocked(stub.api.session.active).mock.calls.length).toBeGreaterThan(activeChecks) + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + activeRunMonitor(7)?.dispose() + await vi.waitFor(() => { + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false) + }) + }) + it('strips thinking blocks from the persisted assistant response while joining text items', async () => { const stub = createStubScheduleApi({ sessionID: 'ses-think', @@ -1263,6 +1340,7 @@ describe('ScheduleService startup cancellation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.stubWorktreeManager.prepare.mockResolvedValue(null) mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) }) @@ -1525,6 +1603,7 @@ describe('ScheduleService monitor cancellation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.stubWorktreeManager.prepare.mockResolvedValue(null) mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) }) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts index d9b317875..22b12262d 100644 --- a/backend/test/services/session-goals.test.ts +++ b/backend/test/services/session-goals.test.ts @@ -1100,3 +1100,157 @@ describe('SessionGoalService recovery', () => { expect(fake.auditorCalls).toHaveLength(0) }) }) + +describe('SessionGoalService remote servers', () => { + let db: Database + + const REMOTE_SERVER_ID = 'srv_remote' + + function createRemoteRegistry( + local: FakeSessionGoalClient, + remote: FakeSessionGoalClient, + options: { missing?: boolean; disabled?: boolean } = {}, + ) { + return { + getClient: (serverId: string) => + serverId === 'local' ? local.client : remote.client, + get: (serverId: string) => { + if (serverId === 'local') return { enabled: true } + if (options.missing) return null + return { enabled: !options.disabled } + }, + } + } + + beforeEach(() => { + vi.useFakeTimers() + db = createTestDb() + }) + + afterEach(() => { + vi.useRealTimers() + db.close() + }) + + it('continues a remote goal through the remote client while the auditor stays local', async () => { + const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"continue","reason":"keep going"}'] }) + const remote = createFakeSessionGoalClient({ + tokens: { ses_remote: { input: 10, output: 5, reasoning: 0 } }, + }) + const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { + quietMs: 0, + }) + + const goal = await service.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote work', + }) + + expect(goal.serverId).toBe(REMOTE_SERVER_ID) + + await service.handleEvent(DIRECTORY, executionStarted('ses_remote'), REMOTE_SERVER_ID) + await service.handleEvent(DIRECTORY, sessionIdle('ses_remote'), REMOTE_SERVER_ID) + await vi.runAllTimersAsync() + + expect(remote.promptCalls).toHaveLength(1) + expect(remote.promptCalls[0]?.sessionID).toBe('ses_remote') + expect(local.promptCalls).toHaveLength(0) + expect(local.auditorCalls).toHaveLength(1) + expect(remote.auditorCalls).toHaveLength(0) + }) + + it('keeps goal state scoped to the server that owns the session', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient({ + tokens: { ses_shared: { input: 0, output: 0, reasoning: 0 } }, + }) + const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + + await service.start({ + sessionId: 'ses_shared', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote only', + }) + + expect(service.getLatest('ses_shared')).toBeNull() + expect(service.getLatest('ses_shared', REMOTE_SERVER_ID)?.objective).toBe('Remote only') + }) + + it('recovers a remote goal using the remote client', async () => { + const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"done","reason":"finished"}'] }) + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote recovery', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { + quietMs: 0, + }) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(local.auditorCalls).toHaveLength(1) + expect(remote.auditorCalls).toHaveLength(0) + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)?.status).toBe('completed') + }) + + it('leaves remote goals open when their server is missing', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Orphaned', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService( + db, + createRemoteRegistry(local, remote, { missing: true }), + new SettingsService(db), + { quietMs: 0 }, + ) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) + expect(local.auditorCalls).toHaveLength(0) + expect(remote.auditorCalls).toHaveLength(0) + }) + + it('leaves remote goals open when their server is disabled', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Disabled', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService( + db, + createRemoteRegistry(local, remote, { disabled: true }), + new SettingsService(db), + { quietMs: 0 }, + ) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) + expect(local.auditorCalls).toHaveLength(0) + expect(remote.auditorCalls).toHaveLength(0) + }) +}) diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index 5e1c05282..057f86023 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -3,7 +3,7 @@ import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' -import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' +import { createScheduleRun, getScheduleRunBySessionId, updateScheduleRunMetadata } from '../../src/db/schedules' import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' import { SettingsService } from '../../src/services/settings' import { SessionPermissionModeService } from '../../src/services/session-permission-modes' @@ -42,6 +42,21 @@ function createPermissionModeService( return new SessionPermissionModeService(db, client, settingsService, new SessionAncestry(client)) } +function createMultiServerService( + db: Database, + clients: { local: OpenCodeClient } & Record, + settingsService: SettingsService, +): SessionPermissionModeService { + const local = clients.local + const resolve = (serverId: string) => (serverId === 'local' ? local : clients[serverId] ?? local) + return new SessionPermissionModeService( + db, + { getClient: resolve }, + settingsService, + new SessionAncestry(resolve), + ) +} + function permissionAskedEvent(sessionID: string, id: string): SSEEvent { return { id: `evt_${id}`, @@ -555,4 +570,167 @@ describe('SessionPermissionModeService', () => { expect(client.replyPermission).not.toHaveBeenCalled() }) + + describe('remote servers', () => { + it('scopes stored modes by server', async () => { + setSessionPermissionMode(db, 'ses_remote', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient({ parents: { ses_remote: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_remote: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_remote')).resolves.toMatchObject({ mode: 'ask' }) + await expect(service.getEffectiveMode('ses_remote', 'srv_remote')).resolves.toMatchObject({ mode: 'auto' }) + + await service.setMode('ses_remote', 'auto', DIRECTORY, 'srv_remote') + + expect(getSessionPermissionMode(db, 'ses_remote')).toBeNull() + expect(getSessionPermissionMode(db, 'ses_remote', 'srv_remote')).toBe('auto') + expect(remote.replyPermission).not.toHaveBeenCalled() + }) + + it('auto-accepts a remote permission request through the remote client', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_root', 'perm-remote'), 'srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledTimes(1) + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_root', + requestID: 'perm-remote', + decision: 'once', + }) + expect(local.replyPermission).not.toHaveBeenCalled() + }) + + it('resolves remote session parents through the remote client', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ parents: { ses_child: 'ses_root', ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_child', 'perm-child'), 'srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_child', + requestID: 'perm-child', + decision: 'once', + }) + }) + + it('accepts pending requests for active sessions on the remote server', async () => { + setSessionPermissionMode(db, 'ses_remote', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ + parents: { ses_remote: null }, + directories: { ses_remote: DIRECTORY }, + activeSessions: ['ses_remote'], + pendingRequests: { [DIRECTORY]: [{ id: 'perm-remote', sessionID: 'ses_remote' }] }, + }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.acceptPendingRequestsForActiveSessions('srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_remote', + requestID: 'perm-remote', + decision: 'once', + }) + expect(local.replyPermission).not.toHaveBeenCalled() + }) + + it('reports schedule locks only for the local server', async () => { + createRepo(db, { + localPath: 'repo-one', + sourcePath: '/abs/repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + const run = createScheduleRun(db, { + jobId: 1, + repoId: 1, + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, 1, 1, run.id, { sessionId: 'ses_scheduled' }) + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled')).resolves.toMatchObject({ lockedReason: 'schedule' }) + await expect(service.getEffectiveMode('ses_scheduled', 'srv_remote')).resolves.toMatchObject({ + lockedReason: null, + }) + }) + + it('reports the schedule lock for a remote scheduled session even with a stored auto mode', async () => { + const run = createScheduleRun(db, { + jobId: 1, + repoId: null, + serverId: 'srv_remote', + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, null, 1, run.id, { sessionId: 'ses_scheduled' }) + setSessionPermissionMode(db, 'ses_scheduled', 'auto', 'srv_remote') + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled', 'srv_remote')).resolves.toEqual({ + sessionId: 'ses_scheduled', + rootSessionId: 'ses_scheduled', + mode: 'ask', + lockedReason: 'schedule', + }) + await expect(service.setMode('ses_scheduled', 'auto', DIRECTORY, 'srv_remote')).rejects.toMatchObject({ status: 409 }) + }) + + it('does not lock a local session that matches only a remote schedule run', async () => { + const run = createScheduleRun(db, { + jobId: 1, + repoId: null, + serverId: 'srv_remote', + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, null, 1, run.id, { sessionId: 'ses_scheduled' }) + + expect(getScheduleRunBySessionId(db, 'ses_scheduled')).toBeNull() + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: createFakeSessionPermissionClient() }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled')).resolves.toEqual({ + sessionId: 'ses_scheduled', + rootSessionId: 'ses_scheduled', + mode: 'ask', + lockedReason: null, + }) + }) + + it('suppresses permission notifications for remote auto sessions', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + const event = permissionAskedEvent('ses_root', 'perm-remote') + + await expect(service.suppressesPermissionNotification(event, 'ses_root', 'srv_remote')).resolves.toBe(true) + await expect(service.suppressesPermissionNotification(event, 'ses_root')).resolves.toBe(false) + await expect(service.suppressesPermissionNotification(event, undefined, 'srv_remote')).resolves.toBe(false) + }) + }) }) diff --git a/backend/test/services/session-reply.test.ts b/backend/test/services/session-reply.test.ts index 66fd0f1c5..86befdf0b 100644 --- a/backend/test/services/session-reply.test.ts +++ b/backend/test/services/session-reply.test.ts @@ -14,13 +14,13 @@ import { } from '../../src/services/session-reply' import { assistantMessage } from '../helpers/stub-schedule-api' -const mockOnEvent = vi.fn() +const mockOnServerEvent = vi.fn() vi.mock('../../src/services/sse-aggregator', () => ({ - sseAggregator: { onEvent: (...args: unknown[]) => mockOnEvent(...args) }, + sseAggregator: { onServerEvent: (...args: unknown[]) => mockOnServerEvent(...args) }, })) beforeEach(() => { - mockOnEvent.mockReset() + mockOnServerEvent.mockReset() }) function createFakeClient(messages: SessionMessageInfo[], active: Record = {}): OpenCodeClient { @@ -166,14 +166,14 @@ describe('sessionSettleSignal', () => { describe('waitForSessionSettled', () => { function captureListener() { - let listener: ((directory: string, event: unknown) => void) | undefined + let listener: ((serverId: string, directory: string | null, event: unknown) => void) | undefined const unsubscribe = vi.fn() - mockOnEvent.mockImplementation((callback: (directory: string, event: unknown) => void) => { + mockOnServerEvent.mockImplementation((callback: (serverId: string, directory: string | null, event: unknown) => void) => { listener = callback return unsubscribe }) return { - emit: (event: unknown) => listener?.('', event), + emit: (event: unknown, serverId = 'local') => listener?.(serverId, null, event), unsubscribe, } } @@ -182,7 +182,7 @@ describe('waitForSessionSettled', () => { const client = createFakeClient([], { 'ses-1': {} }) await expect(waitForSessionSettled(client, 'ses-1', 0)).resolves.toBeUndefined() - expect(mockOnEvent).not.toHaveBeenCalled() + expect(mockOnServerEvent).not.toHaveBeenCalled() }) it('resolves once the session settles and unsubscribes', async () => { @@ -224,4 +224,17 @@ describe('waitForSessionSettled', () => { await expect(waitForSessionSettled(client, 'ses-1', 20)).resolves.toBeUndefined() expect(unsubscribe).toHaveBeenCalledTimes(1) }) + + it('ignores settle events from other servers when a server id is provided', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + const { emit, unsubscribe } = captureListener() + + const settled = waitForSessionSettled(client, 'ses-1', 1000, 'srv_a') + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }, 'local') + expect(unsubscribe).not.toHaveBeenCalled() + + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }, 'srv_a') + await expect(settled).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) }) diff --git a/backend/test/services/sse-aggregator-remote.test.ts b/backend/test/services/sse-aggregator-remote.test.ts index 66cc60f5f..5e9fbbb61 100644 --- a/backend/test/services/sse-aggregator-remote.test.ts +++ b/backend/test/services/sse-aggregator-remote.test.ts @@ -75,24 +75,29 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.setScheduledSessionsResolver(() => []) }) - it('delivers a remote envelope only to clients subscribed to that server and directory', () => { + it('broadcasts a located remote envelope to every connected client whatever they are subscribed to', () => { const subscribed = createCapturingClient() const otherDirectory = createCapturingClient() + const otherServer = createCapturingClient() const local = createCapturingClient() + const unsubscribed = createCapturingClient() sseAggregator.addClient('remote-key', subscribed.callback, subscribed.writeFrame, ['srv_a|/repo']) sseAggregator.addClient('other-dir', otherDirectory.callback, otherDirectory.writeFrame, ['srv_a|/other']) + sseAggregator.addClient('other-server', otherServer.callback, otherServer.writeFrame, ['srv_b|/repo']) sseAggregator.addClient('local-dir', local.callback, local.writeFrame, ['/repo']) + sseAggregator.addClient('no-directories', unsubscribed.callback, unsubscribed.writeFrame, []) sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', PAYLOAD) - expect(subscribed.frames).toEqual([ - `event: message\ndata: {"directory":"/repo","serverId":"srv_a","payload":${PAYLOAD}}\n\n`, - ]) - expect(otherDirectory.frames).toHaveLength(0) - expect(local.frames).toHaveLength(0) + const expected = { directory: '/repo', serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } + expect(subscribed.frames.map(parseFrame)).toEqual([expected]) + expect(otherDirectory.frames.map(parseFrame)).toEqual([expected]) + expect(otherServer.frames.map(parseFrame)).toEqual([expected]) + expect(local.frames.map(parseFrame)).toEqual([expected]) + expect(unsubscribed.frames.map(parseFrame)).toEqual([expected]) }) - it('broadcasts a directory-less remote envelope to every client holding any key of that server', () => { + it('broadcasts a directory-less remote envelope to every connected client', () => { const first = createCapturingClient() const second = createCapturingClient() const otherServer = createCapturingClient() @@ -107,8 +112,8 @@ describe('SSEAggregator remote envelope delivery', () => { const expected = { directory: null, serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } expect(first.frames.map(parseFrame)).toEqual([expected]) expect(second.frames.map(parseFrame)).toEqual([expected]) - expect(otherServer.frames).toHaveLength(0) - expect(local.frames).toHaveLength(0) + expect(otherServer.frames.map(parseFrame)).toEqual([expected]) + expect(local.frames.map(parseFrame)).toEqual([expected]) }) it('skips a targeted remote envelope once the client unsubscribed', () => { diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index a912e3ffb..5ea5cd275 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -836,6 +836,96 @@ describe('SSEAggregator directory-less session event routing', () => { }) }) +describe('SSEAggregator server-aware event feed', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('notifies server listeners for local events with the local server id and resolved directory', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + + expect(seen).toEqual([ + { serverId: 'local', directory: '/r', type: 'session.created' }, + { serverId: 'local', directory: '/r', type: 'session.execution.succeeded' }, + ]) + }) + + it('delivers remote events with the remote server id without touching local active or subagent state', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + sseAggregator.notifyServerEvent('srv_a', '/repo', { + id: 'evt_remote', + created: 1, + type: 'session.created', + location: { directory: '/repo' }, + data: { sessionID: 'ses_remote', parentID: 'ses_parent' }, + } as never) + + expect(seen).toEqual([{ serverId: 'srv_a', directory: '/repo', type: 'session.created' }]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(sseAggregator.isSubagentSession('ses_remote')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_remote', 'srv_a')).toBe(false) + }) + + it('isolates a throwing server event listener from the others', () => { + const throwing = vi.fn(() => { throw new Error('boom') }) + const healthy = vi.fn() + sseAggregator.onServerEvent(throwing) + const unsubscribe = sseAggregator.onServerEvent(healthy) + + sseAggregator.notifyServerEvent('srv_a', '/repo', { type: 'session.idle', data: { sessionID: 'ses_1' } } as never) + + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + + unsubscribe() + sseAggregator.notifyServerEvent('srv_a', '/repo', { type: 'session.idle', data: { sessionID: 'ses_1' } } as never) + expect(healthy).toHaveBeenCalledTimes(1) + }) + + it('notifies server-connected listeners for the local upstream and for remote streams', () => { + const seen: string[] = [] + sseAggregator.onServerConnected(serverId => { seen.push(serverId) }) + + ;(sseAggregator as unknown as { handleUpstreamOpen(wasConnectedBefore: boolean): void }).handleUpstreamOpen(false) + sseAggregator.notifyServerConnected('srv_a') + + expect(seen).toEqual(['local', 'srv_a']) + }) + + it('scopes session visibility to the active server id', () => { + sseAggregator.addClient('viewed-local', () => {}, () => {}, ['/r']) + sseAggregator.addClient('viewed-remote', () => {}, () => {}, ['srv_a|/r']) + + sseAggregator.setClientVisibility('viewed-local', true, 'ses_1') + sseAggregator.setClientVisibility('viewed-remote', true, 'ses_2', 'srv_a') + + expect(sseAggregator.isSessionBeingViewed('ses_1')).toBe(true) + expect(sseAggregator.isSessionBeingViewed('ses_1', 'srv_a')).toBe(false) + expect(sseAggregator.isSessionBeingViewed('ses_2', 'srv_a')).toBe(true) + expect(sseAggregator.isSessionBeingViewed('ses_2')).toBe(false) + }) + + it('clears the active server id when a client reports it is hidden', () => { + sseAggregator.addClient('viewed-hidden', () => {}, () => {}, ['srv_a|/r']) + sseAggregator.setClientVisibility('viewed-hidden', true, 'ses_1', 'srv_a') + sseAggregator.setClientVisibility('viewed-hidden', false) + + expect(sseAggregator.isSessionBeingViewed('ses_1', 'srv_a')).toBe(false) + }) +}) + describe('SSEAggregator location-scoped catalog event broadcast', () => { beforeEach(() => { sseAggregator.shutdown() diff --git a/backend/test/services/terminal.test.ts b/backend/test/services/terminal.test.ts index d9a1d71b1..ebc610b29 100644 --- a/backend/test/services/terminal.test.ts +++ b/backend/test/services/terminal.test.ts @@ -31,8 +31,13 @@ function createTerminalService( provider: CredentialProvider, openSocket?: (url: string) => WebSocket, baseUrl = 'http://127.0.0.1:5551', + includeLocalCredentials = true, ): TerminalService { - return new TerminalService(client, provider, () => baseUrl, openSocket) + return new TerminalService( + { client, baseUrl: () => baseUrl, includeLocalCredentials }, + provider, + openSocket, + ) } class FakeUpstreamSocket { @@ -167,6 +172,83 @@ describe('TerminalService', () => { }) }) + describe('remote target', () => { + it('does not inject local GitHub credentials when creating a terminal', async () => { + const create = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: createPty({ id: 'pty-remote', title: 'Terminal' }), + })) + const credentialProvider = createCredentialProvider({ GH_TOKEN: 'gh' }) + const service = createTerminalService( + createClient({ create }), + credentialProvider, + undefined, + 'https://remote.internal', + false, + ) + + await service.create('/repo', { kind: 'shell', name: 'Terminal', env: { FOO: 'bar' } }) + + expect(credentialProvider.getGhCliEnv).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledWith(expect.objectContaining({ env: { FOO: 'bar' } })) + }) + + it('keeps a remote shell environment empty without input env', async () => { + let captured: Record | undefined + const create = vi.fn(async (input: Record) => { + captured = input + return { location: { directory: '/repo' }, data: createPty() } + }) + const service = createTerminalService( + createClient({ create }), + createCredentialProvider({ GH_TOKEN: 'gh' }), + undefined, + 'https://remote.internal', + false, + ) + + await service.create('/repo', { kind: 'shell', name: 'Terminal' }) + + expect(captured?.env).toEqual({}) + }) + + it('builds the connect URL from the target base URL', async () => { + const list = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: [createPty({ id: 'pty-1' })], + })) + const token = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: { ticket: 'remote-ticket', expires_in: 60 }, + })) + const remoteClient = createClient({ list, connect: { token } }) + let opened: FakeUpstreamSocket | undefined + const service = createTerminalService( + createClient({}), + createCredentialProvider(), + (url) => { + opened = new FakeUpstreamSocket(url) + return opened as unknown as WebSocket + }, + 'https://local.internal', + ) + const remote = service.forTarget({ + client: remoteClient, + baseUrl: () => 'https://remote.internal', + includeLocalCredentials: false, + }) + + await remote.connect('/repo', 'pty-1', undefined, createPeer()) + + expect(list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + const url = new URL(opened!.url) + expect(url.protocol).toBe('wss:') + expect(url.host).toBe('remote.internal') + expect(url.pathname).toBe('/api/pty/pty-1/connect') + expect(url.searchParams.get('ticket')).toBe('remote-ticket') + }) + }) + describe('requireTerminal', () => { it('returns the terminal when the id exists in the directory', async () => { const list = vi.fn(async () => ({ diff --git a/frontend/src/App.permission-dialog.test.tsx b/frontend/src/App.permission-dialog.test.tsx index df89ba600..86a7edfa9 100644 --- a/frontend/src/App.permission-dialog.test.tsx +++ b/frontend/src/App.permission-dialog.test.tsx @@ -1,11 +1,12 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { PermissionRequest } from '@opencode-manager/shared/opencode' const mocks = vi.hoisted(() => ({ usePermissions: vi.fn(), getRepoIdForSession: vi.fn(), - useSession: vi.fn(), + sessionQueryOptions: vi.fn(), navigateToCurrent: vi.fn(), PermissionRequestDialog: vi.fn(), })) @@ -15,7 +16,7 @@ vi.mock('@/contexts/EventContext', () => ({ useEventContext: () => ({ getRepoIdForSession: mocks.getRepoIdForSession }), })) -vi.mock('@/hooks/useOpenCode', () => ({ useSession: mocks.useSession })) +vi.mock('@/hooks/useOpenCode', () => ({ sessionQueryOptions: mocks.sessionQueryOptions })) vi.mock('@/components/session/PermissionRequestDialog', () => ({ PermissionRequestDialog: mocks.PermissionRequestDialog, @@ -50,6 +51,7 @@ const localPermission: PendingPermission = { interface DialogProps { permission: PermissionRequest | null + sessionTitle?: string onOpenSession?: () => void } @@ -66,7 +68,12 @@ function renderWrapper(permission: PendingPermission) { currentIsFromDifferentSession: false, }) - render() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) return mocks.PermissionRequestDialog.mock.calls.at(-1)?.[0] as DialogProps } @@ -74,7 +81,11 @@ function renderWrapper(permission: PendingPermission) { describe('PermissionDialogWrapper', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useSession.mockReturnValue({ data: undefined }) + mocks.sessionQueryOptions.mockImplementation((sessionID: string | undefined, directory?: string, serverId?: string) => ({ + queryKey: ['session', sessionID ?? null, directory ?? null, serverId ?? null], + queryFn: async () => ({ title: 'Kitchen deploy' }), + enabled: !!sessionID, + })) mocks.PermissionRequestDialog.mockReturnValue(
) }) @@ -86,7 +97,18 @@ describe('PermissionDialogWrapper', () => { expect(props.permission).toEqual(remotePermission) expect(props.onOpenSession).toBe(mocks.navigateToCurrent) expect(mocks.getRepoIdForSession).not.toHaveBeenCalled() - expect(mocks.useSession).toHaveBeenCalledWith(undefined, '/srv/app') + expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_remote', '/srv/app', 'srv_kitchen') + }) + + it('resolves the remote session title from the permission server', async () => { + mocks.getRepoIdForSession.mockReturnValue(null) + + renderWrapper(remotePermission) + + await waitFor(() => { + const latest = mocks.PermissionRequestDialog.mock.calls.at(-1)?.[0] as DialogProps + expect(latest.sessionTitle).toBe('Kitchen deploy') + }) }) it('offers the session for a local permission resolved through its Manager repo', () => { @@ -96,7 +118,7 @@ describe('PermissionDialogWrapper', () => { expect(props.onOpenSession).toBe(mocks.navigateToCurrent) expect(mocks.getRepoIdForSession).toHaveBeenCalledWith('ses_local') - expect(mocks.useSession).toHaveBeenCalledWith('ses_local', '/srv/app') + expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_local', '/srv/app', 'local') }) it('hides the session action for a local permission without a Manager repo', () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f020528b6..071b656d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,13 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation, useParams, Navigate } from 'react-router-dom' import { useEffect, useRef, useCallback, type ReactNode } from 'react' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' -import { RemoteServer } from './pages/RemoteServer' +import { LinkedServerRedirect } from './pages/LinkedServerRedirect' +import { RemoteProject } from './pages/RemoteProject' import { SessionDetail } from './pages/SessionDetail' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' @@ -40,7 +41,8 @@ import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions' -import { useSession } from '@/hooks/useOpenCode' +import { useSidebarServerRouteSync } from '@/hooks/useSidebarServerSelection' +import { sessionQueryOptions } from '@/hooks/useOpenCode' import { RestartServerDialog } from '@/components/settings/RestartServerDialog' const queryClient = new QueryClient({ @@ -115,9 +117,12 @@ export function PermissionDialogWrapper() { } = usePermissions() const { getRepoIdForSession } = useEventContext() const isRemotePermission = currentPermission !== null && !isLocalOpenCodeServer(currentPermission.serverId) - const { data: session } = useSession( - isRemotePermission ? undefined : currentPermission?.sessionID, - currentDirectory ?? undefined, + const { data: session } = useQuery( + sessionQueryOptions( + currentPermission?.sessionID, + currentDirectory ?? undefined, + currentPermission?.serverId ?? LOCAL_OPENCODE_SERVER_ID, + ), ) const canOpenCurrentSession = currentPermission !== null @@ -160,11 +165,13 @@ function AppShell() { const rootRef = useRef(null) const { openSheet, open } = useMobileTabBar() + useSidebarServerRouteSync() + const swipeNav = useSwipeNavigation() const getRouteSwipeBackTarget = useCallback( - () => getSwipeBackTarget(location.pathname, location.search), - [location.pathname, location.search] + () => swipeNav?.backTarget() ?? getSwipeBackTarget(location.pathname, location.search), + [swipeNav, location.pathname, location.search] ) const canSwipeBack = useCallback( @@ -311,9 +318,14 @@ const router = createBrowserRouter([ }, { path: '/servers/:serverId', + element: , + loader: protectedLoader, + }, + { + path: '/servers/:serverId/projects', element: ( - + ), loader: protectedLoader, diff --git a/frontend/src/api/multiRuns.ts b/frontend/src/api/multiRuns.ts index 4c2aff15f..3409113ec 100644 --- a/frontend/src/api/multiRuns.ts +++ b/frontend/src/api/multiRuns.ts @@ -1,10 +1,21 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import type { + FuseMultiRunRequest, + LaunchMultiRunRequest, + MultiRun, + MultiRunTarget, +} from '@opencode-manager/shared/schemas' -export async function listMultiRuns(repoId: number): Promise { +function targetParams(target: MultiRunTarget): Record { + return target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory } +} + +export async function listMultiRuns(target: MultiRunTarget): Promise { const res = await fetchWrapper<{ runs: MultiRun[] }>(`${API_BASE_URL}/api/multi-runs`, { - params: { repoId }, + params: targetParams(target), }) return res.runs } diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 84a10121d..e8ba22f22 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -37,6 +37,14 @@ export type OpenCodeLocationInfo = Awaited>[number] +export type VcsInfo = Awaited>['data'] + +export type VcsFileStatus = Awaited>['data'][number] + +export type VcsFileDiff = Awaited>['data'][number] + +export type VcsDiffMode = 'working' | 'branch' | 'committed' + export interface SessionPage { items: SessionInfo[] nextCursor?: string @@ -333,6 +341,28 @@ export async function listSkills(directory?: string, serverId?: string): Promise return data } +export async function getVcsInfo(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.vcs.get(openCodeLocation(directory)), serverId) + return data +} + +export async function listVcsStatus(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.vcs.status(openCodeLocation(directory)), serverId) + return data +} + +export async function getVcsDiff( + directory: string, + mode: VcsDiffMode, + serverId?: string, +): Promise { + const { data } = await callOpenCode( + (api) => api.vcs.diff({ ...openCodeLocation(directory), mode }), + serverId, + ) + return data +} + export async function listPendingPermissions( directory: string, serverId?: string, diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 715c81777..2391b282c 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -24,6 +24,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + serverName: string | null lastRun: ScheduleRunSummary | null retainedWorktreeCount: number } @@ -32,6 +33,12 @@ export interface ScheduleRunWithContext extends ScheduleRun { jobName: string repoName: string repoPath: string + serverName: string | null +} + +export interface ScheduleServerTarget { + serverId: string + directory: string } export interface UnreadScheduleRun extends ScheduleRunWithContext { @@ -100,6 +107,9 @@ export async function getScheduleCounts(): Promise> { const counts = new Map() jobs.forEach((job) => { + if (job.repoId === null) { + return + } const existing = counts.get(job.repoId) if (existing) { existing.total += 1 @@ -185,3 +195,55 @@ export async function deleteRepoScheduleRun(repoId: number, jobId: number, runId method: 'DELETE', }) } + +function serverSchedulesPath(serverId: string): string { + return `${API_BASE_URL}/api/opencode-servers/${encodeURIComponent(serverId)}/schedules` +} + +export async function getServerSchedule(serverId: string, jobId: number): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}`) +} + +export async function createServerSchedule(serverId: string, data: CreateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(serverSchedulesPath(serverId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function updateServerSchedule(serverId: string, jobId: number, data: UpdateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function deleteServerSchedule(serverId: string, jobId: number): Promise { + return fetchWrapperVoid(`${serverSchedulesPath(serverId)}/${jobId}`, { method: 'DELETE' }) +} + +export async function runServerSchedule(serverId: string, jobId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/run`, { method: 'POST' }) +} + +export async function listServerScheduleRuns(serverId: string, jobId: number, limit: number = 20): Promise<{ runs: ScheduleRun[] }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs?limit=${limit}`) +} + +export async function getServerScheduleRun(serverId: string, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}`) +} + +export async function cancelServerScheduleRun(serverId: string, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}/cancel`, { method: 'POST' }) +} + +export async function clearServerScheduleRuns(serverId: string, jobId: number): Promise<{ cleared: number }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs`, { method: 'DELETE' }) +} + +export async function deleteServerScheduleRun(serverId: string, jobId: number, runId: number): Promise { + return fetchWrapperVoid(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/sessionGoals.ts b/frontend/src/api/sessionGoals.ts index e0d402a52..a42dd9802 100644 --- a/frontend/src/api/sessionGoals.ts +++ b/frontend/src/api/sessionGoals.ts @@ -1,10 +1,17 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { SessionGoal, StartSessionGoalRequest } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionGoal, + type StartSessionGoalRequest, +} from '@opencode-manager/shared/schemas' -export async function getLatestSessionGoal(sessionId: string): Promise { +export async function getLatestSessionGoal( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { const res = await fetchWrapper<{ goal: SessionGoal | null }>(`${API_BASE_URL}/api/session-goals`, { - params: { sessionId }, + params: { sessionId, serverId }, }) return res.goal } diff --git a/frontend/src/api/sessionPermissionModes.test.ts b/frontend/src/api/sessionPermissionModes.test.ts index a1d9e4e17..e6cac29cc 100644 --- a/frontend/src/api/sessionPermissionModes.test.ts +++ b/frontend/src/api/sessionPermissionModes.test.ts @@ -20,11 +20,23 @@ describe('sessionPermissionModes', () => { const result = await getSessionPermissionMode('ses_1') expect(result).toEqual(state) - expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining('/api/session-permission-modes/ses_1')) + const url = new URL(String(fetchMock.mock.calls[0][0])) + expect(url.pathname).toBe('/api/session-permission-modes/ses_1') + expect(url.searchParams.get('serverId')).toBe('local') expect(fetchMock.mock.calls[0][1].method).toBeUndefined() }) - it('calls PUT /api/session-permission-modes/:id with the mode and directory', async () => { + it('sends the remote server id when reading a remote session mode', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto', lockedReason: null } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + await getSessionPermissionMode('ses_1', 'srv_remote') + + const url = new URL(String(fetchMock.mock.calls[0][0])) + expect(url.searchParams.get('serverId')).toBe('srv_remote') + }) + + it('calls PUT /api/session-permission-modes/:id with the mode, directory and server', async () => { const input = { directory: '/repo', mode: 'auto' as const } const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) @@ -36,6 +48,16 @@ describe('sessionPermissionModes', () => { const options = fetchMock.mock.calls[0][1] expect(options.method).toBe('PUT') expect(options.headers['Content-Type']).toBe('application/json') - expect(JSON.parse(options.body)).toEqual(input) + expect(JSON.parse(options.body)).toEqual({ ...input, serverId: 'local' }) + }) + + it('sends the remote server id when writing a remote session mode', async () => { + const input = { directory: '/repo', mode: 'auto' as const } + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + await setSessionPermissionMode('ses_1', input, 'srv_remote') + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...input, serverId: 'srv_remote' }) }) }) diff --git a/frontend/src/api/sessionPermissionModes.ts b/frontend/src/api/sessionPermissionModes.ts index 0be4a141d..feb8c6b4d 100644 --- a/frontend/src/api/sessionPermissionModes.ts +++ b/frontend/src/api/sessionPermissionModes.ts @@ -1,23 +1,32 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionPermissionModeState, + type SetSessionPermissionModeRequest, +} from '@opencode-manager/shared/schemas' -export async function getSessionPermissionMode(sessionId: string): Promise { +export async function getSessionPermissionMode( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { return fetchWrapper( `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, + { params: { serverId } }, ) } export async function setSessionPermissionMode( sessionId: string, input: SetSessionPermissionModeRequest, + serverId: string = LOCAL_OPENCODE_SERVER_ID, ): Promise { return fetchWrapper( `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), + body: JSON.stringify({ ...input, serverId }), }, ) } diff --git a/frontend/src/api/terminals.test.tsx b/frontend/src/api/terminals.test.tsx new file mode 100644 index 000000000..75be4b3a6 --- /dev/null +++ b/frontend/src/api/terminals.test.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resizeTerminal, useCreateTerminal, useRemoveTerminal, useTerminals } from './terminals' + +const fetchMock = vi.fn() + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }) +} + +function createWrapper(queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function lastRequestUrl(): string { + return String(fetchMock.mock.calls.at(-1)?.[0]) +} + +describe('terminal api targets', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lists repo terminals through the repo route', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + + const { result } = renderHook( + () => useTerminals({ kind: 'repo', repoId: 1 }, '/repo', { enabled: true }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/repos/1/terminals') + expect(url.searchParams.get('directory')).toBe('/repo') + }) + + it('lists linked server terminals through the server route', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + + const { result } = renderHook( + () => useTerminals({ kind: 'server', serverId: 'srv_kitchen' }, '/home/me/app', { enabled: true }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals') + expect(url.searchParams.get('directory')).toBe('/home/me/app') + }) + + it('scopes the query key to the linked server', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const { result } = renderHook( + () => useTerminals({ kind: 'server', serverId: 'srv_kitchen' }, '/home/me/app', { enabled: true }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect( + queryClient.getQueryCache().findAll({ queryKey: ['terminals', 'srv_kitchen'] }), + ).toHaveLength(1) + }) + + it('creates a linked server terminal through the server route', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ id: 'pty-1', title: 'Terminal', kind: 'shell', cwd: '/home/me/app', status: 'running' }), + ) + + const { result } = renderHook( + () => useCreateTerminal({ kind: 'server', serverId: 'srv_kitchen' }), + { wrapper: createWrapper() }, + ) + + await act(() => result.current.mutateAsync({ directory: '/home/me/app' })) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('POST') + }) + + it('removes a linked server terminal with the directory', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + const { result } = renderHook( + () => useRemoveTerminal({ kind: 'server', serverId: 'srv_kitchen' }), + { wrapper: createWrapper() }, + ) + + await act(() => result.current.mutateAsync({ ptyID: 'pty 1', directory: '/home/me/app' })) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty%201') + expect(url.searchParams.get('directory')).toBe('/home/me/app') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('DELETE') + }) + + it('resizes a linked server terminal', async () => { + fetchMock.mockResolvedValue(jsonResponse({ success: true })) + + await resizeTerminal({ kind: 'server', serverId: 'srv_kitchen' }, 'pty-1', { + directory: '/home/me/app', + cols: 80, + rows: 24, + }) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty-1') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('PATCH') + }) +}) diff --git a/frontend/src/api/terminals.ts b/frontend/src/api/terminals.ts index 20e2c1fc9..44ee4c854 100644 --- a/frontend/src/api/terminals.ts +++ b/frontend/src/api/terminals.ts @@ -1,42 +1,47 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { CreateTerminalRequest, ResizeTerminalRequest, TerminalInfo } from '@opencode-manager/shared/types' import { API_BASE_URL } from '@/config' +import { terminalCollectionPath, terminalItemPath, terminalTargetScope, type TerminalTarget } from '@/lib/terminal-target' import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' -function terminalsQueryKey(repoId: number, directory: string | undefined) { - return ['terminals', repoId, directory ?? null] as const +function terminalsQueryKey(target: TerminalTarget, directory: string | undefined) { + return ['terminals', terminalTargetScope(target), directory ?? null] as const } -function terminalsRepoQueryKey(repoId: number) { - return ['terminals', repoId] as const +function terminalsScopeQueryKey(target: TerminalTarget) { + return ['terminals', terminalTargetScope(target)] as const } -async function listTerminals(repoId: number, directory?: string): Promise { +async function listTerminals(target: TerminalTarget, directory?: string): Promise { const data = await fetchWrapper<{ terminals: TerminalInfo[] }>( - `${API_BASE_URL}/api/repos/${repoId}/terminals`, + `${API_BASE_URL}${terminalCollectionPath(target)}`, { params: { directory } }, ) return data.terminals } -async function createTerminal(repoId: number, body: CreateTerminalRequest): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/terminals`, { +async function createTerminal(target: TerminalTarget, body: CreateTerminalRequest): Promise { + return fetchWrapper(`${API_BASE_URL}${terminalCollectionPath(target)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } -export async function resizeTerminal(repoId: number, ptyID: string, body: ResizeTerminalRequest): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}`, { +export async function resizeTerminal( + target: TerminalTarget, + ptyID: string, + body: ResizeTerminalRequest, +): Promise { + return fetchWrapperVoid(`${API_BASE_URL}${terminalItemPath(target, ptyID)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } -async function removeTerminal(repoId: number, ptyID: string, directory?: string): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}`, { +async function removeTerminal(target: TerminalTarget, ptyID: string, directory?: string): Promise { + return fetchWrapperVoid(`${API_BASE_URL}${terminalItemPath(target, ptyID)}`, { method: 'DELETE', params: { directory }, }) @@ -47,21 +52,21 @@ interface UseTerminalsOptions { refetchInterval?: number | false } -export function useTerminals(repoId: number, directory: string | undefined, options: UseTerminalsOptions) { +export function useTerminals(target: TerminalTarget, directory: string | undefined, options: UseTerminalsOptions) { return useQuery({ - queryKey: terminalsQueryKey(repoId, directory), - queryFn: () => listTerminals(repoId, directory), + queryKey: terminalsQueryKey(target, directory), + queryFn: () => listTerminals(target, directory), enabled: options.enabled, refetchInterval: options.refetchInterval, }) } -export function useCreateTerminal(repoId: number) { +export function useCreateTerminal(target: TerminalTarget) { const queryClient = useQueryClient() return useMutation({ - mutationFn: (body: CreateTerminalRequest) => createTerminal(repoId, body), + mutationFn: (body: CreateTerminalRequest) => createTerminal(target, body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: terminalsRepoQueryKey(repoId) }) + queryClient.invalidateQueries({ queryKey: terminalsScopeQueryKey(target) }) }, }) } @@ -71,12 +76,12 @@ interface RemoveTerminalVariables { directory?: string } -export function useRemoveTerminal(repoId: number) { +export function useRemoveTerminal(target: TerminalTarget) { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ ptyID, directory }: RemoveTerminalVariables) => removeTerminal(repoId, ptyID, directory), + mutationFn: ({ ptyID, directory }: RemoveTerminalVariables) => removeTerminal(target, ptyID, directory), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: terminalsRepoQueryKey(repoId) }) + queryClient.invalidateQueries({ queryKey: terminalsScopeQueryKey(target) }) }, }) } diff --git a/frontend/src/components/activity/ActivityStateIndicator.tsx b/frontend/src/components/activity/ActivityStateIndicator.tsx new file mode 100644 index 000000000..f235a5c83 --- /dev/null +++ b/frontend/src/components/activity/ActivityStateIndicator.tsx @@ -0,0 +1,30 @@ +import { ActivitySpinner } from '@/components/ui/activity-spinner' +import { cn } from '@/lib/utils' + +/** + * Renders nothing unless a repo or linked server has an active session, then shows the braille + * spinner in the state colour (info while running, warning when a session needs input). + */ +export function ActivityStateIndicator({ + state, + className, +}: { + state: 'needs-input' | 'running' | null + className?: string +}) { + if (state === null) return null + + return ( + + + + ) +} diff --git a/frontend/src/components/activity/RepoActivityIndicator.tsx b/frontend/src/components/activity/RepoActivityIndicator.tsx index e1de6906a..e2fcae2a2 100644 --- a/frontend/src/components/activity/RepoActivityIndicator.tsx +++ b/frontend/src/components/activity/RepoActivityIndicator.tsx @@ -1,23 +1,12 @@ -import { ActivitySpinner } from '@/components/ui/activity-spinner' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { useRepoActivityState } from '@/hooks/useActiveWork' -import { cn } from '@/lib/utils' import type { Repo } from '@/api/types' /** - * Renders nothing unless a session in the repo is active, then shows the braille spinner in the - * state colour (info while running, warning when a session needs input). + * Shows the repo's activity spinner while a session in it needs input or is running. */ export function RepoActivityIndicator({ repo, className }: { repo: Repo; className?: string }) { const state = useRepoActivityState(repo) - if (state === null) return null - return ( - - - - ) + return } diff --git a/frontend/src/components/file-browser/FileDiffView.tsx b/frontend/src/components/file-browser/FileDiffView.tsx index c4193ba23..481b02cfc 100644 --- a/frontend/src/components/file-browser/FileDiffView.tsx +++ b/frontend/src/components/file-browser/FileDiffView.tsx @@ -28,6 +28,19 @@ interface FileDiffViewProps { isMobile?: boolean; } +export interface FileDiffContentProps { + filePath: string; + diff: string | null; + status: GitFileStatusType; + additions: number; + deletions: number; + isBinary?: boolean; + onBack?: () => void; + onClose?: () => void; + onOpenFile?: (path: string, lineNumber?: number) => void; + isMobile?: boolean; +} + const statusConfig: Record< GitFileStatusType, { icon: typeof FileText; color: string; bgColor: string; label: string } @@ -70,54 +83,24 @@ const statusConfig: Record< }, }; -export function FileDiffView({ - repoId, +export function FileDiffContent({ filePath, - includeStaged, - commitHash, + diff, + status, + additions, + deletions, + isBinary = false, onBack, onClose, onOpenFile, isMobile = false, -}: FileDiffViewProps) { - const workingDiff = useFileDiff(repoId, filePath, includeStaged); - const commitDiff = useCommitFileDiff(repoId, commitHash, filePath); - const { data: diffData, isLoading, error } = commitHash ? commitDiff : workingDiff; - +}: FileDiffContentProps) { const fileName = filePath.split("/").pop() || filePath; const dirPath = filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""; - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- -

Failed to load diff

-

{error.message}

- {onBack && ( - - )} -
- ); - } - - if (!diffData) { - return null; - } - - const config = statusConfig[diffData.status]; + const config = statusConfig[status]; const Icon = config.icon; return ( @@ -178,15 +161,15 @@ export function FileDiffView({ > {config.label} - {!diffData.isBinary && ( + {!isBinary && ( <> - +{diffData.additions} - -{diffData.deletions} + +{additions} + -{deletions} )} - {diffData.diff && ( + {diff && (
- {diffData.isBinary ? ( + {isBinary ? (

Binary file - cannot display diff

- ) : !diffData.diff ? ( + ) : !diff ? (

No changes to display

) : (
); } + +export function FileDiffView({ + repoId, + filePath, + includeStaged, + commitHash, + onBack, + onClose, + onOpenFile, + isMobile = false, +}: FileDiffViewProps) { + const workingDiff = useFileDiff(repoId, filePath, includeStaged); + const commitDiff = useCommitFileDiff(repoId, commitHash, filePath); + const { data: diffData, isLoading, error } = commitHash ? commitDiff : workingDiff; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Failed to load diff

+

{error.message}

+ {onBack && ( + + )} +
+ ); + } + + if (!diffData) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/message/ComposerToolsMenu.test.tsx b/frontend/src/components/message/ComposerToolsMenu.test.tsx index 67a832a2a..fb9517f74 100644 --- a/frontend/src/components/message/ComposerToolsMenu.test.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ComposerToolsMenu } from './ComposerToolsMenu' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { useSessionPermissionMode, useSetSessionPermissionMode } from '@/hooks/useSessionPermissionMode' import { useSessionSandbox, useSetSessionSandbox } from '@/hooks/useSessionSandbox' import { FetchError } from '@/api/fetchWrapper' @@ -72,6 +73,25 @@ function renderMenu(overrides: Partial> = {}) { + const props = { + sessionID: 'ses_remote', + directory: '/remote/repo', + goalArmed: false, + goalDisabled: false, + goalLabel: 'Goal mode: the next message becomes the objective', + onToggleGoal: vi.fn(), + onAttachFile: vi.fn(), + ...overrides, + } + render( + + + , + ) + return props +} + describe('ComposerToolsMenu', () => { beforeEach(() => { vi.clearAllMocks() @@ -112,6 +132,18 @@ describe('ComposerToolsMenu', () => { expect(mutate).toHaveBeenCalledWith({ directory: '/repo', mode: 'auto' }) }) + it('offers permission and goal controls on a remote server without sandbox controls', async () => { + const user = userEvent.setup() + mockPermissionMode() + renderRemoteMenu() + + await user.click(screen.getByRole('button', { name: 'Composer options' })) + + expect(await screen.findByRole('menuitemcheckbox', { name: /Accept all permissions/ })).toBeInTheDocument() + expect(screen.getByRole('menuitemcheckbox', { name: /Goal mode/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitemcheckbox', { name: /Sandbox commands/ })).not.toBeInTheDocument() + }) + it('shows why permissions cannot be changed', async () => { const user = userEvent.setup() mockPermissionMode({ lockedReason: 'child' }) diff --git a/frontend/src/components/message/ComposerToolsMenu.tsx b/frontend/src/components/message/ComposerToolsMenu.tsx index 70b201f94..27094ade4 100644 --- a/frontend/src/components/message/ComposerToolsMenu.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { getIconToggleButtonClassName } from '@/components/ui/icon-toggle-button' +import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { usePermissionModeToggle } from '@/hooks/usePermissionModeToggle' import { useSandboxToggle } from '@/hooks/useSandboxToggle' import { shouldShowSandboxResources } from '@/lib/sandboxResources' @@ -25,7 +26,6 @@ interface ComposerToolsMenuProps { onToggleGoal: () => void onAttachFile: () => void onDictate?: () => void - allowManagerTools?: boolean } export function ComposerToolsMenu({ @@ -37,10 +37,10 @@ export function ComposerToolsMenu({ onToggleGoal, onAttachFile, onDictate, - allowManagerTools = true, }: ComposerToolsMenuProps) { - const permission = usePermissionModeToggle(sessionID, directory, { enabled: allowManagerTools }) - const sandbox = useSandboxToggle(sessionID, { enabled: allowManagerTools }) + const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId()) + const permission = usePermissionModeToggle(sessionID, directory) + const sandbox = useSandboxToggle(sessionID, { enabled: isLocalServer }) const [resourcesOpen, setResourcesOpen] = useState(false) return ( @@ -57,30 +57,28 @@ export function ComposerToolsMenu({ - {allowManagerTools ? ( - <> - - : } - title="Accept all permissions" - detail={permission.disabled ? permission.label : undefined} - /> - - setResourcesOpen(true)} /> - - } - title="Goal mode" - detail={goalDisabled ? goalLabel : undefined} - /> - - - - ) : null} + + : } + title="Accept all permissions" + detail={permission.disabled ? permission.label : undefined} + /> + + {isLocalServer && ( + setResourcesOpen(true)} /> + )} + + } + title="Goal mode" + detail={goalDisabled ? goalLabel : undefined} + /> + + } title="Attach image or PDF" /> diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 7702fdc9f..fff193da3 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -252,7 +252,7 @@ export const PromptInput = memo(forwardRef( const sessionAgent = useSessionAgent(sessionID, directory) const hasBackgroundActivity = useHasBackgroundActivity(sessionID) - const allowManagerTools = isLocalOpenCodeServer(useOpenCodeServerId()) + const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId()) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) @@ -260,8 +260,8 @@ export const PromptInput = memo(forwardRef( const startGoal = useStartSessionGoal() const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending || startGoal.isPending const interruptSession = useInterruptSession() - const { data: sessionGoal } = useSessionGoal(sessionID, { enabled: allowManagerTools }) - const { data: permissionMode } = useSessionPermissionMode(sessionID, { enabled: allowManagerTools }) + const { data: sessionGoal } = useSessionGoal(sessionID) + const { data: permissionMode } = useSessionPermissionMode(sessionID) const { searchCommands, findCommand, recentNames } = useCommands({ directory }) const recordCommand = useRecentCommandsStore((state) => state.recordCommand) const isExactCommandPrompt = (value: string) => { @@ -1579,10 +1579,10 @@ return ( onAgentChange={handleAgentChange} isBashMode={isBashMode} /> - {directory && !isMobile && allowManagerTools ? ( + {directory && !isMobile ? ( <> - + {isLocalServer && } void startVoiceRecording() : undefined} /> )} diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index 9a37fcf38..186e4eba6 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -5,7 +5,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter, useLocation } from 'react-router-dom' import type { ReactNode } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { DesktopSessionTree } from './DesktopSessionTree' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' import type { Repo } from '@/api/types' type SessionFixture = { @@ -90,6 +92,11 @@ vi.mock('@/hooks/useSessionPins', () => ({ useSessionPins: () => ({ data: sessionPinsData.current }), })) +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: () => ({ servers: [], isLoading: false }), + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ hasForSession: (sessionID: string) => permissionSessions.current.has(sessionID), @@ -156,6 +163,8 @@ const fetchedDirectories = () => new Set(sessionsHookCalls.flatMap((call) => cal describe('DesktopSessionTree', () => { beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) listReposMock.mockReset() listReposMock.mockResolvedValue([repoA, repoB, repoC]) getRepoSiblingsMock.mockReset() diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx index e7395b4e7..a31d6bf94 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -1,11 +1,15 @@ -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Search } from 'lucide-react' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Repo, Session } from '@/api/types' import { Input } from '@/components/ui/input' +import { RepoActivityIndicator } from '@/components/activity/RepoActivityIndicator' import { useNavigableRepos, useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' import { useSessionSearch } from '@/hooks/useSessionSearch' import { useSessionPins } from '@/hooks/useSessionPins' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { NewSessionButton, RepoNavGroup, @@ -21,7 +25,7 @@ import { isCurrentSessionItem, isRepoReady, } from '@/components/navigation/sidebar-session-tree' -import { getRepoPath } from '@/lib/navigation' +import { getRepoPath, getSessionPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' import { buildPinnedSessionKeys } from '@/lib/sessionKey' @@ -52,6 +56,28 @@ function SessionSearchInput({ value, onChange }: { value: string; onChange: (val ) } +function SessionTreeLayout({ + draft, + onDraftChange, + children, +}: { + draft: string + onDraftChange: (value: string) => void + children: ReactNode +}) { + return ( +
+
+ +
+ +
+ {children} +
+
+ ) +} + interface SessionSearchResultsProps { nameMatchedRepos: Repo[] otherRepos: Repo[] @@ -90,14 +116,21 @@ function SessionSearchResults({ {matchingGroups.map((group) => ( navigate(getRepoPath(group.repo.id))} - actions={} + indicator={} + actions={ + getSessionPath(group.repo.id, sessionId)} + onOpenSession={navigate} + /> + } > {group.items.map((item) => ( -
- -
+ + {isLoading ? ( + Loading repos... + ) : trimmedDraft ? ( + + ) : ( + navigate(getRepoPath(repoId))} + onSelectSession={navigate} + renderActions={(repo) => ( + getSessionPath(repo.id, sessionId)} + onOpenSession={navigate} + /> + )} + /> + )} + + ) +} -
- {isLoading ? ( - Loading repos... - ) : trimmedDraft ? ( - - ) : ( - navigate(getRepoPath(repoId))} - onSelectSession={navigate} - renderActions={(repo) => } - /> - )} -
-
+function RemoteSessionTree({ serverId }: { serverId: string }) { + const navigate = useNavigate() + const [draft, setDraft] = useState('') + + return ( + + + ) } + +/** + * The sidebar session navigator. Follows the selected server: local repos for this machine, or the + * selected linked server's projects with the same rows and search behaviour. + */ +export function DesktopSessionTree() { + const [selectedServerId] = useSidebarServerSelection() + + if (selectedServerId !== LOCAL_OPENCODE_SERVER_ID) { + return + } + + return +} diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index d5d17fb2a..a52b6d721 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -250,7 +250,7 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Logout')).toBeInTheDocument() }) - it('hides the Servers group when no linked servers exist', () => { + it('hides the server switcher when no linked servers exist', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -261,7 +261,7 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/']) }) - expect(screen.queryByText('Servers')).toBeNull() + expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() }) it('renders home, repos and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index ed9bc907d..2303c5e34 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -89,24 +89,31 @@ export function DesktopSidebar() { )} {collapsed ? ( -
- {collapsedItems.map((item: MoreDrawerItem) => ( - handleItemClick(item)} - danger={item.danger} - /> - ))} -
+ <> +
+ {collapsedItems.map((item: MoreDrawerItem) => ( + handleItemClick(item)} + danger={item.danger} + /> + ))} +
+ + ) : ( <> - +
+ +
-

Sessions

+

+ Sessions +

diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index caaf40e87..27d4cc4d3 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -1,11 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { LinkedServersNav } from './LinkedServersNav' +import { useSidebarServerRouteSync } from '@/hooks/useSidebarServerSelection' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' const mocks = vi.hoisted(() => ({ useOpenCodeServers: vi.fn(), useOpenCodeServerStatuses: vi.fn(), + useServerActivityState: vi.fn(), + useLocalActivityState: vi.fn(), })) vi.mock('@/hooks/useOpenCodeServers', () => ({ @@ -13,6 +19,11 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, })) +vi.mock('@/hooks/useActiveWork', () => ({ + useServerActivityState: mocks.useServerActivityState, + useLocalActivityState: mocks.useLocalActivityState, +})) + const kitchen = { id: 'srv_kitchen', name: 'Kitchen', @@ -26,53 +37,228 @@ const kitchen = { const office = { ...kitchen, id: 'srv_office', name: 'Office' } -function renderNav(pathname = '/') { +function LocationDisplay() { + const location = useLocation() + return
{`${location.pathname}${location.search}`}
+} + +function SidebarServerRouteSync() { + useSidebarServerRouteSync() + return null +} + +function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { return render( - + + + , ) } +function setServers(servers: Array) { + mocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false, isSuccess: true }) +} + +function triggerName(name: string) { + return `Switch server, current: ${name}` +} + describe('LinkedServersNav', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + setServers([]) + mocks.useServerActivityState.mockReturnValue(null) + mocks.useLocalActivityState.mockReturnValue(null) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], - statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, + statusById: { + srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null }, + srv_office: { serverId: 'srv_office', connected: false, version: null, error: null }, + }, isLoading: false, }) }) it('renders nothing when no linked servers exist', () => { - const { container } = renderNav() + renderNav() + + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() + }) + + it('renders nothing for disabled linked servers', () => { + setServers([{ ...kitchen, enabled: false }]) + + renderNav() + + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() + }) + + it('shows the local machine on the trigger by default', () => { + setServers([kitchen]) + + renderNav() - expect(container).toBeEmptyDOMElement() - expect(screen.queryByText('Servers')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() }) - it('lists enabled servers with a link and connection dot', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + it('shows the selected server name and connection dot on the trigger', async () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) renderNav() - expect(screen.getByText('Servers')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /Kitchen/ })).toHaveAttribute('href', '/servers/srv_kitchen') - expect(screen.getByRole('link', { name: /Office/ })).toHaveAttribute('href', '/servers/srv_office') + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Disconnected' })).toBeInTheDocument() }) - it('hides disabled servers', () => { - mocks.useOpenCodeServers.mockReturnValue({ - servers: [kitchen, { ...office, enabled: false }], - isLoading: false, - }) + it('lists the local machine and enabled servers in the menu', async () => { + const user = userEvent.setup() + setServers([kitchen, { ...office, enabled: false }]) + + renderNav() + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) + + expect(await screen.findByRole('menuitem', { name: /OC Manager/ })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() + }) + + it('selects a server without navigating', async () => { + const user = userEvent.setup() + setServers([kitchen, office]) + + renderNav() + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) + await user.click(await screen.findByRole('menuitem', { name: /Office/ })) + + expect(await screen.findByRole('button', { name: triggerName('Office') })).toBeInTheDocument() + expect(useSidebarServerStore.getState().serverId).toBe('srv_office') + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('selects the local machine without navigating', async () => { + const user = userEvent.setup() + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) renderNav() + await user.click(screen.getByRole('button', { name: triggerName('Kitchen') })) + await user.click(await screen.findByRole('menuitem', { name: /OC Manager/ })) + + expect(await screen.findByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('follows a linked server route', async () => { + setServers([kitchen]) + + renderNav('/servers/srv_kitchen/sessions/abc') + + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() + }) + + it('falls back to local when the persisted server is disabled', async () => { + useSidebarServerStore.setState({ serverId: 'srv_office' }) + setServers([kitchen, { ...office, enabled: false }]) + + renderNav() + + expect(await screen.findByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() + }) + + it('shows an attention dot for another server that needs input', async () => { + setServers([kitchen, office]) + mocks.useServerActivityState.mockImplementation((serverId: string) => + serverId === 'srv_office' ? 'needs-input' : null, + ) + + renderNav() + + expect(screen.getByRole('img', { name: 'Office needs input' })).toBeInTheDocument() + expect(screen.queryByRole('img', { name: 'Office is running' })).not.toBeInTheDocument() + }) + + it('shows a running attention dot for another running server', () => { + setServers([kitchen]) + mocks.useServerActivityState.mockReturnValue('running') + + renderNav() + + expect(screen.getByRole('img', { name: 'Kitchen is running' })).toBeInTheDocument() + }) + + it('shows local attention while a remote server is selected', () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useLocalActivityState.mockReturnValue('needs-input') + + renderNav() + + expect(screen.getByRole('img', { name: 'OC Manager needs input' })).toBeInTheDocument() + }) + + it('does not show an attention dot for the selected server', async () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useServerActivityState.mockReturnValue('needs-input') + + renderNav() + + await waitFor(() => + expect(screen.getByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument(), + ) + expect(screen.queryByRole('img', { name: 'Kitchen needs input' })).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Session needs input' })).toBeInTheDocument() + }) + + it('opens settings on the servers tab from the menu', async () => { + const user = userEvent.setup() + setServers([kitchen]) + + renderNav() + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) + await user.click(await screen.findByRole('menuitem', { name: /Manage servers/ })) + + expect(screen.getByTestId('location')).toHaveTextContent('/?settings=open&settingsTab=servers') + }) + + it('renders a single icon trigger in the collapsed rail', async () => { + const user = userEvent.setup() + setServers([kitchen]) + + renderNav('/', 'rail') + + const trigger = screen.getByRole('button', { name: triggerName('OC Manager') }) + expect(trigger).toBeInTheDocument() + expect(screen.queryByText('OC Manager')).not.toBeInTheDocument() + + await user.click(trigger) + expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + }) + + it('renders nothing in the collapsed rail when no linked servers exist', () => { + renderNav('/', 'rail') + + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() + }) + + it('renders a thin trigger row in the sheet variant', async () => { + const user = userEvent.setup() + setServers([kitchen]) + + renderNav('/', 'sheet') + + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - expect(screen.getByRole('link', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('link', { name: /Office/ })).not.toBeInTheDocument() + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 77436ed61..e0db4a056 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -1,51 +1,247 @@ -import { Link, useLocation } from 'react-router-dom' -import { Server } from 'lucide-react' +import { Check, ChevronDown, Laptop, Server, Settings2 } from 'lucide-react' +import { + LOCAL_OPENCODE_SERVER_ID, + type OpenCodeServer, + type OpenCodeServerStatus, +} from '@opencode-manager/shared/schemas' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' -import { SidebarSection } from '@/components/ui/sidebar' -import { getServerPath } from '@/lib/navigation' +import { useLocalActivityState, useServerActivityState } from '@/hooks/useActiveWork' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -export function LinkedServersNav() { - const location = useLocation() +const LOCAL_SERVER_LABEL = 'OC Manager' + +export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' + +interface LinkedServersNavProps { + /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `sheet` the mobile sheet row. */ + variant?: LinkedServersVariant +} + +type ActivityState = 'needs-input' | 'running' | null + +function ServerConnectionDot({ connected }: { connected: boolean }) { + const label = connected ? 'Connected' : 'Disconnected' + + return ( + + ) +} + +function LocalActivityIndicator() { + const activity = useLocalActivityState() + + return +} + +function ServerActivityIndicator({ serverId }: { serverId: string }) { + const activity = useServerActivityState(serverId) + + return +} + +function AttentionDot({ activity, label }: { activity: ActivityState; label: string }) { + if (activity === null) return null + + const needsInput = activity === 'needs-input' + + return ( + + ) +} + +function LocalAttentionDot() { + const activity = useLocalActivityState() + + return +} + +function ServerAttentionDot({ server }: { server: OpenCodeServer }) { + const activity = useServerActivityState(server.id) + + return +} + +function OtherServersAttention({ + servers, + selectedServerId, +}: { + servers: OpenCodeServer[] + selectedServerId: string +}) { + const otherServers = servers.filter((server) => server.id !== selectedServerId) + const showLocal = selectedServerId !== LOCAL_OPENCODE_SERVER_ID + + if (!showLocal && otherServers.length === 0) return null + + return ( + + {showLocal && } + {otherServers.map((server) => ( + + ))} + + ) +} + +function LocalServerMenuItem({ active, onSelect }: { active: boolean; onSelect: () => void }) { + const activity = useLocalActivityState() + + return ( + + + {LOCAL_SERVER_LABEL} + + {active && } + + ) +} + +function LinkedServerMenuItem({ + server, + active, + connected, + onSelect, +}: { + server: OpenCodeServer + active: boolean + connected: boolean + onSelect: () => void +}) { + const activity = useServerActivityState(server.id) + + return ( + + + {server.name} + + + {active && } + + ) +} + +function LinkedServersMenuContent({ + servers, + selectedServerId, + statusById, + onSelect, +}: { + servers: OpenCodeServer[] + selectedServerId: string + statusById: Record + onSelect: (serverId: string) => void +}) { + const { openTab } = useSettingsDialog() + + return ( + + onSelect(LOCAL_OPENCODE_SERVER_ID)} + /> + {servers.map((server) => ( + onSelect(server.id)} + /> + ))} + + openTab('servers')} className="gap-2"> + + Manage servers… + + + ) +} + +/** + * The server switcher. The trigger names the selected server and opens a menu that changes which + * server's projects and sessions the sidebar lists; selecting never navigates. Hidden when no + * linked server is enabled. + */ +export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) { const { servers, isLoading } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() + const [selectedServerId, selectServer] = useSidebarServerSelection() const enabledServers = servers.filter((server) => server.enabled) if (isLoading || enabledServers.length === 0) { return null } + const selectedServer = enabledServers.find((server) => server.id === selectedServerId) + const localActive = selectedServerId === LOCAL_OPENCODE_SERVER_ID + const selectedName = localActive ? LOCAL_SERVER_LABEL : selectedServer?.name ?? LOCAL_SERVER_LABEL + const isRail = variant === 'rail' + const connectionConnected = statusById[selectedServerId]?.connected ?? false + return ( - - {enabledServers.map((server) => { - const path = getServerPath(server.id) - const active = location.pathname === path || location.pathname.startsWith(`${path}/`) - const connected = statusById[server.id]?.connected ?? false - - return ( - - - {server.name} - - - ) - })} - + + + + + + ) } diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index 22d249fc9..018e19b0c 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -13,8 +13,10 @@ import userEvent from '@testing-library/user-event' import { describe, it, expect, beforeEach } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { MobileTabBar } from './MobileTabBar' import { useMobile } from '@/hooks/useMobile' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' function LocationSpy() { const { pathname, search } = useLocation() @@ -24,6 +26,8 @@ function LocationSpy() { describe('MobileTabBar', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) }) it('renders nothing when useMobile returns false', () => { @@ -194,6 +198,94 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('/assistant') }) + it('hides the tab bar on a linked server page path', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const { container } = render( + + + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders global tabs with Git on a linked server project page', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + const labels = screen.getAllByRole('button').map((button) => button.textContent) + expect(labels).toEqual(['Repos', 'Git', 'Assistant', 'More']) + expect(screen.queryByText('Files')).not.toBeInTheDocument() + expect(screen.queryByText('Schedules')).not.toBeInTheDocument() + }) + + it('opens the remote source control dialog from the Git tab on a linked server project page', async () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const user = userEvent.setup() + + render( + + + + + + + } /> + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Git' })) + expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen/projects') + expect(screen.getByTestId('location')).toHaveTextContent('directory=%2Fhome%2Fme%2Fapp') + expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') + }) + + it('opens the repos sheet from the Repos tab on a linked server project page', async () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const user = userEvent.setup() + + render( + + + + + + + } /> + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Repos' })) + expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=repos') + }) + + it('does not render tab bar on a linked server session path', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const { container } = render( + + + + + , + ) + expect(container.firstChild).toBeNull() + }) + it('renders schedule tabs on /repos/:id/schedules path', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() @@ -228,6 +320,22 @@ describe('MobileTabBar', () => { expect(screen.queryByText('Git')).not.toBeInTheDocument() }) + it('hides the Files tab on the home page when a linked server is selected', () => { + vi.mocked(useMobile).mockReturnValue(true) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + const queryClient = new QueryClient() + render( + + + + + , + ) + const labels = screen.getAllByRole('button').map((button) => button.textContent) + expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) + expect(screen.queryByText('Files')).not.toBeInTheDocument() + }) + it('renders tab bar on /schedules path', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 3267ddfb8..ea5816147 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -7,7 +7,14 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useUrlParams } from '@/hooks/useUrlParams' import { useScheduleUrlState, type ScheduleTab } from '@/hooks/useScheduleUrlState' import { useUnreadScheduleRuns } from '@/hooks/useSchedules' -import { getAssistantPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { + getAssistantPath, + isAssistantPath, + parseRepoRoute, + parseServerProjectRoute, +} from '@/lib/navigation' import { openDialogParam } from '@/hooks/useDialogParam' interface TabDef { @@ -27,50 +34,68 @@ interface GlobalTabsArgs { navigate: ReturnType isInsideRepo: boolean repoId: number | null + serverScope: ServerRouteScope updateParams: ReturnType['updateParams'] unreadScheduleCount: number + localServerSelected: boolean } type TabBarMode = 'hidden' | 'global' | 'schedule' +type ServerRouteScope = 'project' | null + interface MobileTabRouteState { mode: TabBarMode isInsideRepo: boolean repoId: number | null + serverScope: ServerRouteScope +} + +function getServerRouteScope(pathname: string, search: string): ServerRouteScope { + return parseServerProjectRoute(pathname, search) ? 'project' : null } -function getMobileTabRouteState(pathname: string): MobileTabRouteState { +function getMobileTabRouteState(pathname: string, search: string): MobileTabRouteState { if (isAssistantPath(pathname)) { - return { mode: 'global', isInsideRepo: false, repoId: null } + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope: null } } - const { repoId, section: repoSection } = parseRepoRoute(pathname) - if (pathname === '/' || pathname === '/schedules') { - return { mode: 'global', isInsideRepo: false, repoId: null } + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope: null } + } + + const serverScope = getServerRouteScope(pathname, search) + if (serverScope !== null) { + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope } } + const { repoId, section: repoSection } = parseRepoRoute(pathname) + if (repoId === null) { - return { mode: 'hidden', isInsideRepo: false, repoId: null } + return { mode: 'hidden', isInsideRepo: false, repoId: null, serverScope: null } } switch (repoSection) { case null: - return { mode: 'global', isInsideRepo: true, repoId } + return { mode: 'global', isInsideRepo: true, repoId, serverScope: null } case 'schedules': - return { mode: 'schedule', isInsideRepo: true, repoId } + return { mode: 'schedule', isInsideRepo: true, repoId, serverScope: null } default: - return { mode: 'hidden', isInsideRepo: false, repoId } + return { mode: 'hidden', isInsideRepo: false, repoId, serverScope: null } } } -function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, updateParams, unreadScheduleCount }: GlobalTabsArgs): TabDef[] { +function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, serverScope, updateParams, unreadScheduleCount, localServerSelected }: GlobalTabsArgs): TabDef[] { const openRepoDialog = (dialog: string) => { openDialogParam(updateParams, dialog) } const inRepo = isInsideRepo && repoId !== null + const inServerRoute = serverScope !== null const filesAreScoped = inRepo || isAssistantPath(pathname) + const showsFiles = !inServerRoute && (filesAreScoped || localServerSelected) + const showsGit = inRepo || serverScope === 'project' + const showsSchedules = !inRepo && serverScope !== 'project' const handleFilesClick = () => { if (filesAreScoped) { @@ -93,14 +118,16 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: () => open('repos'), active: openSheet === 'repos' || (pathname === '/' && !openSheet), }, - { - key: 'files', - label: 'Files', - icon: FolderOpen, - onClick: handleFilesClick, - active: openSheet === 'files', - }, - ...(inRepo + ...(showsFiles + ? [{ + key: 'files', + label: 'Files', + icon: FolderOpen, + onClick: handleFilesClick, + active: openSheet === 'files', + }] + : []), + ...(showsGit ? [{ key: 'source-control', label: 'Git', @@ -116,7 +143,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: handleAssistantClick, active: isAssistantPath(pathname) && !openSheet, }, - ...(!inRepo + ...(showsSchedules ? [{ key: 'schedules', label: 'Schedules', @@ -198,14 +225,16 @@ const TabBarRow = memo(function TabBarRow({ tabs }: TabBarRowProps) { }) export const MobileTabBar = memo(function MobileTabBar() { - const { pathname } = useLocation() + const { pathname, search } = useLocation() const navigate = useNavigate() const { openSheet, open, close } = useMobileTabBar() const { updateParams } = useUrlParams() const { scheduleTab, setScheduleTab } = useScheduleUrlState() const { data: unreadRuns } = useUnreadScheduleRuns() const isMobile = useMobile() - const routeState = useMemo(() => getMobileTabRouteState(pathname), [pathname]) + const [selectedServerId] = useSidebarServerSelection() + const localServerSelected = selectedServerId === LOCAL_OPENCODE_SERVER_ID + const routeState = useMemo(() => getMobileTabRouteState(pathname, search), [pathname, search]) const unreadScheduleCount = unreadRuns?.total ?? 0 const tabs = useMemo( @@ -219,8 +248,10 @@ export const MobileTabBar = memo(function MobileTabBar() { navigate, isInsideRepo: routeState.isInsideRepo, repoId: routeState.repoId, + serverScope: routeState.serverScope, updateParams, unreadScheduleCount, + localServerSelected, })), [ routeState, @@ -233,6 +264,7 @@ export const MobileTabBar = memo(function MobileTabBar() { navigate, updateParams, unreadScheduleCount, + localServerSelected, ], ) diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index 1e40ef2e0..1f4032abc 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -415,15 +415,15 @@ describe('MoreDrawer', () => { expect(screen.getByText('Skills')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() + expect(screen.getByText('Terminal')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument() + expect(screen.getByText('Repos')).toBeInTheDocument() expect(screen.queryByText('Files')).not.toBeInTheDocument() - expect(screen.queryByText('Source Control')).not.toBeInTheDocument() - expect(screen.queryByText('Terminal')).not.toBeInTheDocument() + expect(screen.getByText('Source Control')).toBeInTheDocument() expect(screen.queryByText('Preview')).not.toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() expect(screen.queryByText('Reset Permissions')).not.toBeInTheDocument() - expect(screen.queryByText('Repos')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Activity' })).not.toBeInTheDocument() }) it('shows unread reports and marks them all read', () => { diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 83582333c..2499d1229 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -14,7 +14,7 @@ import { useMarkAllScheduleRunsViewed, useUnreadScheduleRuns } from '@/hooks/use import { buildMoreItems, buildNavModel, isManagerOnlyNavItem, isSessionDetailPath, type MoreDrawerItem } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' import { cn, getRepoDisplayName } from '@/lib/utils' -import { getPathWithReturnTo, isAssistantPath } from '@/lib/navigation' +import { getPathWithReturnTo, isAssistantPath, parseServerSessionRoute } from '@/lib/navigation' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' @@ -77,6 +77,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = isSessionDetailPath(location.pathname) + const isRemoteSessionDetail = parseServerSessionRoute(location.pathname) !== null const isAssistantRoute = isAssistantPath(location.pathname) const isAssistantSession = isSessionDetail && searchParams.get('assistant') === '1' const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) @@ -205,11 +206,11 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { />
)} - {isOpen && !isRemoteRoute && ( + {isOpen && ( navigate(path)} /> )} - {isSessionDetail && !isRemoteRoute && ( + {(isSessionDetail || isRemoteSessionDetail) && ( openMobileSheet('repos')} trailing /> )} {assistantCta?.to && ( diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx new file mode 100644 index 000000000..33b0181b2 --- /dev/null +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { OpenCodeProject } from '@/api/opencode' +import { RemoteProjectNavList } from './RemoteProjectNav' + +type SessionFixture = { + id: string + projectID: string + title: string + time: { created: number; updated: number; archived?: number } + location: { directory: string } + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } +} + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), + useSessionsAcrossDirectories: vi.fn(), + useCreateSession: vi.fn(), + sessionsCalls: [] as Array<{ directories: string[]; options?: { serverId?: string } }>, + createCalls: [] as Array<{ directory?: string; serverId?: string }>, + sessions: [] as SessionFixture[], + hasNextPage: { current: false }, +})) + +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: mocks.listProjects } +}) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: ( + directories: string[], + options?: { serverId?: string }, + ) => { + mocks.sessionsCalls.push({ directories, options }) + return { + data: mocks.sessions.filter((session) => directories.includes(session.location.directory)), + isLoading: false, + isError: false, + hasNextPage: mocks.hasNextPage.current, + } + }, + useCreateSession: ( + directory: string, + onSuccess?: (session: { id: string }) => void, + serverId?: string, + ) => ({ + mutate: () => { + mocks.createCalls.push({ directory, serverId }) + onSuccess?.({ id: 'new-session' }) + }, + isPending: false, + }), + } +}) + +vi.mock('@/hooks/useActiveWork', () => ({ + useProjectActivityState: () => null, +})) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ hasForSession: () => false }), + useForms: () => ({ hasForSession: () => false }), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +function createProject(id: string, name: string | undefined, canonical: string): OpenCodeProject { + return { + id, + canonical, + name, + time: { created: 1, updated: 2 }, + sandboxes: [], + } as OpenCodeProject +} + +function createSession(id: string, directory: string, updated = Date.now()): SessionFixture { + return { + id, + projectID: 'proj-1', + title: `Session ${id}`, + time: { created: updated - 10000, updated }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +const alpha = createProject('p1', 'Alpha', '/projects/alpha') +const beta = createProject('p2', undefined, '/projects/beta') + +function LocationSpy() { + const { pathname, search } = useLocation() + return
{`${pathname}${search}`}
+} + +function createWrapper(initialEntry: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + } + /> + + + + ) +} + +const toggleFor = (name: string) => screen.getByRole('button', { name: `Show sessions in ${name}` }) + +function RemoteListHarness({ serverId }: { serverId: string }) { + const navigate = useNavigate() + return ( + + ) +} + +describe('RemoteProjectNavList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.sessionsCalls.splice(0, mocks.sessionsCalls.length) + mocks.createCalls.splice(0, mocks.createCalls.length) + mocks.sessions = [createSession('a1', '/projects/alpha', Date.now())] + mocks.hasNextPage.current = false + mocks.listProjects.mockResolvedValue([alpha, beta]) + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, + isLoading: false, + }) + }) + + it('renders project names with their directory and activity indicator slot', async () => { + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('beta')).toBeInTheDocument() + expect(screen.getByText('/projects/alpha')).toBeInTheDocument() + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + + it('expands the project of the current project route and loads its sessions', async () => { + render(, { + wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), + }) + + await waitFor(() => expect(toggleFor('Alpha')).toHaveAttribute('aria-expanded', 'true')) + expect(await screen.findByRole('button', { name: /Session a1/ })).toBeInTheDocument() + expect(mocks.sessionsCalls.some((call) => call.directories.includes('/projects/alpha'))).toBe(true) + expect(mocks.sessionsCalls[0]?.options?.serverId).toBe('srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: /Session a1/ })) + expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen/sessions/a1') + }) + + it('opens a project when its name is clicked', async () => { + render(, { wrapper: createWrapper('/') }) + + fireEvent.click(await screen.findByRole('button', { name: /^Alpha/ })) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) + }) + + it('expands another project and shows its sessions', async () => { + mocks.sessions = [ + createSession('a1', '/projects/alpha', Date.now()), + createSession('b1', '/projects/beta', Date.now() - 1000), + ] + + render(, { wrapper: createWrapper('/') }) + + await screen.findByText('beta') + fireEvent.click(toggleFor('beta')) + + expect(await screen.findByRole('button', { name: /Session b1/ })).toBeInTheDocument() + expect(toggleFor('beta')).toHaveAttribute('aria-expanded', 'true') + }) + + it('creates a session on the selected server and opens it', async () => { + render(, { wrapper: createWrapper('/') }) + + fireEvent.click(await screen.findByRole('button', { name: 'New session in beta' })) + + expect(mocks.createCalls).toContainEqual({ directory: '/projects/beta', serverId: 'srv_kitchen' }) + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/sessions/new-session', + ) + }) + + it('offers All sessions when the project has another page', async () => { + mocks.hasNextPage.current = true + + render(, { + wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), + }) + + fireEvent.click(await screen.findByText('All sessions')) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) + }) + + it('shows a disabled state for a disabled server', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...kitchen, enabled: false }], + isLoading: false, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(screen.getByText('Server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('reports a failed project load', async () => { + mocks.listProjects.mockRejectedValue(new Error('offline')) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Could not load projects')).toBeInTheDocument() + }) + + it('reports an unreachable server on a failed project load', async () => { + mocks.listProjects.mockRejectedValue(new Error('offline')) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: false, version: null, error: 'boom' } }, + isLoading: false, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Server unreachable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx new file mode 100644 index 000000000..d3b733911 --- /dev/null +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -0,0 +1,435 @@ +import { useEffect, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { listProjects, type OpenCodeProject } from '@/api/opencode' +import type { Session } from '@/api/types' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' +import { useSessionsAcrossDirectories, sessionQueryOptions } from '@/hooks/useOpenCode' +import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' +import { useProjectActivityState } from '@/hooks/useActiveWork' +import { useSessionSearch } from '@/hooks/useSessionSearch' +import { serverScopedKey } from '@/lib/serverScope' +import { + getServerProjectPath, + getServerSessionPath, + parseServerProjectRoute, + parseServerSessionRoute, +} from '@/lib/navigation' +import { getDirectoryName } from '@/lib/utils' +import { getSessionKey } from '@/lib/sessionKey' +import { + NewSessionButton, + RepoNavGroup, + SessionNavLink, + SessionNavRow, + SessionNavStatus, +} from '@/components/navigation/RepoSessionNav' +import { + SIDEBAR_SESSIONS_PER_REPO, + isCurrentSessionItem, + normalizeDirectory, + type SidebarSessionItem, +} from '@/components/navigation/sidebar-session-tree' + +function projectName(project: OpenCodeProject): string { + return project.name?.trim() || getDirectoryName(project.canonical) +} + +function buildRemoteItem(session: Session, serverId: string, projectLabel: string): SidebarSessionItem { + return { + key: getSessionKey(session), + session, + repoId: 0, + serverId, + repoLabel: projectLabel, + branchLabel: null, + isWorktree: false, + isPinned: false, + path: getServerSessionPath(serverId, session.id), + } +} + +function RemoteProjectActivityIndicator({ serverId, directory }: { serverId: string; directory: string }) { + const state = useProjectActivityState(serverId, directory) + + return +} + +function useActiveRemoteDirectory(serverId: string): string | null { + const { pathname, search } = useLocation() + const projectRoute = parseServerProjectRoute(pathname, search) + const sessionRoute = parseServerSessionRoute(pathname) + const sessionID = sessionRoute?.serverId === serverId ? sessionRoute.sessionId : undefined + const { data: session } = useQuery({ + ...sessionQueryOptions(sessionID, undefined, serverId), + enabled: Boolean(sessionID), + }) + + if (projectRoute?.serverId === serverId) return projectRoute.directory + return session?.location.directory ?? null +} + +interface RemoteSessionRowsProps { + serverId: string + projectLabel: string + sessions: Session[] + isLoading: boolean + isError: boolean + hasMore?: boolean + allSessionsPath?: string + onSelectSession: (path: string) => void +} + +function RemoteSessionRows({ + serverId, + projectLabel, + sessions, + isLoading, + isError, + hasMore, + allSessionsPath, + onSelectSession, +}: RemoteSessionRowsProps) { + const location = useLocation() + const visibleSessions = useMemo( + () => sessions.filter((session) => !session.time.archived), + [sessions], + ) + + if (isLoading) return Loading sessions... + if (isError) return Failed to load sessions + if (visibleSessions.length === 0) return No sessions + + return ( + <> + {visibleSessions.map((session) => { + const item = buildRemoteItem(session, serverId, projectLabel) + return ( + + ) + })} + {hasMore && allSessionsPath && ( + onSelectSession(allSessionsPath)}>All sessions + )} + + ) +} + +interface RemoteProjectSessionsProps { + serverId: string + project: OpenCodeProject + onSelectSession: (path: string) => void +} + +function RemoteProjectSessions({ serverId, project, onSelectSession }: RemoteProjectSessionsProps) { + const { data, isLoading, isError, hasNextPage } = useSessionsAcrossDirectories([project.canonical], { + serverId, + limit: SIDEBAR_SESSIONS_PER_REPO, + }) + + return ( + + ) +} + +interface RemoteProjectGroupProps { + serverId: string + project: OpenCodeProject + isOpen: boolean + isCurrent: boolean + onToggle?: () => void + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void + children: React.ReactNode +} + +function RemoteProjectGroup({ + serverId, + project, + isOpen, + isCurrent, + onToggle, + onOpenProject, + onSelectSession, + children, +}: RemoteProjectGroupProps) { + const name = projectName(project) + + return ( + onOpenProject(getServerProjectPath(serverId, project.canonical))} + onToggle={onToggle} + indicator={} + actions={ + getServerSessionPath(serverId, sessionId)} + onOpenSession={onSelectSession} + /> + } + > + {children} + + ) +} + +interface RemoteProjectGroupsProps { + serverId: string + projects: OpenCodeProject[] + activeDirectory: string | null + isVisible: boolean + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectGroups({ + serverId, + projects, + activeDirectory, + isVisible, + onOpenProject, + onSelectSession, +}: RemoteProjectGroupsProps) { + const [expandedProjectId, setExpandedProjectId] = useState(null) + const normalizedActiveDirectory = normalizeDirectory(activeDirectory) + + useEffect(() => { + if (!isVisible || !normalizedActiveDirectory) return + const match = projects.find( + (project) => normalizeDirectory(project.canonical) === normalizedActiveDirectory, + ) + if (match) setExpandedProjectId(match.id) + }, [isVisible, normalizedActiveDirectory, projects]) + + return ( + <> + {projects.map((project) => { + const isExpanded = expandedProjectId === project.id + return ( + + setExpandedProjectId((current) => (current === project.id ? null : project.id)) + } + onOpenProject={onOpenProject} + onSelectSession={onSelectSession} + > + + + ) + })} + + ) +} + +interface RemoteProjectSearchResultsProps { + serverId: string + projects: OpenCodeProject[] + activeDirectory: string | null + search: string + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectSearchResults({ + serverId, + projects, + activeDirectory, + search, + onOpenProject, + onSelectSession, +}: RemoteProjectSearchResultsProps) { + const directories = useMemo(() => projects.map((project) => project.canonical), [projects]) + const { + setQuery, + filteredSessions, + isLoading, + isError, + } = useSessionSearch(directories, { + limit: SIDEBAR_SESSIONS_PER_REPO, + serverId, + initialQuery: search, + }) + + useEffect(() => { + setQuery(search) + }, [search, setQuery]) + + const needle = search.trim().toLowerCase() + const normalizedActiveDirectory = normalizeDirectory(activeDirectory) + + const nameMatchedProjects = useMemo( + () => + projects.filter( + (project) => + projectName(project).toLowerCase().includes(needle) || + project.canonical.toLowerCase().includes(needle), + ), + [projects, needle], + ) + + const sessionsByProjectId = useMemo(() => { + const byDirectory = new Map() + for (const session of filteredSessions) { + const directory = normalizeDirectory(session.location.directory) + if (!directory) continue + const list = byDirectory.get(directory) + if (list) list.push(session) + else byDirectory.set(directory, [session]) + } + + const byProjectId = new Map() + for (const project of projects) { + const sessions = byDirectory.get(normalizeDirectory(project.canonical) ?? '') + if (sessions) byProjectId.set(project.id, sessions) + } + return byProjectId + }, [projects, filteredSessions]) + + const visibleProjects = useMemo(() => { + const nameMatchedIds = new Set(nameMatchedProjects.map((project) => project.id)) + return projects.filter( + (project) => nameMatchedIds.has(project.id) || sessionsByProjectId.has(project.id), + ) + }, [projects, nameMatchedProjects, sessionsByProjectId]) + + if (isLoading) return Loading sessions... + if (isError) return Failed to load sessions + if (visibleProjects.length === 0) return No sessions found + + return ( + <> + {visibleProjects.map((project) => { + const matchedSessions = sessionsByProjectId.get(project.id) + return ( + + {matchedSessions ? ( + + ) : ( + + )} + + ) + })} + + ) +} + +interface RemoteProjectNavListProps { + serverId: string + isVisible: boolean + search?: string + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +/** + * The linked server's projects in the sidebar, styled like the local repo groups and expanded to + * the current project or session. Searching filters project names and that server's sessions. + */ +export function RemoteProjectNavList({ + serverId, + isVisible, + search = '', + onOpenProject, + onSelectSession, +}: RemoteProjectNavListProps) { + const { servers, isLoading: isServersLoading } = useOpenCodeServers() + const { statusById } = useOpenCodeServerStatuses() + const server = servers.find((candidate) => candidate.id === serverId) + const activeDirectory = useActiveRemoteDirectory(serverId) + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + const trimmedSearch = search.trim() + + if (isServersLoading) return Loading projects... + if (!server) return Server not found + if (!server.enabled) return Server is disabled + if (projectsQuery.isLoading) return Loading projects... + if (projectsQuery.isError) { + return ( + + {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} + + ) + } + if (projects.length === 0) return No projects + + if (trimmedSearch) { + return ( + + ) + } + + return ( + + ) +} diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 14641d520..021aaf119 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -1,11 +1,15 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, it, expect, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' import { getRepoSiblings, listRepos } from '@/api/repos' +import { listProjects, type OpenCodeProject } from '@/api/opencode' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' import type { ActiveWorkItem } from '@/lib/activeWork' type SessionFixture = { @@ -38,14 +42,32 @@ const activityMocks = vi.hoisted(() => ({ useActivityWindowMinutes: vi.fn(), })) +const serverMocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: serverMocks.useOpenCodeServers, + useOpenCodeServerStatuses: serverMocks.useOpenCodeServerStatuses, +})) + vi.mock('@/hooks/useActiveWork', () => ({ useActiveWork: activityMocks.useActiveWork, useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, useRepoActivityState: () => null, + useServerActivityState: () => null, + useLocalActivityState: () => null, + useProjectActivityState: () => null, })) vi.mock('@/api/repos') +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: vi.fn() } +}) + vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ hasForSession: (id: string) => permissionSessions.current.has(id), @@ -132,6 +154,43 @@ function setActivityGroups(groups: { }) } +function createLinkedServer(id: string, name: string, enabled = true) { + return { + id, + name, + baseUrl: `https://${id}.example.com`, + username: 'opencode', + enabled, + hasPassword: true, + createdAt: 1, + updatedAt: 2, + } +} + +function createProject(id: string, name: string, canonical: string): OpenCodeProject { + return { + id, + canonical, + name, + time: { created: 1, updated: 2 }, + sandboxes: [], + } as OpenCodeProject +} + +function setLinkedServers(servers: Array>) { + serverMocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) + serverMocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: Object.fromEntries( + servers.map((server) => [ + server.id, + { serverId: server.id, connected: true, version: null, error: null }, + ]), + ), + isLoading: false, + }) +} + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -167,12 +226,16 @@ function MobileSheetHarness() { describe('RepoQuickSwitchSheet', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) vi.mocked(getRepoSiblings).mockResolvedValue([]) + vi.mocked(listProjects).mockResolvedValue([]) sessionsData.splice(0, sessionsData.length) sessionsHookCalls.splice(0, sessionsHookCalls.length) permissionSessions.current.clear() activityMocks.useActivityWindowMinutes.mockReturnValue(30) setActivityGroups({}) + setLinkedServers([]) }) it('renders with loading state', async () => { @@ -927,4 +990,107 @@ describe('RepoQuickSwitchSheet', () => { expect(pathname).toBe('/repos/2/sessions/new-session') expect(handleClose).toHaveBeenCalled() }) + + it('renders the server switcher when enabled linked servers exist', async () => { + const user = userEvent.setup() + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + setLinkedServers([ + createLinkedServer('srv_kitchen', 'Kitchen'), + createLinkedServer('srv_office', 'Office', false), + ]) + + render(, { wrapper: createWrapper() }) + + const trigger = await screen.findByRole('button', { name: 'Switch server, current: OC Manager' }) + expect(trigger).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() + + await user.click(trigger) + + expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + }) + + it('hides the server switcher when no linked servers exist', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() + }) + + it('switches to a linked server without navigating or closing the sheet', async () => { + const user = userEvent.setup() + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) + setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) + const handleClose = vi.fn() + + render( + + + + + + + + } + /> + + + , + ) + + await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) + + expect(await screen.findByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('repo1')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Add repository/ })).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') + expect(handleClose).not.toHaveBeenCalled() + }) + + it('opens a remote project from the sheet and closes it', async () => { + const user = userEvent.setup() + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) + setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) + const handleClose = vi.fn() + + render( + + + + + + + + } + /> + + + , + ) + + await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) + await user.click(await screen.findByRole('button', { name: /^Alpha/ })) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) + expect(handleClose).toHaveBeenCalled() + }) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index 2fe8bae10..e1b586625 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { Input } from '@/components/ui/input' import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' @@ -9,10 +10,13 @@ import { getRepoDisplayName } from '@/lib/utils' import { AddRepoDialog } from '@/components/repo/AddRepoDialog' import { FolderGit2, Plus } from 'lucide-react' import { useUrlParams } from '@/hooks/useUrlParams' -import { getAssistantPath, getRepoPath } from '@/lib/navigation' +import { getAssistantPath, getRepoPath, getSessionPath } from '@/lib/navigation' import { NewSessionButton, RepoSessionNavList, SearchClearButton } from '@/components/navigation/RepoSessionNav' +import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { getActiveRepoId } from '@/components/navigation/sidebar-session-tree' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' interface RepoQuickSwitchSheetProps { isOpen: boolean @@ -25,6 +29,8 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const { searchParams } = useUrlParams() const [searchQuery, setSearchQuery] = useState('') const [addRepoOpen, setAddRepoOpen] = useState(false) + const [selectedServerId] = useSidebarServerSelection() + const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID const activeRepoId = getActiveRepoId(location.pathname) const { repos, isLoading, refetch } = useNavigableRepos(isOpen) @@ -80,26 +86,41 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr /> {searchQuery.length > 0 && setSearchQuery('')} />}
- + {!isRemote && ( + + )} + {isOpen && ( +
+ +
+ )} {isOpen && !searchQuery.trim() && ( navigateAndClose(path)} /> )} - {isLoading ? ( + {isRemote ? ( + navigateAndClose(path, { replace: true })} + onSelectSession={(path) => navigateAndClose(path)} + /> + ) : isLoading ? (
{Array.from({ length: 5 }).map((_, i) => (
@@ -117,7 +138,14 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr isVisible={isOpen} onOpenRepo={handleClick} onSelectSession={(path) => navigateAndClose(path)} - renderActions={(repo) => navigateAndClose(path)} />} + renderActions={(repo) => ( + getSessionPath(repo.id, sessionId)} + onOpenSession={(path) => navigateAndClose(path)} + /> + )} /> )} } -function SessionNavLink({ children, onClick }: { children: ReactNode; onClick: () => void }) { +export function SessionNavLink({ children, onClick }: { children: ReactNode; onClick: () => void }) { return ( +
+ ), })) vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewWorkspace: () =>
preview-tool
})) vi.mock('@/components/schedules/RepoSchedulesContent', () => ({ @@ -317,6 +327,16 @@ describe('ToolSidePanel', () => { expect(search().has('terminal')).toBe(false) }) + it('closes the docked terminal panel when the last terminal exits', async () => { + const user = userEvent.setup() + renderAt('?panel=terminal&terminal=pty-1') + + await user.click(await screen.findByRole('button', { name: 'Exit last terminal' })) + + await waitFor(() => expect(search().has('panel')).toBe(false)) + expect(search().has('terminal')).toBe(false) + }) + it('falls back to the dialog when not docked', async () => { renderAt('?panel=sourceControl', false) @@ -360,12 +380,43 @@ describe('ToolSidePanel', () => { within(rail) .getAllByRole('button') .map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()), - ).toEqual(['Activity', 'MCP', 'Skills']) + ).toEqual(['Activity', 'MCP', 'Skills', 'Source Control', 'Terminal']) await user.click(screen.getByRole('button', { name: 'MCP' })) expect(await screen.findByText('mcp-tool')).toBeInTheDocument() }) + it('opens the remote source control panel for a linked-server session', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Source Control' })) + + expect( + await screen.findByText('remote-source-control-tool srv_kitchen /home/me/app'), + ).toBeInTheDocument() + expect(search().get('panel')).toBe('sourceControl') + expect(screen.queryByText('source-control-tool')).not.toBeInTheDocument() + }) + + it('opens the skills panel for a linked-server session', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Skills' })) + + expect(await screen.findByText('skills-tool')).toBeInTheDocument() + expect(search().get('panel')).toBe('skills') + }) + it('opens the Activity panel with rows and the window label when clicked', async () => { const user = userEvent.setup() setActivityGroups([createActivityItem('other', 'Other Session', 2, 'Beta')]) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index e65d0eb5c..930571d4b 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import { FileBrowser } from '@/components/file-browser/FileBrowser' import { SourceControlContent } from '@/components/source-control' +import { RemoteSourceControlContent } from '@/components/source-control/RemoteSourceControlContent' import { TerminalWorkspace } from '@/components/terminal/TerminalPanel' import { ChangesWalkthroughCancel, @@ -26,6 +27,7 @@ import { useOpenNavItem } from '@/hooks/useOpenNavItem' import { ActivityList } from '@/components/activity/ActivityList' import { ActivityCountPill } from '@/components/activity/ActivityCountPill' import { withQueryParam } from '@/lib/navigation' +import type { TerminalTarget } from '@/lib/terminal-target' import { useActiveWorkCounts, useActivityWindowMinutes } from '@/hooks/useActiveWork' import { formatActivityWindow } from '@/lib/activeWork' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -37,7 +39,7 @@ const PANEL_WIDTH_CLASSES: Partial> = { activity: 'w-80', } -const REMOTE_TOOL_KEYS: ReadonlySet = new Set(['activity', 'mcp', 'skills']) +const REMOTE_TOOL_KEYS: ReadonlySet = new Set(['activity', 'mcp', 'skills', 'source-control', 'terminal']) function ActivityWindowLabel() { const minutes = useActivityWindowMinutes() @@ -87,6 +89,7 @@ interface ToolSidePanelProps { sessionId?: string directory?: string terminalDirectory?: string + terminalTarget?: TerminalTarget repoDirectory?: string currentBranch?: string selectedFilePath?: string @@ -149,6 +152,7 @@ export function ToolSidePanel({ sessionId, directory, terminalDirectory, + terminalTarget, repoDirectory, currentBranch = 'main', selectedFilePath, @@ -160,7 +164,8 @@ export function ToolSidePanel({ const { activeTool, toggleTool, closePanel } = panel const location = useLocation() const openNavItem = useOpenNavItem() - const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) + const routeServerId = useRouteOpenCodeServerId() + const isRemoteRoute = !isLocalOpenCodeServer(routeServerId) const railRef = useRef(null) const previousActiveToolRef = useRef(activeTool) @@ -176,6 +181,8 @@ export function ToolSidePanel({ const panelTools: Array<{ item: MoreDrawerItem; tool: PanelTool }> = [] const actionItems: MoreDrawerItem[] = [] + const terminalWorkspaceTarget: TerminalTarget | undefined = + terminalTarget ?? (repoId === undefined ? undefined : { kind: 'repo', repoId }) for (const item of buildToolItems(location.pathname)) { if (isRemoteRoute && !REMOTE_TOOL_KEYS.has(item.key)) continue const tool = toolKeyOf(item) @@ -202,27 +209,39 @@ export function ToolSidePanel({ if (tool === 'mcp') return repoDirectory ? : null if (tool === 'walkthrough') return sessionId ? : null if (tool === 'activity') return + if (tool === 'terminal') { + return terminalWorkspaceTarget + ? + : null + } + if (tool === 'skills') { + return sessionId ? ( + + ) : ( + + ) + } + if (tool === 'sourceControl') { + if (isRemoteRoute) { + return repoDirectory + ? + : null + } + return repoId === undefined + ? null + : + } if (repoId === undefined) return null switch (tool) { - case 'sourceControl': - return - case 'terminal': - return case 'actions': return - case 'skills': - return sessionId ? ( - - ) : ( - - ) case 'schedules': return } diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index bb03bc405..75a33657f 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -64,7 +64,29 @@ describe('buildMoreItems', () => { it('returns only the server tools a linked server can serve for /servers/:id/sessions/:sid', () => { const items = buildMoreItems('/servers/srv_kitchen/sessions/abc') - expect(items.map((item) => item.key)).toEqual(['home', 'mcp', 'skills', 'settings', 'logout']) + expect(items.map((item) => item.key)).toEqual([ + 'home', + 'mcp', + 'skills', + 'source-control', + 'terminal', + 'settings', + 'logout', + ]) + }) + + it('returns only the server tools a linked server can serve for /servers/:id/projects', () => { + const items = buildMoreItems('/servers/srv_kitchen/projects') + expect(items.map((item) => item.key)).toEqual([ + 'home', + 'mcp', + 'skills', + 'source-control', + 'terminal', + 'settings', + 'logout', + ]) + expect(items.find((item) => item.key === 'source-control')?.dialog).toBe('sourceControl') }) it('omits the Actions item for an Assistant session', () => { @@ -174,6 +196,18 @@ describe('buildToolItems', () => { 'activity', 'mcp', 'skills', + 'source-control', + 'terminal', + ]) + }) + + it('returns the linked-server project tools for the tool rail', () => { + expect(buildToolItems('/servers/srv_kitchen/projects').map((item) => item.key)).toEqual([ + 'activity', + 'mcp', + 'skills', + 'source-control', + 'terminal', ]) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 9871bfd71..270df0fe1 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -49,6 +49,7 @@ function getBaseItems(): MoreDrawerItem[] { const SESSION_DETAIL_PATH = /^\/repos\/(\d+)\/sessions\/[^/]+$/ const SERVER_SESSION_DETAIL_PATH = /^\/servers\/[^/]+\/sessions\/[^/]+$/ +const SERVER_PROJECT_PATH = /^\/servers\/[^/]+\/projects$/ export function isSessionDetailPath(pathname: string): boolean { return SESSION_DETAIL_PATH.test(pathname) @@ -58,8 +59,6 @@ const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) const MANAGER_ONLY_ITEM_KEYS: ReadonlySet = new Set([ 'files', - 'source-control', - 'terminal', 'preview', 'walkthrough', 'actions', @@ -87,12 +86,14 @@ export function toolKeyOf(item: MoreDrawerItem): string | null { function buildRouteNavModel(pathname: string): NavModel { const baseItems = getBaseItems() - if (SERVER_SESSION_DETAIL_PATH.test(pathname)) { + if (SERVER_SESSION_DETAIL_PATH.test(pathname) || SERVER_PROJECT_PATH.test(pathname)) { return { primary: [getAssistantNavItem()], items: [ { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, + { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, + { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, ...baseItems, ], } diff --git a/frontend/src/components/navigation/sidebar-session-tree.ts b/frontend/src/components/navigation/sidebar-session-tree.ts index 1d842a248..ffb0b28aa 100644 --- a/frontend/src/components/navigation/sidebar-session-tree.ts +++ b/frontend/src/components/navigation/sidebar-session-tree.ts @@ -2,7 +2,7 @@ import type { Repo, Session } from '@/api/types' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' import { getSessionKey } from '@/lib/sessionKey' -import { getSessionPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { getSessionPath, isAssistantPath, parseRepoRoute, parseServerSessionRoute } from '@/lib/navigation' import { partitionSessions, selectRootSessions } from '@/components/session/session-partition' import type { ActiveWorktree } from '@/lib/activeWork' @@ -29,6 +29,8 @@ export interface SidebarSessionItem { key: string session: Session repoId: number + /** The linked server owning the session; absent for local repo sessions. */ + serverId?: string repoLabel: string branchLabel: string | null /** Branch of the worktree the session runs in, when it runs in a worktree of the group's repo. */ @@ -46,6 +48,10 @@ export interface SidebarRepoGroup { } export function isCurrentSessionItem(item: SidebarSessionItem, pathname: string): boolean { + if (item.serverId) { + const selection = parseServerSessionRoute(pathname) + return selection?.serverId === item.serverId && item.session.id === selection.sessionId + } const selection = parseRepoRoute(pathname) return item.repoId === selection.repoId && item.session.id === selection.sessionId } diff --git a/frontend/src/components/repo/FusionComposer.tsx b/frontend/src/components/repo/FusionComposer.tsx index ede706f94..36bef67a3 100644 --- a/frontend/src/components/repo/FusionComposer.tsx +++ b/frontend/src/components/repo/FusionComposer.tsx @@ -1,6 +1,7 @@ import { useId, useState } from 'react' import { ArrowRight, ArrowUpRight, Combine, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { ModelCombobox } from '@/components/model/ModelCombobox' @@ -11,6 +12,7 @@ import { FusionRecoveredDetailsSchema, MULTI_RUN_FUSION_MIN_SOURCES, type MultiRun, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' export interface FusionComposerSubmission { @@ -20,7 +22,7 @@ export interface FusionComposerSubmission { } interface FusionComposerProps { - repoId: number + target: MultiRunTarget directory?: string run: MultiRun selectedEntryIds: number[] @@ -38,7 +40,7 @@ function errorCode(error: unknown): string | undefined { } export function FusionComposer({ - repoId, + target, directory, run, selectedEntryIds, @@ -85,15 +87,26 @@ export function FusionComposer({ Base ref
- + {target.kind === 'repo' ? ( + + ) : ( + setBaseRef(event.target.value)} + placeholder="Current HEAD" + autoComplete="off" + className="font-mono text-sm" + /> + )}
diff --git a/frontend/src/components/repo/MultiRunCard.tsx b/frontend/src/components/repo/MultiRunCard.tsx index 718f7df5f..2df5404ce 100644 --- a/frontend/src/components/repo/MultiRunCard.tsx +++ b/frontend/src/components/repo/MultiRunCard.tsx @@ -42,7 +42,7 @@ interface MultiRunCardProps { onExitSelection: () => void onToggleEntry: (entryId: number, checked: boolean) => void onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void onDiscard: (entry: MultiRunEntry) => void } @@ -278,7 +278,7 @@ interface EntryRowProps { unavailableReason: string | null onToggle: (checked: boolean) => void onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void onDiscard: () => void } @@ -344,7 +344,7 @@ function EntryRow({ {sessionId ? ( onOpenSession(sessionId, entry.isolated)} /> ) : null} - {sessionId ? ( + {sessionId && onWalkthrough ? ( onWalkthrough(sessionId)} /> ) : null} {canDiscard ? ( @@ -375,7 +375,7 @@ function EntryRow({ interface FusionRowProps { fusion: MultiRunFusion onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void } function FusionRow({ fusion, onOpenSession, onWalkthrough }: FusionRowProps) { @@ -403,7 +403,7 @@ function FusionRow({ fusion, onOpenSession, onWalkthrough }: FusionRowProps) { {sessionId ? (
onOpenSession(sessionId, fusion.isolated)} /> - onWalkthrough(sessionId)} /> + {onWalkthrough ? onWalkthrough(sessionId)} /> : null}
) : null} diff --git a/frontend/src/components/repo/MultiRunSheet.test.tsx b/frontend/src/components/repo/MultiRunSheet.test.tsx index 1b47ae264..cb257a6b4 100644 --- a/frontend/src/components/repo/MultiRunSheet.test.tsx +++ b/frontend/src/components/repo/MultiRunSheet.test.tsx @@ -119,6 +119,8 @@ function fusion(overrides: Partial = {}): MultiRunFusion { const oneStartedRun: MultiRun = { id: 3, repoId: 7, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -162,7 +164,14 @@ function createWrapper() { function renderSheet(overrides: Partial> = {}) { const onOpenChange = vi.fn() const view = render( - , + , { wrapper: createWrapper() }, ) return { ...view, onOpenChange } @@ -714,4 +723,93 @@ describe('MultiRunSheet', () => { expect(mockNavigate).toHaveBeenCalledWith('/repos/7/sessions/ses_recovered') }) }) + + describe('Linked server target', () => { + const serverTarget = { kind: 'server', serverId: 'srv_kitchen', directory: '/srv/app' } as const + const serverRun: MultiRun = { + ...oneStartedRun, + repoId: null, + serverId: 'srv_kitchen', + directory: '/srv/app', + } + + function renderServerSheet() { + return renderSheet({ target: serverTarget, directory: '/srv/app', defaultBaseRef: undefined }) + } + + it('lists the runs of the server directory', async () => { + mocks.listMultiRuns.mockResolvedValue([serverRun]) + renderServerSheet() + + expect(await screen.findByRole('region', { name: 'Sweep' })).toBeInTheDocument() + expect(mocks.listMultiRuns).toHaveBeenCalledWith(serverTarget) + }) + + it('launches with the server target instead of a repository', async () => { + const user = userEvent.setup() + mocks.launchMultiRun.mockResolvedValue(serverRun) + renderServerSheet() + + await fillLaunchForm(user) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/gpt-4o'], + isolate: true, + }) + }) + }) + + it('takes the base ref as text because server branches are not listed', async () => { + const user = userEvent.setup() + mocks.launchMultiRun.mockResolvedValue(serverRun) + renderServerSheet() + + await openNewRun(user) + expect(screen.getByRole('textbox', { name: 'Start from' })).toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: 'Start from' })).not.toBeInTheDocument() + await user.type(screen.getByLabelText('Start from'), 'release') + expect(mocks.listBranches).not.toHaveBeenCalled() + + await user.type(screen.getByLabelText('Group name'), 'Sweep') + await user.type(screen.getByLabelText('Prompt'), 'go') + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith(expect.objectContaining({ baseRef: 'release' })) + }) + }) + + it('opens sessions on the server route and hides the local-only walkthrough', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([serverRun]) + const { onOpenChange } = renderServerSheet() + + const card = await screen.findByRole('region', { name: 'Sweep' }) + expect(within(card).queryByRole('button', { name: /walkthrough/i })).not.toBeInTheDocument() + + await user.click(within(card).getByRole('button', { name: 'Open' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockNavigate).toHaveBeenCalledWith('/servers/srv_kitchen/sessions/ses_11') + }) + + it('opens a server fusion session on the server route', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([{ ...serverRun, fusions: [fusion()] }]) + renderServerSheet() + + const card = await screen.findByRole('region', { name: 'Sweep' }) + const fusionRow = within(card).getByText('from 1 source (truncated)').closest('li') as HTMLElement + await user.click(within(fusionRow).getByRole('button', { name: 'Open' })) + + expect(mockNavigate).toHaveBeenCalledWith('/servers/srv_kitchen/sessions/ses_fusion') + }) + }) }) diff --git a/frontend/src/components/repo/MultiRunSheet.tsx b/frontend/src/components/repo/MultiRunSheet.tsx index a1f0f6bff..580a19445 100644 --- a/frontend/src/components/repo/MultiRunSheet.tsx +++ b/frontend/src/components/repo/MultiRunSheet.tsx @@ -17,18 +17,19 @@ import { ChangesWalkthroughSheet } from '@/components/session/ChangesWalkthrough import { useModelSections } from '@/hooks/useModelSections' import { useDiscardMultiRunEntry, useFuseMultiRun, useLaunchMultiRun, useMultiRuns } from '@/hooks/useMultiRuns' import { filterModelSections } from '@/lib/modelSections' +import { getServerSessionPath, getSessionPath } from '@/lib/navigation' import { randomId } from '@/lib/utils' -import { buildSessionPath } from '@opencode-manager/shared/utils' import { FusionUnavailableDetailsSchema, MULTI_RUN_MAX_MODELS, type FuseMultiRunRequest, type LaunchMultiRunRequest, type MultiRunEntry, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' interface MultiRunSheetProps { - repoId: number + target: MultiRunTarget directory?: string defaultBaseRef?: string open: boolean @@ -55,7 +56,14 @@ function unavailableReasonsFromError(error: unknown): ReadonlyMap [source.entryId, source.message])) } -export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenChange }: MultiRunSheetProps) { +function multiRunSessionPath(target: MultiRunTarget, sessionId: string, isolated: boolean): string { + if (target.kind === 'server') { + return getServerSessionPath(target.serverId, sessionId) + } + return getSessionPath(target.repoId, sessionId, isolated ? { repoTab: 'workspaces' } : undefined) +} + +export function MultiRunSheet({ target, directory, defaultBaseRef, open, onOpenChange }: MultiRunSheetProps) { const navigate = useNavigate() const [activeTab, setActiveTab] = useState('runs') const [expandedRuns, setExpandedRuns] = useState>({}) @@ -64,9 +72,9 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC const [pendingDiscard, setPendingDiscard] = useState(null) const [walkthroughSessionId, setWalkthroughSessionId] = useState(null) - const runsQuery = useMultiRuns(repoId, open) - const fuse = useFuseMultiRun(repoId) - const discard = useDiscardMultiRunEntry(repoId) + const runsQuery = useMultiRuns(target, open) + const fuse = useFuseMultiRun(target) + const discard = useDiscardMultiRunEntry(target) const resetFuse = fuse.reset useEffect(() => { @@ -85,7 +93,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC const openSession = (sessionId: string, isolated: boolean) => { onOpenChange(false) - navigate(buildSessionPath(repoId, sessionId, isolated ? { repoTab: 'workspaces' } : undefined)) + navigate(multiRunSessionPath(target, sessionId, isolated)) } const startSelection = (runId: number) => { @@ -197,7 +205,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC onExitSelection={exitSelection} onToggleEntry={toggleEntry} onOpenSession={openSession} - onWalkthrough={setWalkthroughSessionId} + onWalkthrough={target.kind === 'repo' ? setWalkthroughSessionId : undefined} onDiscard={(entry) => setPendingDiscard({ runId: run.id, entry })} /> )) @@ -207,7 +215,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC {selection && selectedRun ? ( { if (!next) setWalkthroughSessionId(null) }} - onOpenSession={(id) => navigate(buildSessionPath(repoId, id))} + onOpenSession={(id) => navigate(multiRunSessionPath(target, id, false))} /> ) : null} @@ -266,14 +274,14 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC } interface NewRunFormProps { - repoId: number + target: MultiRunTarget directory?: string defaultBaseRef?: string open: boolean onLaunched: () => void } -function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: NewRunFormProps) { +function NewRunForm({ target, directory, defaultBaseRef, open, onLaunched }: NewRunFormProps) { const [name, setName] = useState('') const [prompt, setPrompt] = useState('') const [selectedModels, setSelectedModels] = useState([]) @@ -283,7 +291,7 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New const deferredModelSearch = useDeferredValue(modelSearch) const { sections: modelSections } = useModelSections(directory, { enabled: open }) - const launch = useLaunchMultiRun(repoId) + const launch = useLaunchMultiRun(target) const visibleModelSections = useMemo( () => filterModelSections(modelSections, deferredModelSearch), @@ -306,7 +314,9 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New const handleLaunch = () => { if (!canSubmit) return const request: LaunchMultiRunRequest = { - repoId, + ...(target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory }), name: name.trim(), prompt: prompt.trim(), models: selectedModels, @@ -378,14 +388,25 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New {isolate ? (
- + {target.kind === 'repo' ? ( + + ) : ( + setBaseRef(event.target.value)} + placeholder="Current HEAD" + autoComplete="off" + className="font-mono text-sm" + /> + )}

Each isolated workspace starts from this branch.

) : null} diff --git a/frontend/src/components/repo/ProjectActionsMenu.tsx b/frontend/src/components/repo/ProjectActionsMenu.tsx index f7e7963a1..74f7ebcd3 100644 --- a/frontend/src/components/repo/ProjectActionsMenu.tsx +++ b/frontend/src/components/repo/ProjectActionsMenu.tsx @@ -61,12 +61,12 @@ export function ProjectActionsMenu({ repoId, directory }: ProjectActionsMenuProp const openPreview = useOpenPreview() const openTerminal = useOpenTerminal() const configQuery = useProjectConfig(repoId, directory, menuOpen && !!directory) - const terminalsQuery = useTerminals(repoId, directory, { + const terminalsQuery = useTerminals({ kind: 'repo', repoId }, directory, { enabled: menuOpen && !!directory, refetchInterval: menuOpen ? 5000 : false, }) const runAction = useRunProjectAction(repoId, directory) - const removeTerminal = useRemoveTerminal(repoId) + const removeTerminal = useRemoveTerminal({ kind: 'repo', repoId }) const trustConfig = useTrustRepoConfig(repoId) const [pendingTrust, setPendingTrust] = useState(null) const scopeRef = useRef({ repoId, directory }) diff --git a/frontend/src/components/repo/RemoteMultiRunButton.test.tsx b/frontend/src/components/repo/RemoteMultiRunButton.test.tsx new file mode 100644 index 000000000..613868bcc --- /dev/null +++ b/frontend/src/components/repo/RemoteMultiRunButton.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { RemoteMultiRunButton } from './RemoteMultiRunButton' + +vi.mock('@/components/repo/MultiRunSheet', () => ({ + MultiRunSheet: ({ + target, + directory, + open, + }: { + target: { kind: string; serverId?: string; directory?: string } + directory?: string + open: boolean + }) => ( +
+ ), +})) + +describe('RemoteMultiRunButton', () => { + it('opens the multi-run sheet for the project directory on the linked server', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByTestId('multi-run-sheet')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Multi-run' })) + + const sheet = screen.getByTestId('multi-run-sheet') + expect(sheet).toHaveAttribute('data-open', 'true') + expect(sheet).toHaveAttribute('data-kind', 'server') + expect(sheet).toHaveAttribute('data-server', 'srv_kitchen') + expect(sheet).toHaveAttribute('data-target-directory', '/srv/app') + expect(sheet).toHaveAttribute('data-directory', '/srv/app') + }) +}) diff --git a/frontend/src/components/repo/RemoteMultiRunButton.tsx b/frontend/src/components/repo/RemoteMultiRunButton.tsx new file mode 100644 index 000000000..2ba6e834c --- /dev/null +++ b/frontend/src/components/repo/RemoteMultiRunButton.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { Layers } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { MultiRunSheet } from '@/components/repo/MultiRunSheet' + +interface RemoteMultiRunProps { + serverId: string + directory: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * The multi-run sheet for one project on a linked OpenCode server, controlled by its caller. + */ +export function RemoteMultiRun({ serverId, directory, open, onOpenChange }: RemoteMultiRunProps) { + return ( + + ) +} + +export function RemoteMultiRunButton({ serverId, directory }: { serverId: string; directory: string }) { + const [mounted, setMounted] = useState(false) + const [open, setOpen] = useState(false) + + const handleOpen = () => { + setMounted(true) + setOpen(true) + } + + return ( + <> + + {mounted ? ( + + ) : null} + + ) +} diff --git a/frontend/src/components/repo/RepoCard.tsx b/frontend/src/components/repo/RepoCard.tsx index 73395b393..ea79385bf 100644 --- a/frontend/src/components/repo/RepoCard.tsx +++ b/frontend/src/components/repo/RepoCard.tsx @@ -5,6 +5,7 @@ import { getRepoDisplayName } from "@/lib/utils"; import type { Repo } from "@/api/types"; import type { GitStatusResponse } from "@/types/git" import { RepoActivityIndicator } from "@/components/activity/RepoActivityIndicator" +import { RepoCardShell } from "./RepoCardShell" import { RepoRowActions } from "./RepoRowActions" interface RepoCardProps { @@ -58,90 +59,65 @@ export function RepoCard({ } }; + const meta = isCloning ? ( + + + Cloning... + + ) : ( + <> + + + {branchToDisplay || "main"} + + {isDirty && ( + + + + {unstagedCount > 0 && unstagedCount} + {unstagedCount > 0 && stagedCount > 0 && "/"} + {stagedCount > 0 && `${stagedCount}s`} + + + )} + {(ahead > 0 || behind > 0) && ( + + + {ahead > 0 && `↑${ahead}`} + {behind > 0 && `↓${behind}`} + + + )} + {repo.isLocal && ( + + + + )} + + ); + return ( -
} + actions={ + !manageMode && !isSelected && !hasSelectedRepos ? ( + + ) : undefined + } + meta={meta} + activityLabel={activityLabel} + isInteractive={isReady} + isSelected={isSelected} onClick={handleCardClick} - className={`relative border rounded-xl overflow-hidden transition-all duration-200 w-full ${ - isReady ? "cursor-pointer active:scale-[0.98] hover:border-primary/50 hover:bg-accent/50 hover:shadow-md" : "cursor-default" - } ${ - isSelected - ? "border-primary bg-primary/5" - : "border-border bg-card" - }`} - > -
-
-
-
-

- {repoName} -

- {isReady && ( -
- )} - -
- - {!manageMode && !isSelected && !hasSelectedRepos && ( - - )} -
- -
-
- {isCloning ? ( - - - Cloning... - - ) : ( - <> - - - {branchToDisplay || "main"} - - {isDirty && ( - - - - {unstagedCount > 0 && unstagedCount} - {unstagedCount > 0 && stagedCount > 0 && "/"} - {stagedCount > 0 && `${stagedCount}s`} - - - )} - {(ahead > 0 || behind > 0) && ( - - - {ahead > 0 && `↑${ahead}`} - {behind > 0 && `↓${behind}`} - - - )} - {repo.isLocal && ( - - - - )} - - )} -
- - {activityLabel && ( - - {activityLabel} - - )} -
-
-
-
+ /> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/repo/RepoCardShell.tsx b/frontend/src/components/repo/RepoCardShell.tsx new file mode 100644 index 000000000..8a5086f40 --- /dev/null +++ b/frontend/src/components/repo/RepoCardShell.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +interface RepoCardShellProps { + title: string; + meta: ReactNode; + statusDotClassName?: string; + activity?: ReactNode; + actions?: ReactNode; + activityLabel?: string; + isInteractive?: boolean; + isSelected?: boolean; + onClick?: () => void; +} + +export function RepoCardShell({ + title, + meta, + statusDotClassName, + activity, + actions, + activityLabel, + isInteractive = false, + isSelected = false, + onClick, +}: RepoCardShellProps) { + return ( +
+
+
+
+
+

+ {title} +

+ {statusDotClassName && ( +
+ )} + {activity} +
+ + {actions} +
+ +
+
+ {meta} +
+ + {activityLabel && ( + + {activityLabel} + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/repo/RepoListControls.tsx b/frontend/src/components/repo/RepoListControls.tsx index e780c04c7..ba24ca882 100644 --- a/frontend/src/components/repo/RepoListControls.tsx +++ b/frontend/src/components/repo/RepoListControls.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { Search, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { SlidersHorizontal, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { RepoSearchInput } from "./RepoSearchInput"; import { DropdownMenu, DropdownMenuContent, @@ -120,17 +120,7 @@ export function RepoListControls({ return (
-
- - onSearchChange(e.target.value)} - placeholder="Search repositories..." - className="pl-9 h-9" - autoComplete="off" - name="repo-search" - /> -
+ {isMobile ? ( diff --git a/frontend/src/components/repo/RepoSearchInput.tsx b/frontend/src/components/repo/RepoSearchInput.tsx new file mode 100644 index 000000000..61e9a0844 --- /dev/null +++ b/frontend/src/components/repo/RepoSearchInput.tsx @@ -0,0 +1,30 @@ +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; + +interface RepoSearchInputProps { + value: string; + onValueChange: (query: string) => void; + placeholder?: string; + name?: string; +} + +export function RepoSearchInput({ + value, + onValueChange, + placeholder = "Search repositories...", + name = "repo-search", +}: RepoSearchInputProps) { + return ( +
+ + onValueChange(event.target.value)} + placeholder={placeholder} + className="pl-9 h-9" + autoComplete="off" + name={name} + /> +
+ ); +} diff --git a/frontend/src/components/repo/RepoSkillsDialog.tsx b/frontend/src/components/repo/RepoSkillsDialog.tsx index 6307a8aa7..49fb67b11 100644 --- a/frontend/src/components/repo/RepoSkillsDialog.tsx +++ b/frontend/src/components/repo/RepoSkillsDialog.tsx @@ -28,17 +28,17 @@ function toRemoteSkillFileInfo(skill: SkillInfo): SkillFileInfo { type RepoSkillsSessionProps = | { sessionId: string; directory?: string; onSkillLoaded?: (skill: SkillFileInfo) => void } - | { sessionId?: undefined; directory?: undefined; onSkillLoaded?: undefined } + | { sessionId?: undefined; directory?: string; onSkillLoaded?: undefined } type RepoSkillsDialogProps = { open: boolean onOpenChange: (open: boolean) => void - repoId: number + repoId?: number } & RepoSkillsSessionProps type RepoSkillsContentProps = { open: boolean - repoId: number + repoId?: number onDone: () => void } & RepoSkillsSessionProps @@ -150,7 +150,7 @@ export function RepoSkillsContent({ } export function RepoSkillsDialog({ onOpenChange, ...props }: RepoSkillsDialogProps) { - if (!props.repoId && !props.sessionId) { + if (!props.repoId && !props.sessionId && !props.directory) { return null } diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index 83f8acf04..4bb0ea261 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -30,6 +30,7 @@ type GeneralTabProps = { workspaceMode: ScheduleWorkspaceMode onWorkspaceModeChange: (value: ScheduleWorkspaceMode) => void showWorkspaceMode: boolean + showBranch?: boolean showRepoSelector?: boolean isEditing: boolean repoId?: number @@ -74,6 +75,7 @@ export function GeneralTab({ workspaceMode, onWorkspaceModeChange, showWorkspaceMode, + showBranch = true, showRepoSelector, isEditing, repoId, @@ -171,10 +173,11 @@ export function GeneralTab({ name="schedule-workspace-mode" value={workspaceMode} onChange={onWorkspaceModeChange} - options={workspaceModeOptions} + options={workspaceModeOptions.filter((option) => option.value !== 'in-place')} />
)} + {showBranch && (
@@ -190,6 +193,7 @@ export function GeneralTab({ clearable />
+ )}
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index 6d18403e9..85660b6b9 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -40,10 +40,13 @@ export function JobDetailTab({ runningRun, isJobFetching, }: JobDetailTabProps) { - const { scheduleTarget } = useScheduleTarget(selectedJob?.repoId) - const { availableModels } = useScheduleModels(Boolean(selectedJob), scheduleTarget?.fullPath) + const repoId = selectedJob?.repoId ?? undefined + const isRemote = selectedJob?.repoId === null + const { scheduleTarget } = useScheduleTarget(repoId) + const modelDirectory = isRemote ? (selectedJob?.directory ?? undefined) : scheduleTarget?.fullPath + const { availableModels } = useScheduleModels(Boolean(selectedJob), modelDirectory, isRemote ? selectedJob?.serverId : undefined) const resolvedModel = resolveScheduleModel(selectedJob?.model, availableModels) - const { data: worktrees = [] } = useScheduleWorktrees(selectedJob?.repoId, selectedJob?.id ?? null) + const { data: worktrees = [] } = useScheduleWorktrees(repoId, selectedJob?.id ?? null) const removeWorktrees = useRemoveScheduleWorktrees() const idleWorktreeCount = worktrees.filter((worktree) => !worktree.inUse).length @@ -119,7 +122,7 @@ export function JobDetailTab({ )} - {worktrees.length > 0 && ( + {!isRemote && worktrees.length > 0 && (
@@ -130,7 +133,7 @@ export function JobDetailTab({ variant="outline" size="sm" aria-label="Remove all worktrees" - onClick={() => removeWorktrees.mutate({ repoId: selectedJob.repoId, jobId: selectedJob.id })} + onClick={() => { if (repoId !== undefined) removeWorktrees.mutate({ repoId, jobId: selectedJob.id }) }} disabled={removeWorktrees.isPending || idleWorktreeCount === 0} > {removeWorktrees.isPending ? : } @@ -152,7 +155,7 @@ export function JobDetailTab({ size="sm" aria-label={`Remove worktree ${worktree.branch}`} title={worktree.inUse ? 'In use by a running run' : 'Remove worktree'} - onClick={() => removeWorktrees.mutate({ repoId: selectedJob.repoId, jobId: selectedJob.id, worktreePath: worktree.worktreePath })} + onClick={() => { if (repoId !== undefined) removeWorktrees.mutate({ repoId, jobId: selectedJob.id, worktreePath: worktree.worktreePath }) }} disabled={worktree.inUse || removeWorktrees.isPending} > @@ -184,7 +187,7 @@ export function JobDetailTab({

Model

{resolvedModel ?? 'Workspace default'}

- {selectedJob.repoId !== ASSISTANT_REPO_ID && ( + {(isRemote || selectedJob.repoId !== ASSISTANT_REPO_ID) && (

Workspace

{formatWorkspaceMode(selectedJob.workspaceMode)}

diff --git a/frontend/src/components/schedules/RemoteSchedulesButton.tsx b/frontend/src/components/schedules/RemoteSchedulesButton.tsx new file mode 100644 index 000000000..6335ea276 --- /dev/null +++ b/frontend/src/components/schedules/RemoteSchedulesButton.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { CalendarClock } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { ScheduleServerTarget } from '@/api/schedules' +import { ServerSchedulesDialog } from './ServerSchedulesDialog' + +interface RemoteSchedulesDialogProps { + serverId: string + directory: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * The schedule list for one project on a linked OpenCode server, controlled by its caller. + */ +export function RemoteSchedulesDialog({ serverId, directory, open, onOpenChange }: RemoteSchedulesDialogProps) { + const target: ScheduleServerTarget = { serverId, directory } + + return +} + +/** + * Opens the schedule list for one project on a linked OpenCode server. + */ +export function RemoteSchedulesButton({ serverId, directory }: { serverId: string; directory: string }) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 0ef87e448..a129bd73d 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -9,7 +9,7 @@ import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' import { getRepo } from '@/api/repos' import { createSessionWithContext } from '@/api/opencode' import { getWorkspaceFilePath } from '@/lib/markdownLinks' -import { getSessionPath } from '@/lib/navigation' +import { getServerSessionPath, getSessionPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' import { showToast } from '@/lib/toast' import { useMarkScheduleRunViewed } from '@/hooks/useSchedules' @@ -17,20 +17,22 @@ import { Loader2 } from 'lucide-react' import type { ScheduleRun } from '@opencode-manager/shared/types' interface RunDetailPanelProps { - repoId: number + repoId: number | null + serverId?: string activeRun: ScheduleRun | null selectedRunLoading: boolean onCancelRun: () => void cancelRunPending: boolean } -export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { +export function RunDetailPanel({ repoId, serverId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { const navigate = useNavigate() const [selectedFilePath, setSelectedFilePath] = useState(null) const [openingSession, setOpeningSession] = useState(false) const { data: repo, isPending: repoPending, isError: repoError, refetch: refetchRepo } = useQuery({ queryKey: ['repo', repoId], - queryFn: () => getRepo(repoId), + queryFn: () => getRepo(repoId!), + enabled: repoId !== null, }) const markRunViewed = useMarkScheduleRunViewed() const viewedRunIdRef = useRef(null) @@ -74,6 +76,10 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel const opensNewRepoSession = Boolean(activeRun.runBranch) && !activeRun.worktreePath && activeRun.status !== 'running' const handleOpenSession = async () => { + if (repoId === null) { + if (sessionId && serverId) navigate(getServerSessionPath(serverId, sessionId)) + return + } if (!opensNewRepoSession) { if (sessionId) navigate(getSessionPath(repoId, sessionId)) return @@ -130,10 +136,10 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel
) : activeRun.responseText ? ( <> - {repoPending && ( + {repoId !== null && repoPending && (
Loading repository details for local links.
)} - {repoError && ( + {repoId !== null && repoError && (
Could not load repository details for local links. @@ -160,7 +166,7 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel onClose={() => setSelectedFilePath(null)} basePath={repo?.localPath} repoName={repo ? getRepoDisplayName(repo) : undefined} - repoId={repoId} + repoId={repoId ?? undefined} initialSelectedFile={selectedFilePath ?? undefined} /> diff --git a/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx new file mode 100644 index 000000000..77d8c5d71 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ScheduleJob } from '@opencode-manager/shared/types' +import { ScheduleJobDialog } from './ScheduleJobDialog' + +Element.prototype.scrollIntoView = vi.fn() + +vi.mock('@/hooks/usePromptTemplates', () => ({ + usePromptTemplates: () => ({ data: [], isLoading: false }), + useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useScheduleTarget', () => ({ + useScheduleTarget: () => ({ scheduleTarget: undefined, isLoading: false, isError: false }), +})) + +vi.mock('@/hooks/useScheduleModels', () => ({ + useScheduleModels: () => ({ availableModels: null, isLoading: false }), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useAgents: () => ({ data: [], isSuccess: true }), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + listManagedSkills: () => Promise.resolve([]), + }, +})) + +vi.mock('@/api/mcp', () => ({ + mcpApi: { + getStatus: () => Promise.resolve({}), + }, +})) + +vi.mock('@/api/repos', () => ({ + listRepos: () => Promise.resolve([]), +})) + +const remoteJob: ScheduleJob = { + id: 7, + repoId: null, + serverId: 'srv_kitchen', + directory: '/srv/projects/audit', + name: 'Nightly audit', + description: null, + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Audit the project.', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'in-place', + createdAt: 0, + updatedAt: 0, + lastRunAt: null, + nextRunAt: null, +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('ScheduleJobDialog — server target', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('hides worktree workspace mode and submits an in-place target directory', async () => { + const onSubmit = vi.fn() + + render( + , + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(screen.getByText('Edit schedule')).toBeInTheDocument() + }) + + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Base branch')).not.toBeInTheDocument() + expect(screen.queryByText('Fresh worktree, cleaned up')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + workspaceMode: 'in-place', + directory: '/srv/projects/audit', + branch: null, + })) + }) + }) +}) diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 68fbbcffb..7c3f13164 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -9,6 +9,7 @@ import { settingsApi } from '@/api/settings' import { mcpApi } from '@/api/mcp' import { listRepos } from '@/api/repos' import type { Repo } from '@/api/types' +import type { ScheduleServerTarget } from '@/api/schedules' import { Button } from '@/components/ui/button' import type { ComboboxOption } from '@/components/ui/combobox' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -45,9 +46,10 @@ type ScheduleJobDialogProps = { repoId?: number onRepoChange?: (repoId: number | undefined) => void isEditing?: boolean + serverTarget?: ScheduleServerTarget } -export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, showRepoSelector, repoId: selectedRepoId, onRepoChange }: ScheduleJobDialogProps) { +export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, showRepoSelector, repoId: selectedRepoId, onRepoChange, serverTarget }: ScheduleJobDialogProps) { const [schedulePreset, setSchedulePreset] = useState('interval') const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -81,17 +83,19 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates() const deleteTemplateMutation = useDeletePromptTemplate() - const effectiveRepoId = selectedRepoId ?? job?.repoId - const { scheduleTarget } = useScheduleTarget(open ? effectiveRepoId : undefined) - const scheduleDirectory = scheduleTarget?.fullPath + const serverTargetId = serverTarget?.serverId + const serverTargetDirectory = serverTarget?.directory + const effectiveRepoId = selectedRepoId ?? job?.repoId ?? undefined + const { scheduleTarget } = useScheduleTarget(serverTargetId ? undefined : (open ? effectiveRepoId : undefined)) + const scheduleDirectory = serverTargetDirectory ?? scheduleTarget?.fullPath - const { availableModels } = useScheduleModels(open, scheduleDirectory) + const { availableModels } = useScheduleModels(open, scheduleDirectory, serverTargetId) const resolvedModel = useMemo( () => (modelDirty ? (model.trim() || null) : resolveScheduleModel(model, availableModels)), [model, modelDirty, availableModels], ) - const { data: agents = [], isSuccess: agentsLoaded } = useAgents(scheduleDirectory, { enabled: !!scheduleDirectory }) + const { data: agents = [], isSuccess: agentsLoaded } = useAgents(scheduleDirectory, { enabled: !!scheduleDirectory, serverId: serverTargetId }) const { data: skills = [], isLoading: skillsLoading } = useQuery({ queryKey: ['managed-skills'], @@ -101,8 +105,10 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, }) const { data: mcpStatuses = {}, isLoading: mcpStatusesLoading } = useQuery({ - queryKey: ['mcp-status', scheduleDirectory], - queryFn: () => mcpApi.getStatus(scheduleDirectory), + queryKey: ['mcp-status', scheduleDirectory, serverTargetId], + queryFn: () => (serverTargetId + ? mcpApi.getStatus(scheduleDirectory, serverTargetId) + : mcpApi.getStatus(scheduleDirectory)), enabled: open && !!scheduleDirectory, }) @@ -167,12 +173,12 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, initialSkillSlugsRef.current = initialSkillSlugs initialSkillNotesRef.current = initialSkillNotes setMcpServers(job?.mcpServers ?? []) - setBranch(job?.branch ?? '') - setWorkspaceMode(job?.workspaceMode ?? 'worktree') + setBranch(serverTargetDirectory ? '' : (job?.branch ?? '')) + setWorkspaceMode(serverTargetDirectory ? 'in-place' : (job?.workspaceMode ?? 'worktree')) setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false) setAllowQuestions(job?.permissionConfig?.allowQuestions ?? false) setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) - }, [job, open]) + }, [job, open, serverTargetDirectory]) useEffect(() => { if (!open) { @@ -216,8 +222,9 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, agentSlug: selectedAgentSlug.trim() || undefined, model: resolvedModel ?? undefined, prompt: prompt.trim(), - branch: branch.trim() || null, - workspaceMode, + branch: serverTargetDirectory ? null : (branch.trim() || null), + workspaceMode: serverTargetDirectory ? 'in-place' : workspaceMode, + ...(serverTargetDirectory ? { directory: serverTargetDirectory } : {}), mcpServers, permissionConfig: { allowExternalDirectory, @@ -266,7 +273,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, {job ? 'Edit schedule' : 'New schedule'} - Create a reusable repo job with a visual schedule builder, manual runs, and optional advanced metadata. + {serverTarget ? 'Create a reusable job for this remote project' : 'Create a reusable repo job'} with a visual schedule builder, manual runs, and optional advanced metadata. @@ -296,10 +303,11 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, onEnabledChange={setEnabled} branch={branch} onBranchChange={setBranch} - branchRepoId={open && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} + branchRepoId={!serverTargetDirectory && open && effectiveRepoId !== undefined && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} workspaceMode={workspaceMode} onWorkspaceModeChange={setWorkspaceMode} - showWorkspaceMode={effectiveRepoId !== ASSISTANT_REPO_ID} + showWorkspaceMode={!serverTargetDirectory && effectiveRepoId !== ASSISTANT_REPO_ID} + showBranch={!serverTargetDirectory} showRepoSelector={showRepoSelector} isEditing={!!job} repoId={selectedRepoId} diff --git a/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx b/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx index 548f8cda8..bdc481a4a 100644 --- a/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx +++ b/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx @@ -8,13 +8,13 @@ import type { ScheduleRunWithContext } from '@/api/schedules' import type { Repo } from '@/api/types' const mocks = vi.hoisted(() => ({ - useRepoScheduleRun: vi.fn(), + useScheduleRunDetail: vi.fn(), useMarkScheduleRunViewed: vi.fn(), getRepo: vi.fn(), })) vi.mock('@/hooks/useSchedules', () => ({ - useRepoScheduleRun: mocks.useRepoScheduleRun, + useScheduleRunDetail: mocks.useScheduleRunDetail, useMarkScheduleRunViewed: mocks.useMarkScheduleRunViewed, })) @@ -85,7 +85,7 @@ function renderDrawer(props: Partial> = describe('ScheduleRunDrawer', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useScheduleRunDetail.mockReturnValue({ data: undefined, isLoading: false }) mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.getRepo.mockResolvedValue(repo) }) diff --git a/frontend/src/components/schedules/ScheduleRunDrawer.tsx b/frontend/src/components/schedules/ScheduleRunDrawer.tsx index cafc41274..cdb82feb3 100644 --- a/frontend/src/components/schedules/ScheduleRunDrawer.tsx +++ b/frontend/src/components/schedules/ScheduleRunDrawer.tsx @@ -5,7 +5,7 @@ import { SideDrawer, SideDrawerContent, SideDrawerHeader } from '@/components/ui import { RunDetailPanel } from './RunDetailPanel' import { formatRunBranch, getRunRepoName, getRunStatusIcon, getRunStatusLabel, getRunTitle, type ScheduleRunRow } from './schedule-run-display' import { formatRunDuration } from './schedule-utils' -import { useRepoScheduleRun } from '@/hooks/useSchedules' +import { useScheduleRunDetail } from '@/hooks/useSchedules' interface ScheduleRunDrawerProps { run: ScheduleRunRow | null @@ -36,7 +36,7 @@ export function ScheduleRunDrawer({ onPrev, onNext, }: ScheduleRunDrawerProps) { - const { data: detail, isLoading } = useRepoScheduleRun(run?.repoId, run?.jobId ?? null, run?.id ?? null) + const { data: detail, isLoading } = useScheduleRunDetail(run) const activeRun = detail ?? run const title = run ? getRunTitle(run) : 'Run' @@ -90,6 +90,7 @@ export function ScheduleRunDrawer({ {activeRun ? ( ('jobs') + const [jobId, setJobId] = useState(null) + const [runId, setRunId] = useState(null) + const [dialog, setDialog] = useState(null) + const [search, setSearch] = useState('') + const [clearRunsOpen, setClearRunsOpen] = useState(false) + const [runToDelete, setRunToDelete] = useState(null) + + const { data: allSchedules, isLoading } = useAllSchedules() + const jobs = useMemo( + () => allSchedules?.filter((job) => job.serverId === serverId && job.directory === directory), + [allSchedules, directory, serverId], + ) + const selectedJob = useMemo( + () => jobs?.find((job) => job.id === jobId), + [jobs, jobId], + ) + const { data: fetchedJob, isFetching: isJobFetching } = useServerSchedule(serverId, jobId) + const { data: runs, isLoading: runsLoading } = useServerScheduleRuns(serverId, jobId, 30) + + const createMutation = useCreateServerSchedule(serverId) + const updateMutation = useUpdateServerSchedule(serverId) + const deleteMutation = useDeleteServerSchedule(serverId) + const runMutation = useRunServerSchedule(serverId) + const cancelRunMutation = useCancelServerScheduleRun(serverId) + const clearRunsMutation = useClearServerScheduleRuns(serverId) + const deleteRunMutation = useDeleteServerScheduleRun(serverId) + + const visibleJobs = useMemo( + () => (jobs ?? []).filter((job) => matchesScheduleJobSearch(job, search)), + [jobs, search], + ) + const clearableRuns = useMemo(() => (runs ?? []).filter((run) => run.status !== 'running'), [runs]) + const runningRun = useMemo(() => runs?.find((run) => run.status === 'running') ?? null, [runs]) + const activeJob = selectedJob ?? fetchedJob + const editingJob = dialog === 'edit' ? selectedJob : undefined + + const openJob = (job: ScheduleJob) => { + setJobId(job.id) + setTab('detail') + } + + const handleCreate = (data: CreateScheduleJobRequest) => { + createMutation.mutate({ ...data, directory }, { + onSuccess: (job) => { + setDialog(null) + setJobId(job.id) + setTab('detail') + }, + }) + } + + const handleUpdate = (data: CreateScheduleJobRequest) => { + if (dialog !== 'edit' || jobId === null) return + updateMutation.mutate({ jobId, data: toUpdateScheduleRequest(data) }, { + onSuccess: () => setDialog(null), + }) + } + + const handleDelete = () => { + if (dialog !== 'delete' || jobId === null) return + deleteMutation.mutate(jobId, { + onSuccess: () => { + setDialog(null) + setJobId(null) + setTab('jobs') + }, + }) + } + + const handleToggleEnabled = (job: ScheduleJob) => { + updateMutation.mutate({ jobId: job.id, data: { enabled: !job.enabled } }) + } + + const handleRunNow = (job: ScheduleJob) => { + runMutation.mutate(job.id, { + onSuccess: (run) => { + setTab('runs') + setRunId(run.id) + }, + }) + } + + const handleCancelRun = () => { + const run = runId !== null ? runs?.find((entry) => entry.id === runId) : null + if (!run || run.status !== 'running') return + cancelRunMutation.mutate({ jobId: run.jobId, runId: run.id }) + } + + const handleCancelJobRun = (job: ScheduleJob) => { + const lastRun = jobs?.find((entry) => entry.id === job.id)?.lastRun + if (!lastRun) return + cancelRunMutation.mutate({ jobId: job.id, runId: lastRun.id }) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const hasJobs = (jobs?.length ?? 0) > 0 + + return ( + <> +
+ {!hasJobs ? ( + + +
+ +
+
+

No schedules yet

+

+ Create a schedule for this remote project to automate recurring agent work in {directory}. +

+
+ +
+
+ ) : ( + <> + {tab === 'jobs' && ( +
+ + + +
+
+ { setJobId(job.id); setDialog('edit') }} + onDelete={(job) => { setJobId(job.id); setDialog('delete') }} + onCancelRun={handleCancelJobRun} + runPending={runMutation.isPending} + cancelPending={cancelRunMutation.isPending} + /> +
+
+
+ )} + {tab === 'detail' && ( + { setJobId(job.id); setDialog('edit') }} + onDelete={(id) => { setJobId(id); setDialog('delete') }} + onToggleEnabled={() => { if (activeJob) handleToggleEnabled(activeJob) }} + onRunNow={() => { if (activeJob) handleRunNow(activeJob) }} + updatePending={updateMutation.isPending} + runPending={runMutation.isPending} + runningRun={Boolean(runningRun)} + isJobFetching={isJobFetching} + /> + )} + {tab === 'runs' && ( + setClearRunsOpen(true)} + clearHistoryPending={clearRunsMutation.isPending} + onDeleteRun={setRunToDelete} + deleteRunPending={deleteRunMutation.isPending} + /> + )} + + )} +
+ + {hasJobs && ( + + )} + + { if (!open) setDialog(null) }} + job={editingJob} + isSaving={createMutation.isPending || updateMutation.isPending} + onSubmit={dialog === 'edit' ? handleUpdate : handleCreate} + serverTarget={target} + /> + + { if (!open) setDialog(null) }} + onConfirm={handleDelete} + onCancel={() => setDialog(null)} + title="Delete Schedule" + description={DELETE_SCHEDULE_DESCRIPTION} + isDeleting={deleteMutation.isPending} + /> + + { if (!open) setClearRunsOpen(false) }} + title="Clear run history" + description={ + <>This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone. + } + affectedWorktreeCount={0} + isPending={clearRunsMutation.isPending} + onCancel={() => setClearRunsOpen(false)} + onConfirm={() => { + if (jobId === null) return + clearRunsMutation.mutate(jobId, { onSuccess: () => setClearRunsOpen(false) }) + }} + /> + + { if (!open) setRunToDelete(null) }} + title="Delete run" + description="This permanently deletes this run. This cannot be undone." + affectedWorktreeCount={0} + isPending={deleteRunMutation.isPending} + onCancel={() => setRunToDelete(null)} + onConfirm={() => { + if (jobId === null || runToDelete === null) return + deleteRunMutation.mutate({ jobId, runId: runToDelete }, { onSuccess: () => setRunToDelete(null) }) + }} + /> + + ) +} diff --git a/frontend/src/components/schedules/ServerSchedulesDialog.tsx b/frontend/src/components/schedules/ServerSchedulesDialog.tsx new file mode 100644 index 000000000..630f30e91 --- /dev/null +++ b/frontend/src/components/schedules/ServerSchedulesDialog.tsx @@ -0,0 +1,29 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { ScheduleServerTarget } from '@/api/schedules' +import { ServerSchedulesContent } from './ServerSchedulesContent' + +interface ServerSchedulesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + target: ScheduleServerTarget | null +} + +export function ServerSchedulesDialog({ open, onOpenChange, target }: ServerSchedulesDialogProps) { + return ( + + + + Schedules + + {target?.directory ?? ''} + + + {target ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index a62f4bd1a..f2f068921 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -56,6 +56,11 @@ export const workspaceModeOptions: Array> = [ label: 'Shared worktree', description: 'Every run reuses one worktree and branch, so work builds up from run to run.', }, + { + value: 'in-place', + label: 'In place', + description: 'Runs directly in the target project directory. Used for projects on a linked server.', + }, ] export function formatWorkspaceMode(mode: ScheduleWorkspaceMode): string { diff --git a/frontend/src/components/servers/ConnectionStatus.tsx b/frontend/src/components/servers/ConnectionStatus.tsx new file mode 100644 index 000000000..80c867622 --- /dev/null +++ b/frontend/src/components/servers/ConnectionStatus.tsx @@ -0,0 +1,43 @@ +import type { OpenCodeServerStatus } from '@opencode-manager/shared/schemas' + +interface ConnectionStatusProps { + status?: OpenCodeServerStatus + isLoading: boolean +} + +/** + * Connection indicator for a linked server: checking, unknown, unreachable with its error, or + * connected with the server version. + */ +export function ConnectionStatus({ status, isLoading }: ConnectionStatusProps) { + if (isLoading) { + return Checking connection... + } + + if (!status) { + return ( + + + Unknown + + ) + } + + if (!status.connected) { + return ( + + + Unreachable + {status.error ? · {status.error} : null} + + ) + } + + return ( + + + Connected + {status.version ? · v{status.version} : null} + + ) +} diff --git a/frontend/src/components/servers/LinkedServerStateCard.tsx b/frontend/src/components/servers/LinkedServerStateCard.tsx new file mode 100644 index 000000000..84dfabb95 --- /dev/null +++ b/frontend/src/components/servers/LinkedServerStateCard.tsx @@ -0,0 +1,36 @@ +import type { LucideIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' + +interface LinkedServerStateCardProps { + icon: LucideIcon + title: string + description: string + actionLabel: string + onAction: () => void +} + +/** + * Centered notice for a linked-server page that cannot render its content, such as a missing or + * disabled server or a route without its project directory. + */ +export function LinkedServerStateCard({ + icon: Icon, + title, + description, + actionLabel, + onAction, +}: LinkedServerStateCardProps) { + return ( + + +
+

{title}

+

{description}

+
+ +
+ ) +} diff --git a/frontend/src/components/servers/OpenDirectoryDialog.tsx b/frontend/src/components/servers/OpenDirectoryDialog.tsx new file mode 100644 index 000000000..468947f7d --- /dev/null +++ b/frontend/src/components/servers/OpenDirectoryDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertCircle, Loader2, Plus } from 'lucide-react' +import { useCreateSession } from '@/hooks/useOpenCode' +import { getServerSessionPath } from '@/lib/navigation' +import { parseNetworkError } from '@/lib/opencode-errors' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +interface OpenDirectoryDialogProps { + serverId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * Dialog that starts a new session in an absolute directory on the linked server. + */ +export function OpenDirectoryDialog({ serverId, open, onOpenChange }: OpenDirectoryDialogProps) { + const navigate = useNavigate() + const [directory, setDirectory] = useState('') + const [error, setError] = useState(null) + const createSession = useCreateSession(directory) + + useEffect(() => { + if (!open) { + setDirectory('') + setError(null) + } + }, [open]) + + const canSubmit = directory.trim().length > 0 && !createSession.isPending + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!canSubmit) return + + setError(null) + try { + const session = await createSession.mutateAsync({ agent: undefined }) + onOpenChange(false) + navigate(getServerSessionPath(serverId, session.id)) + } catch (cause) { + setError(parseNetworkError(cause).message) + } + } + + return ( + + + + Open directory + + Start a session in an absolute path on the linked server. + + +
+
+ + setDirectory(event.target.value)} + placeholder="/absolute/path/on/the/server" + autoComplete="off" + name="remote-directory" + className="font-mono text-sm" + autoFocus + disabled={createSession.isPending} + /> +
+ {error ? ( +
+ +

{error}

+
+ ) : null} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx new file mode 100644 index 000000000..4ef717c14 --- /dev/null +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -0,0 +1,230 @@ +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Folder, FolderGit2, FolderPlus, GitBranch, Search, Server } from 'lucide-react' +import { listProjects, type OpenCodeProject } from '@/api/opencode' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useProjectActivityState } from '@/hooks/useActiveWork' +import { serverScopedKey } from '@/lib/serverScope' +import { getServerProjectPath } from '@/lib/navigation' +import { formatShortRelativeTime, getDirectoryName } from '@/lib/utils' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { OpenDirectoryDialog } from '@/components/servers/OpenDirectoryDialog' +import { RemoteProjectRowActions } from '@/components/servers/RemoteProjectRowActions' +import { RepoCardShell } from '@/components/repo/RepoCardShell' +import { RepoCardSkeleton } from '@/components/repo/RepoCardSkeleton' +import { RepoSearchInput } from '@/components/repo/RepoSearchInput' + +function projectName(project: OpenCodeProject): string { + return project.name?.trim() || getDirectoryName(project.canonical) +} + +function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; serverId: string }) { + const navigate = useNavigate() + const activityState = useProjectActivityState(serverId, project.canonical) + const updatedAt = project.time.updated + + return ( + } + actions={} + meta={ + + {project.vcs ? ( + + ) : ( + + )} + {project.canonical} + + } + activityLabel={updatedAt > 0 ? formatShortRelativeTime(new Date(updatedAt)) : undefined} + isInteractive + onClick={() => navigate(getServerProjectPath(serverId, project.canonical))} + /> + ) +} + +function OpenDirectoryTrigger({ onClick }: { onClick: () => void }) { + return ( + + ) +} + +function RemoteProjectListLoading() { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+
+
+ ) +} + +function RemoteProjectListContent({ serverId }: { serverId: string }) { + const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { openTab } = useSettingsDialog() + const [openDirectoryOpen, setOpenDirectoryOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + const server = servers.find((candidate) => candidate.id === serverId) + const isDisabled = server !== undefined && !server.enabled + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + + const filteredProjects = useMemo(() => { + const query = searchQuery.trim().toLowerCase() + if (!query) return projects + + return projects.filter((project) => + projectName(project).toLowerCase().includes(query) || + project.canonical.toLowerCase().includes(query), + ) + }, [projects, searchQuery]) + + const openSettings = () => openTab('servers') + + return ( +
+ {!serversLoading && !server ? ( + + ) : null} + + {isDisabled ? ( + + ) : null} + + {server && !isDisabled && projectsQuery.isError ? ( + + +
+

Could not load projects

+

+ {projectsQuery.error instanceof Error + ? projectsQuery.error.message + : 'The server did not respond.'} +

+
+ +
+ ) : null} + + {server && !isDisabled && !projectsQuery.isError ? ( + projectsQuery.isLoading ? ( + + ) : projects.length === 0 ? ( +
+ +

No projects on this server yet

+

+ Start a session in a directory to create one. +

+
+ setOpenDirectoryOpen(true)} /> +
+
+ ) : ( + <> +
+ + setOpenDirectoryOpen(true)} /> +
+
+
+ {filteredProjects.length === 0 ? ( +
+ +

+ No projects found matching "{searchQuery}" +

+
+ ) : ( +
+ {filteredProjects.map((project) => ( + + ))} +
+ )} +
+
+ + ) + ) : null} + + {server && !isDisabled ? ( + + ) : null} +
+ ) +} + +/** + * The projects of one linked server with their sessions and project actions, scoped to that server + * so every hook inside it targets the linked server rather than the local one. + */ +export function RemoteProjectList({ serverId }: { serverId: string }) { + return ( + + + + ) +} diff --git a/frontend/src/components/servers/RemoteProjectRowActions.tsx b/frontend/src/components/servers/RemoteProjectRowActions.tsx new file mode 100644 index 000000000..3b58840e3 --- /dev/null +++ b/frontend/src/components/servers/RemoteProjectRowActions.tsx @@ -0,0 +1,160 @@ +import { useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { CalendarClock, Layers, Loader2, MoreVertical, Plus } from 'lucide-react' +import { useCreateSession } from '@/hooks/useOpenCode' +import { useMobile } from '@/hooks/useMobile' +import { getServerSessionPath } from '@/lib/navigation' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { RemoteSchedulesDialog } from '@/components/schedules/RemoteSchedulesButton' +import { RemoteMultiRun } from '@/components/repo/RemoteMultiRunButton' + +interface RemoteProjectRowActionsProps { + serverId: string + directory: string +} + +interface ProjectActionButtonProps { + label: string + onClick: () => void + disabled?: boolean + children: ReactNode +} + +function ProjectActionButton({ label, onClick, disabled, children }: ProjectActionButtonProps) { + return ( + + + + + {label} + + ) +} + +/** + * The project actions for one remote project: new session, schedules, and multi-run. Desktop shows + * inline icon buttons and mobile shows a menu, matching local repository rows. + */ +export function RemoteProjectRowActions({ serverId, directory }: RemoteProjectRowActionsProps) { + const navigate = useNavigate() + const isMobile = useMobile() + const createSession = useCreateSession(directory) + const [schedulesOpen, setSchedulesOpen] = useState(false) + const [multiRunMounted, setMultiRunMounted] = useState(false) + const [multiRunOpen, setMultiRunOpen] = useState(false) + + const handleNewSession = async () => { + try { + const session = await createSession.mutateAsync({ agent: undefined }) + navigate(getServerSessionPath(serverId, session.id)) + } catch { + return + } + } + + const handleMultiRunOpen = () => { + setMultiRunMounted(true) + setMultiRunOpen(true) + } + + return ( +
event.stopPropagation()}> + {isMobile ? ( + + + + + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + void handleNewSession() + }} + disabled={createSession.isPending} + > + {createSession.isPending ? ( + + ) : ( + + )} + New session + + setSchedulesOpen(true)}> + + Schedules + + + + Multi-run + + + + ) : ( + +
+ { + void handleNewSession() + }} + disabled={createSession.isPending} + > + {createSession.isPending ? : } + + setSchedulesOpen(true)}> + + + + + +
+
+ )} + + + {multiRunMounted ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx new file mode 100644 index 000000000..aa9c6532c --- /dev/null +++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { RemoteProjectList } from '../RemoteProjectList' + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + useCreateSession: vi.fn(), + useOpenCodeServers: vi.fn(), + useSettingsDialog: vi.fn(), + openTab: vi.fn(), + createSession: vi.fn(), + isMobile: true, +})) + +vi.mock('@/api/opencode', () => ({ + listProjects: mocks.listProjects, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useCreateSession: mocks.useCreateSession, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: mocks.useSettingsDialog, +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: () => mocks.isMobile, + useSwipeBack: () => ({ + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: {}, + }), +})) + +vi.mock('@/hooks/useActiveWork', () => ({ + useProjectActivityState: () => null, +})) + +vi.mock('@/components/schedules/ServerSchedulesDialog', () => ({ + ServerSchedulesDialog: ({ + open, + target, + }: { + open: boolean + target: { serverId: string; directory: string } | null + }) => + open ? ( +
+ ) : null, +})) + +vi.mock('@/components/repo/MultiRunSheet', () => ({ + MultiRunSheet: ({ + open, + target, + directory, + }: { + open: boolean + target: { kind: string; serverId?: string; directory?: string } + directory?: string + }) => ( +
+ ), +})) + +const server = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const project = { + id: 'prj_1', + canonical: '/home/me/app', + name: 'App', + vcs: 'git', + time: { created: Date.now() - 120_000, updated: Date.now() - 60_000, active: 2 }, + sandboxes: [], +} + +const otherProject = { + id: 'prj_2', + canonical: '/srv/other', + name: 'Other', + time: { created: Date.now() - 120_000, updated: Date.now() - 60_000, active: 2 }, + sandboxes: [], +} + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function renderProjectList(serverId = 'srv_kitchen') { + return render( + + + + } /> + } /> + } /> + + + , + ) +} + +describe('RemoteProjectList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isMobile = true + mocks.listProjects.mockResolvedValue([project]) + mocks.useCreateSession.mockReturnValue({ mutateAsync: mocks.createSession, isPending: false }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) + mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) + }) + + it('lists projects from the remote server', async () => { + renderProjectList() + + await waitFor(() => { + expect(screen.getByText('App')).toBeInTheDocument() + }) + expect(screen.getByText('/home/me/app')).toBeInTheDocument() + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + + it('opens the remote project page when a project card is clicked', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByText('App')) + + await waitFor(() => { + expect(screen.getByTestId('project-route')).toBeInTheDocument() + }) + }) + + it('creates a session in a project directory from the actions menu and navigates to it', async () => { + const user = userEvent.setup() + mocks.createSession.mockResolvedValue({ id: 'ses_new' }) + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /New session/ })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) + }) + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + }) + + it('opens the project schedules from the actions menu', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /Schedules/ })) + + const dialog = await screen.findByTestId('schedules-dialog') + expect(dialog).toHaveAttribute('data-server', 'srv_kitchen') + expect(dialog).toHaveAttribute('data-directory', '/home/me/app') + + await user.click(dialog) + expect(screen.queryByTestId('project-route')).not.toBeInTheDocument() + expect(screen.getByTestId('schedules-dialog')).toBeInTheDocument() + }) + + it('opens multi-run for the project from the actions menu', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /Multi-run/ })) + + const sheet = await screen.findByTestId('multi-run-sheet') + expect(sheet).toHaveAttribute('data-open', 'true') + expect(sheet).toHaveAttribute('data-kind', 'server') + expect(sheet).toHaveAttribute('data-server', 'srv_kitchen') + expect(sheet).toHaveAttribute('data-directory', '/home/me/app') + }) + + it('shows inline project actions on desktop without opening the project', async () => { + const user = userEvent.setup() + mocks.isMobile = false + renderProjectList() + + await screen.findByText('App') + expect(screen.queryByRole('button', { name: 'Project actions' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'New session' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Multi-run' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Schedules' })) + + expect(await screen.findByTestId('schedules-dialog')).toHaveAttribute('data-directory', '/home/me/app') + expect(screen.queryByTestId('project-route')).not.toBeInTheDocument() + }) + + it('opens a session in an arbitrary directory from the dialog', async () => { + const user = userEvent.setup() + mocks.createSession.mockResolvedValue({ id: 'ses_other' }) + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + await user.type(screen.getByLabelText('Directory'), '/srv/other') + await user.click(screen.getByRole('button', { name: /Start session/ })) + + await waitFor(() => { + expect(mocks.useCreateSession).toHaveBeenCalledWith('/srv/other') + }) + expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + }) + + it('does not render the open-directory form in the list until the dialog opens', async () => { + const user = userEvent.setup() + renderProjectList() + + await screen.findByText('App') + expect(screen.queryByLabelText('Directory')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('/absolute/path/on/the/server')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open directory' })) + + expect(await screen.findByLabelText('Directory')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('/absolute/path/on/the/server')).toBeInTheDocument() + }) + + it('disables Start session until a directory is entered', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + + expect(screen.getByRole('button', { name: /Start session/ })).toBeDisabled() + + await user.type(screen.getByLabelText('Directory'), ' ') + expect(screen.getByRole('button', { name: /Start session/ })).toBeDisabled() + + await user.type(screen.getByLabelText('Directory'), '/srv/other') + expect(screen.getByRole('button', { name: /Start session/ })).toBeEnabled() + }) + + it('clears the directory when the dialog is cancelled', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + await user.type(screen.getByLabelText('Directory'), '/srv/other') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByLabelText('Directory')).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Open directory' })) + expect(await screen.findByLabelText('Directory')).toHaveValue('') + }) + + it('filters projects by name and path', async () => { + const user = userEvent.setup() + mocks.listProjects.mockResolvedValue([project, otherProject]) + renderProjectList() + + await screen.findByText('App') + const search = screen.getByPlaceholderText('Search projects...') + + await user.type(search, '/srv/other') + expect(screen.queryByText('App')).not.toBeInTheDocument() + expect(screen.getByText('Other')).toBeInTheDocument() + + await user.clear(search) + await user.type(search, 'app') + expect(screen.getByText('App')).toBeInTheDocument() + expect(screen.queryByText('Other')).not.toBeInTheDocument() + }) + + it('shows an empty search result state', async () => { + const user = userEvent.setup() + renderProjectList() + + await screen.findByText('App') + await user.type(screen.getByPlaceholderText('Search projects...'), 'missing') + + expect(screen.getByText('No projects found matching "missing"')).toBeInTheDocument() + }) + + it('shows the disabled state without loading projects', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProjectList() + + expect(screen.getByText('This server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('shows a missing server state when the server is no longer configured', async () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + + renderProjectList('srv_gone') + + expect(screen.getByText('Server not found')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('shows an empty state when the server has no projects', async () => { + mocks.listProjects.mockResolvedValue([]) + + renderProjectList() + + await waitFor(() => { + expect(screen.getByText('No projects on this server yet')).toBeInTheDocument() + }) + }) + + it('scopes the list to the given server rather than the route', async () => { + renderProjectList('srv_kitchen') + + await waitFor(() => { + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + }) +}) diff --git a/frontend/src/components/session/SessionGoalBar.test.tsx b/frontend/src/components/session/SessionGoalBar.test.tsx index fe3fb2838..5b0b0384e 100644 --- a/frontend/src/components/session/SessionGoalBar.test.tsx +++ b/frontend/src/components/session/SessionGoalBar.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/hooks/useSessionGoals') const baseGoal: SessionGoal = { id: 1, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'Ship the feature', status: 'active', diff --git a/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx b/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx new file mode 100644 index 000000000..c742161a2 --- /dev/null +++ b/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RemoteSourceControlContent } from './RemoteSourceControlContent' + +const mocks = vi.hoisted(() => ({ + getVcsInfo: vi.fn(), + listVcsStatus: vi.fn(), + getVcsDiff: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + getVcsInfo: mocks.getVcsInfo, + listVcsStatus: mocks.listVcsStatus, + getVcsDiff: mocks.getVcsDiff, +})) + +const WORKING_FILES = [ + { file: 'src/app.ts', additions: 3, deletions: 1, status: 'modified' as const }, + { file: 'src/new.ts', additions: 5, deletions: 0, status: 'added' as const }, +] + +const WORKING_DIFF = [ + { + file: 'src/app.ts', + patch: '@@ -1,2 +1,2 @@\n-old line\n+new line', + additions: 1, + deletions: 1, + status: 'modified' as const, + }, +] + +const BRANCH_DIFF = [ + { + file: 'src/branch-only.ts', + patch: '@@ -1,1 +1,2 @@\n branch file\n+extra branch line', + additions: 1, + deletions: 0, + status: 'added' as const, + }, +] + +function renderContent() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +describe('RemoteSourceControlContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getVcsInfo.mockResolvedValue({ + provider: 'github', + branch: { current: 'feature/remote', default: 'main' }, + }) + mocks.listVcsStatus.mockResolvedValue(WORKING_FILES) + mocks.getVcsDiff.mockImplementation((_directory: string, mode: string) => + Promise.resolve(mode === 'branch' ? BRANCH_DIFF : WORKING_DIFF), + ) + }) + + it('lists the working-copy changes with the current branch', async () => { + renderContent() + + expect(await screen.findByText('app.ts')).toBeInTheDocument() + expect(screen.getByText('new.ts')).toBeInTheDocument() + expect(screen.getByText('feature/remote')).toBeInTheDocument() + expect(mocks.listVcsStatus).toHaveBeenCalledWith('/home/me/app', 'srv_kitchen') + }) + + it('switches to branch changes and lists the diff files', async () => { + const user = userEvent.setup() + renderContent() + + await user.click(await screen.findByRole('button', { name: 'Branch changes' })) + + expect(await screen.findByText('branch-only.ts')).toBeInTheDocument() + expect(screen.queryByText('new.ts')).not.toBeInTheDocument() + expect(mocks.getVcsDiff).toHaveBeenCalledWith('/home/me/app', 'branch', 'srv_kitchen') + }) + + it('shows the patch after selecting a changed file', async () => { + const user = userEvent.setup() + renderContent() + + await user.click(await screen.findByText('app.ts')) + + expect(await screen.findByText('new line')).toBeInTheDocument() + expect(mocks.getVcsDiff).toHaveBeenCalledWith('/home/me/app', 'working', 'srv_kitchen') + + await user.click(screen.getByRole('button', { name: 'Back to changed files' })) + + expect(await screen.findByText('new.ts')).toBeInTheDocument() + }) + + it('shows an empty state when the working copy has no changes', async () => { + mocks.listVcsStatus.mockResolvedValue([]) + renderContent() + + expect(await screen.findByText('No working changes')).toBeInTheDocument() + }) + + it('reports a failed status load', async () => { + mocks.listVcsStatus.mockRejectedValue(new Error('vcs unavailable')) + renderContent() + + expect(await screen.findByText('Failed to load changes')).toBeInTheDocument() + expect(screen.getByText('vcs unavailable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/source-control/RemoteSourceControlContent.tsx b/frontend/src/components/source-control/RemoteSourceControlContent.tsx new file mode 100644 index 000000000..645fa10c4 --- /dev/null +++ b/frontend/src/components/source-control/RemoteSourceControlContent.tsx @@ -0,0 +1,258 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft, FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react' +import { + getVcsDiff, + getVcsInfo, + listVcsStatus, + type VcsFileDiff, + type VcsFileStatus, +} from '@/api/opencode' +import { FileDiffContent } from '@/components/file-browser/FileDiffView' +import { GitFlatFileList } from './GitFlatFileList' +import { SourceControlDialog } from './SourceControlPanel' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { serverScopedKey } from '@/lib/serverScope' +import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' +import { useMobile } from '@/hooks/useMobile' +import { GIT_UI_COLORS } from '@/lib/git-status-styles' +import type { GitFileStatus } from '@/types/git' + +type VcsMode = 'working' | 'branch' + +const MODES: ReadonlyArray<{ id: VcsMode; label: string }> = [ + { id: 'working', label: 'Working changes' }, + { id: 'branch', label: 'Branch changes' }, +] + +interface RemoteSourceControlContentProps { + serverId: string + directory: string + compact?: boolean +} + +function toGitFileStatus(entry: VcsFileStatus | VcsFileDiff): GitFileStatus { + return { + path: entry.file, + status: entry.status, + staged: false, + additions: entry.additions, + deletions: entry.deletions, + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load source control' +} + +export function RemoteSourceControlContent({ + serverId, + directory, + compact = false, +}: RemoteSourceControlContentProps) { + const queryClient = useQueryClient() + const [mode, setMode] = useState('working') + const [selectedFile, setSelectedFile] = useState() + + const vcsKey = useMemo( + () => serverScopedKey(['opencode', 'vcs', directory], serverId), + [directory, serverId], + ) + + const infoQuery = useQuery({ + queryKey: [...vcsKey, 'info'], + queryFn: () => getVcsInfo(directory, serverId), + retry: false, + }) + + const statusQuery = useQuery({ + queryKey: [...vcsKey, 'status'], + queryFn: () => listVcsStatus(directory, serverId), + enabled: mode === 'working', + retry: false, + }) + + const diffQuery = useQuery({ + queryKey: [...vcsKey, 'diff', mode], + queryFn: () => getVcsDiff(directory, mode, serverId), + enabled: mode === 'branch' || selectedFile !== undefined, + retry: false, + }) + + const listQuery = mode === 'working' ? statusQuery : diffQuery + const files = useMemo( + () => + mode === 'working' + ? (statusQuery.data ?? []).map(toGitFileStatus) + : (diffQuery.data ?? []).map(toGitFileStatus), + [mode, statusQuery.data, diffQuery.data], + ) + + const selectedDiff = + selectedFile === undefined + ? undefined + : diffQuery.data?.find((entry) => entry.file === selectedFile) + + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: vcsKey, refetchType: 'none' }) + void infoQuery.refetch({ cancelRefetch: false }) + void (mode === 'working' ? statusQuery : diffQuery).refetch({ cancelRefetch: false }) + if (mode === 'working' && selectedFile !== undefined) { + void diffQuery.refetch({ cancelRefetch: false }) + } + } + + useRefreshOnOpen(true, refresh) + + const branch = infoQuery.data?.branch.current ?? infoQuery.data?.branch.default ?? 'unknown' + const isRefreshing = infoQuery.isFetching || listQuery.isFetching || diffQuery.isFetching + + const selectMode = (next: VcsMode) => { + if (next === mode) return + setSelectedFile(undefined) + setMode(next) + } + + if (selectedFile !== undefined) { + return ( +
+
+ + + {selectedFile} + +
+
+ {diffQuery.isLoading ? ( +
+ +
+ ) : diffQuery.error ? ( +
+ +

Failed to load diff

+

{errorMessage(diffQuery.error)}

+
+ ) : ( + setSelectedFile(undefined)} + isMobile={compact} + /> + )} +
+
+ ) + } + + return ( +
+
+
+ + {branch} +
+
+ +
+
+ +
+ {MODES.map((entry) => ( + + ))} +
+ +
+ {listQuery.isLoading ? ( +
+ +
+ ) : listQuery.error ? ( +
+ +
+

Failed to load changes

+

{errorMessage(listQuery.error)}

+
+ +
+ ) : files.length === 0 ? ( +
+

+ {mode === 'working' ? 'No working changes' : 'No branch changes'} +

+
+ ) : ( + setSelectedFile(path)} + readOnly + /> + )} +
+
+ ) +} + +interface RemoteSourceControlPanelProps { + serverId: string + directory: string + isOpen: boolean + onClose: () => void +} + +export function RemoteSourceControlPanel({ + serverId, + directory, + isOpen, + onClose, +}: RemoteSourceControlPanelProps) { + const isMobile = useMobile() + + return ( + + + + ) +} diff --git a/frontend/src/components/source-control/SourceControlPanel.tsx b/frontend/src/components/source-control/SourceControlPanel.tsx index df27ce523..6871f594c 100644 --- a/frontend/src/components/source-control/SourceControlPanel.tsx +++ b/frontend/src/components/source-control/SourceControlPanel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import type { ReactNode } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { FetchError } from '@opencode-manager/shared' import { useGitStatus, getApiErrorMessage } from '@/api/git' @@ -287,6 +288,32 @@ export function SourceControlPanel({ }: SourceControlPanelProps) { const isMobile = useMobile() + return ( + + + + ) +} + +interface SourceControlDialogProps { + isOpen: boolean + onClose: () => void + title: string + children: ReactNode +} + +export function SourceControlDialog({ isOpen, onClose, title, children }: SourceControlDialogProps) { + const isMobile = useMobile() + return ( !open && onClose()}> - {isMobile && repoName ? repoName : 'Source Control'} + {title} {isMobile && ( diff --git a/frontend/src/components/source-control/index.ts b/frontend/src/components/source-control/index.ts index 9611420a6..78e99ee8b 100644 --- a/frontend/src/components/source-control/index.ts +++ b/frontend/src/components/source-control/index.ts @@ -1,4 +1,5 @@ export { SourceControlPanel, SourceControlContent } from './SourceControlPanel' +export { RemoteSourceControlPanel } from './RemoteSourceControlContent' export { ChangesTab } from './ChangesTab' export { CommitsTab } from './CommitsTab' export { BranchesTab } from './BranchesTab' diff --git a/frontend/src/components/terminal/TerminalPanel.test.tsx b/frontend/src/components/terminal/TerminalPanel.test.tsx index 63e2b5239..b226c96b0 100644 --- a/frontend/src/components/terminal/TerminalPanel.test.tsx +++ b/frontend/src/components/terminal/TerminalPanel.test.tsx @@ -22,15 +22,23 @@ vi.mock('./TerminalView', async () => { const React = await import('react') return { TerminalView: React.forwardRef(function MockTerminalView( - props: { ptyID: string; active: boolean }, + props: { ptyID: string; active: boolean; onExited: () => void }, ref: React.Ref, ) { React.useImperativeHandle(ref, () => ({ send: vi.fn() })) - return React.createElement('div', { - 'data-testid': 'terminal-view', - 'data-pty-id': props.ptyID, - 'data-active': String(props.active), - }) + return React.createElement( + 'div', + { + 'data-testid': 'terminal-view', + 'data-pty-id': props.ptyID, + 'data-active': String(props.active), + }, + React.createElement( + 'button', + { type: 'button', 'aria-label': `exit ${props.ptyID}`, onClick: props.onExited }, + 'exit', + ), + ) }), } }) @@ -80,7 +88,7 @@ function mockTerminalHooks( function renderPanel(initialEntry = '/repos/1?dialog=terminal', onClose = vi.fn()) { return render( - + , ) } @@ -88,6 +96,10 @@ function renderPanel(initialEntry = '/repos/1?dialog=terminal', onClose = vi.fn( describe('TerminalPanel', () => { beforeEach(() => { vi.clearAllMocks() + createMutate.mockReset() + removeMutate.mockReset() + refetch.mockReset() + refetch.mockResolvedValue({ data: [] }) vi.mocked(useMobile).mockReturnValue(false) mockTerminalHooks([]) }) @@ -123,7 +135,7 @@ describe('TerminalPanel', () => { mockTerminalHooks([], { isSuccess: true }) rerender( - + , ) @@ -147,7 +159,7 @@ describe('TerminalPanel', () => { mockTerminalHooks([{ ...runningTerminal, status: 'exited', exitCode: 0 }]) rerender( - + , ) @@ -183,7 +195,7 @@ describe('TerminalPanel', () => { const panel = (isOpen: boolean) => ( - + ) @@ -267,6 +279,73 @@ describe('TerminalPanel', () => { expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't2', directory: '/repo' }, expect.any(Object)) }) + it('removes a cleanly exited terminal, closes the panel when it was the last, and does not respawn', async () => { + mockTerminalHooks([runningTerminal]) + refetch.mockResolvedValue({ data: [{ ...runningTerminal, status: 'exited', exitCode: 0 }] }) + removeMutate.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + const onClose = vi.fn() + renderPanel('/repos/1?dialog=terminal&terminal=t1', onClose) + await screen.findAllByTestId('terminal-view') + + fireEvent.click(screen.getByLabelText('exit t1')) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't1', directory: '/repo' }, expect.any(Object)) + expect(screen.queryByLabelText('Close Terminal')).not.toBeInTheDocument() + expect(createMutate).not.toHaveBeenCalled() + }) + + it('keeps a terminal that exited with a non-zero code so the failure stays inspectable', async () => { + mockTerminalHooks([runningTerminal]) + refetch.mockResolvedValue({ data: [{ ...runningTerminal, status: 'exited', exitCode: 2 }] }) + const onClose = vi.fn() + const { rerender } = renderPanel('/repos/1?dialog=terminal&terminal=t1', onClose) + await screen.findAllByTestId('terminal-view') + + fireEvent.click(screen.getByLabelText('exit t1')) + + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)) + expect(removeMutate).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByLabelText('Close Terminal')).toBeInTheDocument() + + mockTerminalHooks([{ ...runningTerminal, status: 'exited', exitCode: 2 }]) + rerender( + + + , + ) + + expect(await screen.findByText('exited (2)')).toBeInTheDocument() + }) + + it('keeps the panel open and selects another running terminal when one exits cleanly', async () => { + const otherTerminal: TerminalInfo = { ...runningTerminal, id: 't3', title: 'Shell' } + mockTerminalHooks([runningTerminal, otherTerminal]) + refetch.mockResolvedValue({ + data: [runningTerminal, { ...otherTerminal, status: 'exited', exitCode: 0 }], + }) + removeMutate.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + const onClose = vi.fn() + renderPanel('/repos/1?dialog=terminal&terminal=t3', onClose) + + const views = await screen.findAllByTestId('terminal-view') + expect(views.find((view) => view.getAttribute('data-pty-id') === 't3')).toHaveAttribute('data-active', 'true') + + fireEvent.click(screen.getByLabelText('exit t3')) + + await waitFor(() => expect(screen.queryByLabelText('Close Shell')).not.toBeInTheDocument()) + expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't3', directory: '/repo' }, expect.any(Object)) + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByTestId('terminal-view')).toHaveAttribute('data-pty-id', 't1') + expect(screen.getByTestId('terminal-view')).toHaveAttribute('data-active', 'true') + expect(createMutate).not.toHaveBeenCalled() + }) + it('renders the key bar only on mobile', async () => { mockTerminalHooks([runningTerminal]) diff --git a/frontend/src/components/terminal/TerminalPanel.tsx b/frontend/src/components/terminal/TerminalPanel.tsx index 62113afc4..00dbcc15e 100644 --- a/frontend/src/components/terminal/TerminalPanel.tsx +++ b/frontend/src/components/terminal/TerminalPanel.tsx @@ -10,6 +10,7 @@ import { useOpenPreview } from '@/hooks/useOpenPreview' import { useUrlParams } from '@/hooks/useUrlParams' import { openUrlFromManager } from '@/lib/open-url' import { cn } from '@/lib/utils' +import type { TerminalTarget } from '@/lib/terminal-target' import { TerminalKeyBar } from './TerminalKeyBar' import type { TerminalViewHandle } from './TerminalView' @@ -18,35 +19,40 @@ const TerminalView = lazy(() => ) interface TerminalPanelProps { - repoId: number + target: TerminalTarget directory: string | undefined isOpen: boolean onClose: () => void } interface TerminalWorkspaceProps { - repoId: number + target: TerminalTarget directory: string | undefined isOpen: boolean + onLastTerminalExited?: () => void } -export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspaceProps) { +export function TerminalWorkspace({ target, directory, isOpen, onLastTerminalExited }: TerminalWorkspaceProps) { const isMobile = useMobile() const { searchParams, updateParams } = useUrlParams() const openPreview = useOpenPreview() - const { data, isLoading, isSuccess, isFetching, refetch, error } = useTerminals(repoId, directory, { + const { data, isLoading, isSuccess, isFetching, refetch, error } = useTerminals(target, directory, { enabled: isOpen && !!directory, refetchInterval: isOpen ? 5000 : false, }) - const { mutate: createTerminal, isPending: isCreating } = useCreateTerminal(repoId) - const { mutate: removeTerminal, isPending: isRemoving } = useRemoveTerminal(repoId) + const { mutate: createTerminal, isPending: isCreating } = useCreateTerminal(target) + const { mutate: removeTerminal, isPending: isRemoving } = useRemoveTerminal(target) const [ctrlArmed, setCtrlArmed] = useState(false) const [pendingClose, setPendingClose] = useState(null) + const [dismissedTerminalIds, setDismissedTerminalIds] = useState>(() => new Set()) const viewHandlesRef = useRef(new Map()) const autoCreateCheckedDirectoryRef = useRef(null) - const terminals = useMemo(() => data ?? [], [data]) + const terminals = useMemo( + () => (data ?? []).filter((terminal) => !dismissedTerminalIds.has(terminal.id)), + [data, dismissedTerminalIds], + ) const selectTerminal = useCallback((id: string) => { updateParams((params) => { @@ -82,27 +88,63 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa ) }, [createTerminal, directory, selectTerminal]) - const performRemove = useCallback((terminal: TerminalInfo) => { - removeTerminal( - { ptyID: terminal.id, directory }, - { - onSuccess: () => { - updateParams((params) => { - if (params.get('terminal') === terminal.id) params.delete('terminal') - }, 'replace') + const performRemove = useCallback( + ( + terminalId: string, + options: { onRemoved?: () => void; onError?: () => void } = {}, + ) => { + removeTerminal( + { ptyID: terminalId, directory }, + { + onSuccess: () => { + updateParams((params) => { + if (params.get('terminal') === terminalId) params.delete('terminal') + }, 'replace') + options.onRemoved?.() + }, + onError: options.onError, }, - }, - ) - }, [removeTerminal, directory, updateParams]) + ) + }, + [removeTerminal, directory, updateParams], + ) const handleCloseTab = useCallback((terminal: TerminalInfo) => { if (terminal.status === 'running') { setPendingClose(terminal) return } - performRemove(terminal) + performRemove(terminal.id) }, [performRemove]) + const handleTerminalExited = useCallback((terminalId: string) => { + const settleExit = async () => { + let latestTerminals: TerminalInfo[] + try { + const result = await refetch() + latestTerminals = result.data ?? [] + } catch { + return + } + const exitCode = latestTerminals.find((terminal) => terminal.id === terminalId)?.exitCode + if (exitCode !== undefined && exitCode !== 0) return + setDismissedTerminalIds((ids) => new Set(ids).add(terminalId)) + performRemove(terminalId, { + onRemoved: () => { + if (latestTerminals.every((terminal) => terminal.id === terminalId)) onLastTerminalExited?.() + }, + onError: () => { + setDismissedTerminalIds((ids) => { + const next = new Set(ids) + next.delete(terminalId) + return next + }) + }, + }) + } + void settleExit() + }, [refetch, performRemove, onLastTerminalExited]) + const handleKeySend = useCallback((input: string) => { if (!activeTerminalId) return viewHandlesRef.current.get(activeTerminalId)?.send(input) @@ -182,14 +224,14 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa if (handle) viewHandlesRef.current.set(terminal.id, handle) else viewHandlesRef.current.delete(terminal.id) }} - repoId={repoId} + target={target} directory={directory} ptyID={terminal.id} active={terminal.id === activeTerminalId} ctrlArmed={ctrlArmed} onCtrlConsumed={handleCtrlConsumed} onOpenLink={handleOpenLink} - onExited={() => { void refetch() }} + onExited={() => handleTerminalExited(terminal.id)} /> ))} @@ -204,7 +246,7 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa open={pendingClose !== null} onOpenChange={(open) => { if (!open) setPendingClose(null) }} onConfirm={() => { - if (pendingClose) performRemove(pendingClose) + if (pendingClose) performRemove(pendingClose.id) setPendingClose(null) }} onCancel={() => setPendingClose(null)} @@ -218,7 +260,7 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa ) } -export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPanelProps) { +export function TerminalPanel({ target, directory, isOpen, onClose }: TerminalPanelProps) { const isMobile = useMobile() return ( @@ -250,7 +292,7 @@ export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPa )} - + ) diff --git a/frontend/src/components/terminal/TerminalView.test.tsx b/frontend/src/components/terminal/TerminalView.test.tsx index e7c44f8a4..e5d8d66f7 100644 --- a/frontend/src/components/terminal/TerminalView.test.tsx +++ b/frontend/src/components/terminal/TerminalView.test.tsx @@ -59,7 +59,7 @@ function renderView(overrides: Partial> = {} const element = ( { const ref = createRef() const onCtrlConsumed = vi.fn() const props: ComponentProps = { - repoId: 1, + target: { kind: 'repo', repoId: 1 }, directory: '/repo', ptyID: 't1', active: true, diff --git a/frontend/src/components/terminal/TerminalView.tsx b/frontend/src/components/terminal/TerminalView.tsx index 32a234acf..5017e96e9 100644 --- a/frontend/src/components/terminal/TerminalView.tsx +++ b/frontend/src/components/terminal/TerminalView.tsx @@ -6,6 +6,7 @@ import '@xterm/xterm/css/xterm.css' import { resizeTerminal } from '@/api/terminals' import { applyCtrlModifier } from '@/lib/terminal-protocol' import { resolveTerminalTheme } from '@/lib/terminal-theme' +import { terminalTargetKey, type TerminalTarget } from '@/lib/terminal-target' import { useTerminalSocket } from '@/hooks/useTerminalSocket' import { useMobile } from '@/hooks/useMobile' import { cn } from '@/lib/utils' @@ -19,7 +20,7 @@ export interface TerminalViewHandle { } interface TerminalViewProps { - repoId: number + target: TerminalTarget directory: string | undefined ptyID: string active: boolean @@ -30,7 +31,7 @@ interface TerminalViewProps { } export const TerminalView = forwardRef(function TerminalView({ - repoId, + target, directory, ptyID, active, @@ -50,8 +51,12 @@ export const TerminalView = forwardRef(fu const isMobile = useMobile() const isMobileRef = useRef(isMobile) const activeRef = useRef(active) + const targetRef = useRef(target) + const targetKey = terminalTargetKey(target) const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null) + targetRef.current = target + ctrlArmedRef.current = ctrlArmed onCtrlConsumedRef.current = onCtrlConsumed onOpenLinkRef.current = onOpenLink @@ -78,7 +83,7 @@ export const TerminalView = forwardRef(fu }, []) const { send } = useTerminalSocket({ - repoId, + target, directory, ptyID, onOutput: handleOutput, @@ -170,7 +175,7 @@ export const TerminalView = forwardRef(fu const lastSize = lastSizeRef.current if (lastSize && lastSize.cols === size.cols && lastSize.rows === size.rows) return lastSizeRef.current = size - resizeTerminal(repoId, ptyID, { directory, cols: size.cols, rows: size.rows }).catch(() => {}) + resizeTerminal(targetRef.current, ptyID, { directory, cols: size.cols, rows: size.rows }).catch(() => {}) }, RESIZE_DEBOUNCE_MS) } @@ -182,7 +187,7 @@ export const TerminalView = forwardRef(fu observer.disconnect() if (timer) clearTimeout(timer) } - }, [repoId, directory, ptyID]) + }, [targetKey, directory, ptyID]) useEffect(() => { if (!active) return diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 98e8190e5..c9a0191f2 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -522,6 +522,41 @@ describe('EventProvider permissions and forms', () => { }) }) + it('reconciles a linked server enabled after the browser stream connects', async () => { + const busySessionID = 'session-remote-busy' + mocks.listActiveSessions.mockImplementation((serverId?: string) => + Promise.resolve(serverId === 'srv_remote' ? { [busySessionID]: {} } : {}), + ) + + const { rerender } = render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + expect(mocks.listActiveSessions).not.toHaveBeenCalledWith('srv_remote') + + mocks.servers = [{ id: 'srv_remote', enabled: true }] + rerender() + + await waitFor(() => { + expect(useSessionStatus.getState().statuses.get(busySessionID)).toEqual({ type: 'busy' }) + }) + const remoteFetches = () => + mocks.listActiveSessions.mock.calls.filter(([serverId]) => serverId === 'srv_remote') + expect(remoteFetches()).toHaveLength(1) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + mocks.servers = [{ id: 'srv_remote', enabled: true }] + rerender() + + expect(remoteFetches()).toHaveLength(1) + }) + it('clears settled pending actions on an upstream resync while the browser stream stays connected', async () => { mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) mocks.listPendingPermissions.mockResolvedValue([]) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index b309d15c5..ac1368538 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -311,6 +311,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const initialFetchDoneRef = useRef(false) const statusSyncVersionRef = useRef(0) const remoteStatusSyncVersionsRef = useRef(new Map()) + const streamConnectedRef = useRef(false) + const reconciledRemoteServerIdsRef = useRef(new Set()) const statusPollIntervalRef = useRef | null>(null) const subscriptionRef = useRef | null>(null) const reposRef = useRef(null) @@ -776,6 +778,24 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } }, [resolveSessionAncestry]) + const reconcileRemoteServersOnce = useCallback((serversToReconcile: Array<{ id: string }>) => { + const reconciled = reconciledRemoteServerIdsRef.current + for (const server of serversToReconcile) { + if (reconciled.has(server.id)) continue + reconciled.add(server.id) + void reconcileRemoteSessionStatuses(server.id) + } + }, [reconcileRemoteSessionStatuses]) + + useEffect(() => { + const enabledIds = new Set(enabledRemoteServers.map((server) => server.id)) + for (const serverId of reconciledRemoteServerIdsRef.current) { + if (!enabledIds.has(serverId)) reconciledRemoteServerIdsRef.current.delete(serverId) + } + if (!streamConnectedRef.current) return + reconcileRemoteServersOnce(enabledRemoteServers) + }, [enabledRemoteServers, reconcileRemoteServersOnce]) + const stopStatusPoll = useCallback(() => { if (statusPollIntervalRef.current === null) return clearInterval(statusPollIntervalRef.current) @@ -855,6 +875,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } case 'session.idle': { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) + rememberSessionDirectory(event.data.sessionID, event.directory, serverId) useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) break } @@ -869,7 +890,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'succeeded') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } @@ -877,7 +898,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'failed') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } @@ -885,18 +906,18 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'interrupted') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } case 'session.deleted': { useSessionStatus.getState().forgetSession(event.data.sessionID) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) break } case 'session.created': { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) if (typeof event.data.parentID === 'string') { useSessionStatus.getState().setSessionParent(event.data.sessionID, event.data.parentID) } @@ -906,7 +927,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { case 'session.renamed': case 'session.moved': case 'session.metadata.updated': - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) break case 'shell.created': { const info = event.data.info @@ -961,6 +982,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { setSSHHostKeyRequest(event.properties) break case 'vcs.branch.updated': { + if (!isLocalOpenCodeServer(serverId)) break const repo = reposRef.current?.find((candidate) => repoMatchesDirectory(candidate, event.directory)) if (!repo) { queryClient.invalidateQueries({ queryKey: ['repo', 'siblings'] }) @@ -982,19 +1004,23 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } } + const reconciledRemoteServerIds = reconciledRemoteServerIdsRef.current + const handleStatusChange = (connected: boolean) => { if (connected) { + streamConnectedRef.current = true + reconciledRemoteServerIdsRef.current.clear() initialFetchDoneRef.current = false fetchInitialPendingData() fetchInitialSessionStatuses() - for (const server of enabledRemoteServersRef.current) { - void reconcileRemoteSessionStatuses(server.id) - } + reconcileRemoteServersOnce(enabledRemoteServersRef.current) void syncRunningShells(collectTrackedDirectories()) invalidateChildSessionCaches(queryClient) invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS) startStatusPoll() } else { + streamConnectedRef.current = false + reconciledRemoteServerIdsRef.current.clear() statusSyncVersionRef.current += 1 invalidateRemoteStatusSyncs() stopStatusPoll() @@ -1009,6 +1035,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { void reconcilePendingActionsForDirectories(directories) void reconcileRemoteSessionStatuses(serverId) void syncRunningShells(directories) + invalidateSessionListCachesDebounced(queryClient, undefined, serverId) invalidateQueryKeysDebounced( queryClient, LOCATION_CATALOG_QUERY_KEYS.map((key) => serverScopedKey(key, serverId)), @@ -1038,13 +1065,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { subscriptionRef.current = subscription return () => { + streamConnectedRef.current = false + reconciledRemoteServerIds.clear() statusSyncVersionRef.current += 1 invalidateRemoteStatusSyncs() stopStatusPoll() subscription.dispose() subscriptionRef.current = null } - }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteSessionStatuses, invalidateRemoteStatusSyncs]) + }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteSessionStatuses, reconcileRemoteServersOnce, invalidateRemoteStatusSyncs]) useEffect(() => { reposRef.current = repos diff --git a/frontend/src/contexts/SwipeNavigationContext.test.tsx b/frontend/src/contexts/SwipeNavigationContext.test.tsx index 7033519c2..fa6435b22 100644 --- a/frontend/src/contexts/SwipeNavigationContext.test.tsx +++ b/frontend/src/contexts/SwipeNavigationContext.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { renderHook } from '@testing-library/react' +import { renderHook, act } from '@testing-library/react' import { SwipeNavigationProvider, useSwipeNavigation } from './SwipeNavigationContext' describe('SwipeNavigationContext', () => { @@ -47,6 +47,24 @@ describe('SwipeNavigationContext', () => { expect(current?.isSuspended()).toBe(false) }) + it('round-trips a page back target', () => { + const { result } = renderHook(() => useSwipeNavigation(), { + wrapper: SwipeNavigationProvider, + }) + + expect(result.current?.backTarget()).toBeNull() + + act(() => { + result.current?.setBackTarget('/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') + }) + expect(result.current?.backTarget()).toBe('/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') + + act(() => { + result.current?.setBackTarget(null) + }) + expect(result.current?.backTarget()).toBeNull() + }) + it('clamps resume below zero to zero', () => { let current: ReturnType diff --git a/frontend/src/contexts/SwipeNavigationContext.tsx b/frontend/src/contexts/SwipeNavigationContext.tsx index e51af5a3f..154e31404 100644 --- a/frontend/src/contexts/SwipeNavigationContext.tsx +++ b/frontend/src/contexts/SwipeNavigationContext.tsx @@ -5,12 +5,17 @@ interface SwipeNavigationValue { suspend: () => void resume: () => void isSuspended: () => boolean + /** Overrides the URL-derived swipe-back destination for the current page; pass null to clear it. */ + setBackTarget: (path: string | null) => void + /** Returns the page-registered swipe-back destination, or null when the URL should decide. */ + backTarget: () => string | null } export const SwipeNavigationContext = createContext(null) function SwipeNavigationProvider({ children }: { children: ReactNode }) { const countRef = useRef(0) + const backTargetRef = useRef(null) const value = useMemo( () => ({ suspend: () => { @@ -20,6 +25,10 @@ function SwipeNavigationProvider({ children }: { children: ReactNode }) { countRef.current = Math.max(0, countRef.current - 1) }, isSuspended: () => countRef.current > 0, + setBackTarget: (path) => { + backTargetRef.current = path + }, + backTarget: () => backTargetRef.current, }), [] ) diff --git a/frontend/src/hooks/useActiveWork.test.tsx b/frontend/src/hooks/useActiveWork.test.tsx index 3337459b7..1b933c928 100644 --- a/frontend/src/hooks/useActiveWork.test.tsx +++ b/frontend/src/hooks/useActiveWork.test.tsx @@ -13,6 +13,7 @@ const WINDOW_MS = 60 * MS_PER_MINUTE const mocks = vi.hoisted(() => ({ listSessionPage: vi.fn(), + listRemoteSessionPage: vi.fn(), getSession: vi.fn(), getRepoSiblings: vi.fn(), useNavigableRepos: vi.fn(), @@ -23,7 +24,10 @@ vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - listSessionPage: mocks.listSessionPage, + listSessionPage: (input: Parameters[0], serverId?: string) => + serverId === undefined || serverId === 'local' + ? mocks.listSessionPage(input, serverId) + : mocks.listRemoteSessionPage(input, serverId), getSession: mocks.getSession, } }) @@ -103,6 +107,8 @@ const recentIDs = (groups: { recent: Array<{ session: SessionInfo }> }): string[ describe('useActiveWork', () => { beforeEach(() => { mocks.listSessionPage.mockReset() + mocks.listRemoteSessionPage.mockReset() + mocks.listRemoteSessionPage.mockResolvedValue({ items: [] }) mocks.getSession.mockReset() mocks.getRepoSiblings.mockReset() mocks.getRepoSiblings.mockResolvedValue([]) @@ -308,6 +314,69 @@ describe('useActiveWork', () => { expect(row.path).toBe('/servers/srv_kitchen/sessions/ses_remote') }) + it('includes recent sessions from an enabled linked server with its server label and path', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [] }) + mocks.listRemoteSessionPage.mockResolvedValue({ + items: [session('ses_remote_recent', now - 500, null, '/home/me/app')], + }) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(recentIDs(result.current.groups)).toContain('ses_remote_recent')) + + const row = result.current.groups.recent[0] + expect(row.serverId).toBe('srv_kitchen') + expect(row.serverName).toBe('Kitchen') + expect(row.repo).toBeNull() + expect(row.path).toBe('/servers/srv_kitchen/sessions/ses_remote_recent') + expect(mocks.listRemoteSessionPage).toHaveBeenCalledWith( + { limit: 50, order: 'desc', includeChildren: true }, + 'srv_kitchen', + ) + expect(result.current.isError).toBe(false) + }) + + it('rolls a listed remote child up to a root fetched from the same server', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [] }) + mocks.listRemoteSessionPage.mockResolvedValue({ + items: [session('ses_remote_child', now - 500, 'ses_remote_root', '/home/me/app')], + }) + mocks.getSession.mockImplementation(async (id: string, serverId?: string) => { + if (serverId !== 'srv_kitchen') throw new Error('local lookup for a remote session') + if (id === 'ses_remote_root') return session('ses_remote_root', now - 1500, null, '/home/me/app') + throw new Error(`unexpected session ${id}`) + }) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(result.current.groups.recent.map((item) => item.session.id)).toEqual(['ses_remote_root'])) + + expect(result.current.groups.recent[0].serverId).toBe('srv_kitchen') + expect(mocks.getSession).toHaveBeenCalledWith('ses_remote_root', 'srv_kitchen') + expect(mocks.getSession).not.toHaveBeenCalledWith('ses_remote_root', 'local') + }) + + it('keeps the local list when a linked server fails to load', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [session('local_recent', now - 500)] }) + mocks.listRemoteSessionPage.mockRejectedValue(new Error('server unreachable')) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(recentIDs(result.current.groups)).toContain('local_recent')) + + expect(result.current.isError).toBe(false) + expect(recentIDs(result.current.groups)).not.toContain('ses_remote_recent') + }) + it('renders a running remote session row and resolves its ancestry on the owning server', async () => { const now = Date.now() mocks.listSessionPage.mockResolvedValue({ items: [] }) diff --git a/frontend/src/hooks/useActiveWork.ts b/frontend/src/hooks/useActiveWork.ts index 0e4a8dcd0..fe3b0325b 100644 --- a/frontend/src/hooks/useActiveWork.ts +++ b/frontend/src/hooks/useActiveWork.ts @@ -1,13 +1,15 @@ import { useEffect, useMemo, useState } from 'react' import { useQueries, useQueryClient, type QueryClient } from '@tanstack/react-query' -import { LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { SessionInfo } from '@opencode-manager/shared/opencode' +import { listSessionPage } from '@/api/opencode' import { sessionQueryOptions, useSessionsAcrossDirectories } from '@/hooks/useOpenCode' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' import { usePendingInputSessionIDs, useRepoWorktreeList } from '@/contexts/RepoWorktreesContext' import { useSessionStatus } from '@/stores/sessionStatusStore' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' import { serverScopedKey } from '@/lib/serverScope' +import { activeWorkSessionsQueryKey } from '@/lib/queryInvalidation' import { useSettings } from '@/hooks/useSettings' import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' import { useRepoWorktrees } from '@/hooks/useRepoWorktrees' @@ -15,16 +17,21 @@ import { MS_PER_MINUTE, buildActiveWork, buildRepoActivityIndex, + buildServerActivityIndex, collectActiveRoots, needsWorktreeLookup, + projectActivityFromIndex, repoActivityFromIndex, rootSessionID, + serverActivityFromIndex, + sessionScopeOfDirectory, type ActiveWorkGroups, } from '@/lib/activeWork' import { combineQuerySessions, mergeExtraSessions } from '@/components/navigation/sidebar-session-tree' import type { Repo } from '@/api/types' const ACTIVE_WORK_SESSION_LIMIT = 50 +const ACTIVE_WORK_SESSIONS_STALE_MS = 10000 /** * The active-work time window in minutes, derived from the user's configured @@ -80,6 +87,79 @@ export function useRepoActivityState(repo: Repo): 'needs-input' | 'running' | nu ) } +/** + * Whether a linked server has any active session, returned as a primitive so rows only re-render + * when it changes. Reads only the session-status store and the pending queues, with no network. + */ +export function useServerActivityState(serverId: string): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => + serverActivityFromIndex( + serverId, + buildServerActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + sessionServers: state.sessionServers, + pendingInputSessionIDs, + }), + ), + ) +} + +/** + * Whether any local session is active, returned as a primitive so the local switcher row only + * re-renders when it changes. Reads only the session-status store and the pending queues. + */ +export function useLocalActivityState(): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => { + const index = buildRepoActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + pendingInputSessionIDs, + }) + + let running = false + for (const activity of index.values()) { + if (activity === 'needs-input') return 'needs-input' + running = true + } + return running ? 'running' : null + }) +} + +/** + * Whether a project on a linked server has any active session, returned as a primitive so rows + * only re-render when it changes. + */ +export function useProjectActivityState( + serverId: string, + directory: string, +): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => + projectActivityFromIndex( + serverId, + directory, + buildServerActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + sessionServers: state.sessionServers, + pendingInputSessionIDs, + }), + ), + ) +} + interface SessionScope { serverId: string directory: string | undefined @@ -94,13 +174,8 @@ function sessionScopeOf( directories: ReadonlyMap, sessionServers: ReadonlyMap, ): SessionScope { - const directoryKey = directories.get(sessionID) - const parsed = directoryKey ? parseServerDirectoryKey(directoryKey) : null - const serverId = - parsed && parsed.serverId !== LOCAL_OPENCODE_SERVER_ID - ? parsed.serverId - : sessionServers.get(sessionID) ?? LOCAL_OPENCODE_SERVER_ID - return { serverId, directory: parsed?.directory } + const scope = sessionScopeOfDirectory(directories.get(sessionID), sessionServers.get(sessionID)) + return { serverId: scope.serverId, directory: scope.directory ?? undefined } } /** @@ -140,6 +215,25 @@ const combineAncestorLinks = ( results: Array<{ data?: Array<[string, string | null]> }>, ): Array<[string, string | null]> => results.flatMap((result) => result.data ?? []) +interface ServerSessionPage { + serverId: string + sessions: SessionInfo[] +} + +/** + * Pairs each server's active-work page with its server id, dropping pages that are empty or failed + * so one unreachable server never removes the others from the list. + */ +function combineServerSessionPages( + results: Array<{ data?: SessionInfo[] }>, + serverIds: ReadonlyArray, +): ServerSessionPage[] { + return results.flatMap((result, index) => { + if (!result.data || result.data.length === 0) return [] + return [{ serverId: serverIds[index], sessions: result.data }] + }) +} + /** * The full active-work list. Performs network reads, so it must only be used by components that * are mounted while visible (the session picker fetches only while open). @@ -198,16 +292,70 @@ export function useActiveWork(windowMs: number): { const sessionServers = useSessionStatus((state) => state.sessionServers) const pendingInputSessionIDs = usePendingInputSessionIDs() + const { servers } = useOpenCodeServers() + const remoteServerIds = useMemo( + () => servers.filter((server) => server.enabled).map((server) => server.id), + [servers], + ) + + const serverSessionPages = useQueries({ + queries: remoteServerIds.map((serverId) => ({ + queryKey: activeWorkSessionsQueryKey(serverId), + queryFn: async (): Promise => { + const page = await listSessionPage( + { limit: ACTIVE_WORK_SESSION_LIMIT, order: 'desc', includeChildren: true }, + serverId, + ) + return page.items + }, + staleTime: ACTIVE_WORK_SESSIONS_STALE_MS, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + retry: 1, + })), + combine: (results) => combineServerSessionPages(results, remoteServerIds), + }) + + const serverSessions = useMemo( + () => serverSessionPages.flatMap((page) => page.sessions), + [serverSessionPages], + ) + + const serverSessionServers = useMemo(() => { + const owners = new Map() + for (const page of serverSessionPages) { + for (const session of page.sessions) { + owners.set(session.id, page.serverId) + if (session.parentID) owners.set(session.parentID, page.serverId) + } + } + return owners + }, [serverSessionPages]) + + const allSessions = useMemo( + () => (serverSessions.length === 0 ? sessions : [...sessions, ...serverSessions]), + [sessions, serverSessions], + ) + + const sessionOwners = useMemo(() => { + if (serverSessionServers.size === 0) return sessionServers + const merged = new Map(sessionServers) + for (const [sessionID, serverId] of serverSessionServers) { + if (!merged.has(sessionID)) merged.set(sessionID, serverId) + } + return merged + }, [sessionServers, serverSessionServers]) + const parents = useMemo(() => { const merged = new Map() - for (const session of sessions) { + for (const session of allSessions) { merged.set(session.id, session.parentID ?? null) } for (const [sessionID, parentID] of storeParents) { if (!merged.has(sessionID)) merged.set(sessionID, parentID) } return merged - }, [sessions, storeParents]) + }, [allSessions, storeParents]) const roots = useMemo( () => collectActiveRoots({ statuses, parents, runningShells, pendingInputSessionIDs }), @@ -215,14 +363,14 @@ export function useActiveWork(windowMs: number): { ) const listedSessionIDs = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions], + () => new Set(allSessions.map((session) => session.id)), + [allSessions], ) const missingRootIDs = useMemo(() => { const ids = new Set() const windowStart = now - windowMs - for (const session of sessions) { + for (const session of allSessions) { if (session.time.updated < windowStart) continue const rootID = rootSessionID(session.id, parents) if (!listedSessionIDs.has(rootID)) ids.add(rootID) @@ -234,14 +382,14 @@ export function useActiveWork(windowMs: number): { if (!listedSessionIDs.has(sessionID)) ids.add(sessionID) } return [...ids] - }, [sessions, parents, listedSessionIDs, roots, windowMs, now]) + }, [allSessions, parents, listedSessionIDs, roots, windowMs, now]) const ancestorLinks = useQueries({ queries: missingRootIDs.map((sessionID) => { - const { serverId } = sessionScopeOf(sessionID, sessionDirectories, sessionServers) + const { serverId } = sessionScopeOf(sessionID, sessionDirectories, sessionOwners) return { queryKey: serverScopedKey(['opencode', 'session-ancestry', sessionID], serverId), - queryFn: () => fetchAncestorLinks(queryClient, sessionID, sessionDirectories, sessionServers), + queryFn: () => fetchAncestorLinks(queryClient, sessionID, sessionDirectories, sessionOwners), staleTime: Infinity, } }), @@ -262,8 +410,8 @@ export function useActiveWork(windowMs: number): { ) const resolvedSessionServers = useMemo(() => { - const merged = new Map(sessionServers) - for (const [sessionID, serverId] of sessionServers) { + const merged = new Map(sessionOwners) + for (const [sessionID, serverId] of sessionOwners) { const visited = new Set() let currentID: string | null | undefined = sessionID while (currentID && !visited.has(currentID)) { @@ -273,7 +421,7 @@ export function useActiveWork(windowMs: number): { } } return merged - }, [sessionServers, resolvedParents]) + }, [sessionOwners, resolvedParents]) const hasRemoteRoots = useMemo( () => @@ -283,12 +431,12 @@ export function useActiveWork(windowMs: number): { [resolvedRoots, resolvedSessionServers], ) - const { servers } = useOpenCodeServers({ enabled: hasRemoteRoots }) + const hasServerSessions = hasRemoteRoots || serverSessionPages.length > 0 const serverNames = useMemo(() => { - if (!hasRemoteRoots || servers.length === 0) return undefined + if (!hasServerSessions || servers.length === 0) return undefined return new Map(servers.map((server) => [server.id, server.name])) - }, [hasRemoteRoots, servers]) + }, [hasServerSessions, servers]) const backfillRootIDs = useMemo(() => { const ids = new Set() @@ -308,8 +456,8 @@ export function useActiveWork(windowMs: number): { }) const mergedSessions = useMemo( - () => mergeExtraSessions(sessions, fetchedSessions), - [sessions, fetchedSessions], + () => mergeExtraSessions(allSessions, fetchedSessions), + [allSessions, fetchedSessions], ) const needsLookup = useMemo( diff --git a/frontend/src/hooks/useCommands.test.tsx b/frontend/src/hooks/useCommands.test.tsx index c7c2a9070..84b88ef66 100644 --- a/frontend/src/hooks/useCommands.test.tsx +++ b/frontend/src/hooks/useCommands.test.tsx @@ -168,9 +168,9 @@ describe('useCommands', () => { const names = result.current.searchCommands('').map(({ item }) => item.name) - expect(names).not.toContain('sessions') - expect(names).not.toContain('resume') - expect(names).not.toContain('continue') + expect(names).toContain('sessions') + expect(names).toContain('resume') + expect(names).toContain('continue') expect(names).not.toContain('walkthrough') expect(names).toContain('mcp') expect(names).toContain('skills') diff --git a/frontend/src/hooks/useGlobalShortcutActions.test.tsx b/frontend/src/hooks/useGlobalShortcutActions.test.tsx index 75390fdf8..4d3c75552 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.test.tsx +++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx @@ -7,6 +7,7 @@ import { useGlobalShortcutActions } from './useGlobalShortcutActions' const mocks = vi.hoisted(() => ({ getRepo: vi.fn(), + getSession: vi.fn(), createSession: vi.fn(), useMobile: vi.fn(), })) @@ -27,6 +28,7 @@ vi.mock('@/api/repos', () => ({ vi.mock('@/api/opencode', () => ({ createSession: mocks.createSession, + getSession: mocks.getSession, })) function Harness() { @@ -60,6 +62,7 @@ describe('useGlobalShortcutActions', () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) mocks.useMobile.mockReturnValue(false) mocks.getRepo.mockResolvedValue({ id: 5, fullPath: '/repo/five' }) + mocks.getSession.mockResolvedValue({ id: 'ses_1', location: { directory: '/srv/kitchen' } }) mocks.createSession.mockResolvedValue({ id: 'session-new' }) }) @@ -129,10 +132,20 @@ describe('useGlobalShortcutActions', () => { await waitFor(() => expect(location()).toBe('/repos/5/sessions/session-new')) }) - it('ignores the terminal and source-control shortcuts on a linked-server route', async () => { + it('toggles a linked-server route tool without falling back to a Manager host', async () => { const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') pressLeaderFollowUp('t') + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1?panel=terminal')) + + pressLeaderFollowUp('t') + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1')) + expect(mocks.getRepo).not.toHaveBeenCalled() + }) + + it('does not fall back to a Manager host for a tool the linked-server route lacks', async () => { + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + pressLeaderFollowUp('b') await act(async () => { @@ -143,6 +156,30 @@ describe('useGlobalShortcutActions', () => { expect(mocks.getRepo).not.toHaveBeenCalled() }) + it('toggles the terminal dialog on a linked-server route on mobile', async () => { + mocks.useMobile.mockReturnValue(true) + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + + pressLeaderFollowUp('t') + + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1?dialog=terminal')) + }) + + it('creates a session in the linked server directory and navigates to the server session path', async () => { + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledWith('ses_1', 'srv_kitchen')) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + pressKey({ key: 'o', ctrlKey: true }) + pressKey({ key: 'n' }) + + await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/kitchen' }, 'srv_kitchen')) + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/session-new')) + }) + it('does not swallow the follow-up key when the route has no new-session host', async () => { renderHarness('/') diff --git a/frontend/src/hooks/useGlobalShortcutActions.ts b/frontend/src/hooks/useGlobalShortcutActions.ts index 989d7ef96..a4c2ebb71 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.ts +++ b/frontend/src/hooks/useGlobalShortcutActions.ts @@ -6,17 +6,25 @@ import { getRepo } from '@/api/repos' import { buildToolItems, toolKeyOf } from '@/components/navigation/moreDrawerItems' import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' import { dialogSearch } from '@/hooks/useDialogParam' -import { useCreateSession } from '@/hooks/useOpenCode' +import { sessionQueryOptions, useCreateSession } from '@/hooks/useOpenCode' import { useMobile } from '@/hooks/useMobile' import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useUrlParams } from '@/hooks/useUrlParams' import { toggleToolDialogParams, toggleToolPanelParams, type PanelTool } from '@/hooks/useToolPanel' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { getAssistantPath, getRepoPath, getSessionPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { + getAssistantPath, + getRepoPath, + getServerSessionPath, + getSessionPath, + isAssistantPath, + parseRepoRoute, + parseServerSessionRoute, +} from '@/lib/navigation' /** - * Registers the app-wide shortcut actions: the settings dialog, the route tool panels, and repo-scoped session - * creation. Page layers can override any of these by registering the same action name. + * Registers the app-wide shortcut actions: the settings dialog, the route tool panels, and session creation on the + * route's OpenCode server. Page layers can override any of these by registering the same action name. */ export function useGlobalShortcutActions(): void { const { pathname } = useLocation() @@ -24,7 +32,8 @@ export function useGlobalShortcutActions(): void { const { updateParams } = useUrlParams() const { open: openSettings } = useSettingsDialog() const isMobile = useMobile() - const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) + const serverId = useRouteOpenCodeServerId() + const isRemoteRoute = !isLocalOpenCodeServer(serverId) const repoId = isAssistantPath(pathname) ? ASSISTANT_REPO_ID : parseRepoRoute(pathname).repoId @@ -34,16 +43,25 @@ export function useGlobalShortcutActions(): void { enabled: repoId !== null, }) - const createSession = useCreateSession(repo?.fullPath, (session) => { + const remoteSessionRoute = isRemoteRoute ? parseServerSessionRoute(pathname) : null + const { data: remoteSession } = useQuery( + sessionQueryOptions(remoteSessionRoute?.sessionId, undefined, serverId), + ) + const sessionDirectory = isRemoteRoute ? remoteSession?.location?.directory : repo?.fullPath + + const createSession = useCreateSession(sessionDirectory, (session) => { + if (isRemoteRoute) { + navigate(getServerSessionPath(serverId, session.id)) + return + } if (repoId === null) return navigate(getSessionPath(repoId, session.id)) - }) + }, serverId) const toolItems = useMemo(() => buildToolItems(pathname), [pathname]) const hasFilesTool = toolItems.some((item) => toolKeyOf(item) === 'files') const toggleRouteTool = useCallback((tool: PanelTool) => { - if (isRemoteRoute) return if (toolItems.some((item) => toolKeyOf(item) === tool)) { updateParams((params) => { if (isMobile) toggleToolDialogParams(params, tool) @@ -51,12 +69,12 @@ export function useGlobalShortcutActions(): void { }, 'push') return } - if (tool === 'files') return + if (isRemoteRoute || tool === 'files') return const host = repoId !== null ? getRepoPath(repoId) : getAssistantPath() navigate(`${host}${dialogSearch(tool)}`) }, [toolItems, isMobile, updateParams, navigate, repoId, isRemoteRoute]) - const newSession = repoId !== null && repo?.fullPath && !createSession.isPending + const newSession = sessionDirectory && !createSession.isPending ? () => createSession.mutate({}) : undefined diff --git a/frontend/src/hooks/useModelSelection.test.tsx b/frontend/src/hooks/useModelSelection.test.tsx index eebcc9cf8..531537077 100644 --- a/frontend/src/hooks/useModelSelection.test.tsx +++ b/frontend/src/hooks/useModelSelection.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useModelSelection, useOpenCodeDefaultModel } from './useModelSelection' +import { modelStateQueryKey, useModelSelection, useOpenCodeDefaultModel } from './useModelSelection' import { useProviders } from './useProviders' import { locationAgentKey, useModelStore, type ModelSelection } from '@/stores/modelStore' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' @@ -493,8 +493,7 @@ describe('useModelSelection', () => { models: [modelInfo('anthropic', 'claude-sonnet-4'), modelInfo('openai', 'gpt-4o')], } - const renderRemote = (directory = '/test') => { - const queryClient = createTestQueryClient() + const renderRemote = (directory = '/test', queryClient = createTestQueryClient()) => { return renderHook( () => useModelSelection(directory), { @@ -531,42 +530,95 @@ describe('useModelSelection', () => { ).toBeUndefined() }) - it('does not read or write the local model state', async () => { + it('reads the shared model state and writes favorites and recents on a remote server', async () => { + mockGetOpenCodeModelState.mockResolvedValue({ + recent: [ + { providerID: 'openai', modelID: 'gpt-4o' }, + { providerID: 'ghost', modelID: 'gone' }, + ], + favorite: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'ghost', modelID: 'gone' }, + ], + variant: {}, + }) + const { result } = renderRemote() await waitFor(() => { expect(result.current.isModelReady).toBe(true) }) + expect(mockGetOpenCodeModelState).toHaveBeenCalledTimes(1) + expect(result.current.modelState).toBeDefined() + await waitFor(() => { + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) + }) + await waitFor(() => { + expect(result.current.favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + }) + act(() => { result.current.setModel({ providerID: 'openai', modelID: 'gpt-4o' }) result.current.toggleFavorite({ providerID: 'openai', modelID: 'gpt-4o' }) result.current.removeRecentModel({ providerID: 'openai', modelID: 'gpt-4o' }) }) + await waitFor(() => { + expect(mockAddOpenCodeRecentModel).toHaveBeenCalledTimes(1) + expect(mockToggleOpenCodeFavoriteModel).toHaveBeenCalledTimes(1) + expect(mockRemoveOpenCodeRecentModel).toHaveBeenCalledTimes(1) + }) + expect(mockAddOpenCodeRecentModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + expect(mockToggleOpenCodeFavoriteModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + expect(mockRemoveOpenCodeRecentModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + }) + + it('reads the model state a local server populated from the same cache entry', async () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(modelStateQueryKey(), { + recent: [{ providerID: 'openai', modelID: 'gpt-4o' }], + favorite: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + variant: {}, + }) + + const { result } = renderRemote('/test', queryClient) + + await waitFor(() => { + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) + }) + expect(result.current.favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) expect(mockGetOpenCodeModelState).not.toHaveBeenCalled() - expect(mockAddOpenCodeRecentModel).not.toHaveBeenCalled() - expect(mockToggleOpenCodeFavoriteModel).not.toHaveBeenCalled() - expect(mockRemoveOpenCodeRecentModel).not.toHaveBeenCalled() - expect(result.current.recentModels).toEqual([]) + }) + + it('stays ready with empty preferences when the model state fails to load', async () => { + mockGetOpenCodeModelState.mockRejectedValue(new Error('offline')) + const { result } = renderRemote() + + await waitFor(() => { + expect(result.current.isModelReady).toBe(true) + }) expect(result.current.favoriteModels).toEqual([]) - expect(result.current.modelState).toBeUndefined() + expect(result.current.model).not.toBeNull() }) - it('ignores local recents in the selection order', async () => { - mockGetOpenCodeConfiguredModel.mockResolvedValue('anthropic/claude-sonnet-4') + it('uses the shared recents in the selection order', async () => { mockGetOpenCodeModelState.mockResolvedValue({ recent: [{ providerID: 'openai', modelID: 'gpt-4o' }], favorite: [], - variant: { 'openai/gpt-4o': 'high' }, + variant: {}, }) const { result } = renderRemote() await waitFor(() => { - expect(result.current.model).toEqual({ providerID: 'anthropic', modelID: 'claude-sonnet-4' }) + expect(result.current.model).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) }) - expect(result.current.recentModels).toEqual([]) + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) }) it('keeps the agent model ahead of the configured model', async () => { diff --git a/frontend/src/hooks/useModelSelection.ts b/frontend/src/hooks/useModelSelection.ts index 9575dd98e..95b554ed7 100644 --- a/frontend/src/hooks/useModelSelection.ts +++ b/frontend/src/hooks/useModelSelection.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { locationAgentKey, useModelStore, type ActiveAgent, type ModelPick, type ModelSelection } from '@/stores/modelStore' -import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { serverScopedKey } from '@/lib/serverScope' import { buildServerDirectoryKey } from '@opencode-manager/shared/schemas' import { useProviders } from './useProviders' @@ -62,13 +62,12 @@ export function modelStateQueryKey() { } export function useOpenCodeModelState(_directory?: string, enabled = true) { - const serverId = useOpenCodeServerId() return useQuery({ - queryKey: serverScopedKey(modelStateQueryKey(), serverId), + queryKey: modelStateQueryKey(), queryFn: () => getOpenCodeModelState(), staleTime: 30000, placeholderData: keepPreviousData, - enabled: enabled && isLocalOpenCodeServer(serverId), + enabled, }) } @@ -123,8 +122,7 @@ export function useModelStateMutation( errorMessage: string, ) { const queryClient = useQueryClient() - const serverId = useOpenCodeServerId() - const queryKey = serverScopedKey(modelStateQueryKey(), serverId) + const queryKey = modelStateQueryKey() return useMutation({ mutationFn: fetcher, @@ -222,8 +220,6 @@ export function useModelSelection( ): UseModelSelectionResult { const { data: catalog, isPlaceholderData: catalogIsPlaceholder } = useProviders(directory) const serverId = useOpenCodeServerId() - const isLocalServer = isLocalOpenCodeServer(serverId) - const activeAgent = useModelStore((state) => state.activeAgent) const newSessionPicks = useModelStore((state) => state.newSessionPicks) const sessionPicks = useModelStore((state) => state.sessionPicks) @@ -231,14 +227,13 @@ export function useModelSelection( const setSessionPick = useModelStore((state) => state.setSessionPick) const setActiveAgent = useModelStore((state) => state.setActiveAgent) - const { data: localModelState } = useOpenCodeModelState() - const modelState = isLocalServer ? localModelState : undefined + const { data: modelState, isError: modelStateFailed } = useOpenCodeModelState() const { data: configModelString } = useOpenCodeConfiguredModel(directory) const isModelReady = Boolean( catalog && !catalogIsPlaceholder && - (!isLocalServer || modelState !== undefined) && + (modelState !== undefined || modelStateFailed) && configModelString !== undefined, ) @@ -353,16 +348,14 @@ export function useModelSelection( } else { setNewSessionPick(key, nextModel) } - if (isLocalServer) updateRecentModel.mutate(nextModel) + updateRecentModel.mutate(nextModel) } const toggleFavorite = (nextModel: ModelSelection) => { - if (!isLocalServer) return updateFavoriteModel.mutate(nextModel) } const removeRecent = (nextModel: ModelSelection) => { - if (!isLocalServer) return removeRecentMutation.mutate(nextModel) } diff --git a/frontend/src/hooks/useMultiRuns.ts b/frontend/src/hooks/useMultiRuns.ts index 0d7b5508b..566a224fe 100644 --- a/frontend/src/hooks/useMultiRuns.ts +++ b/frontend/src/hooks/useMultiRuns.ts @@ -7,15 +7,18 @@ import type { LaunchMultiRunRequest, MultiRun, MultiRunFusion, + MultiRunTarget, } from '@opencode-manager/shared/schemas' -function multiRunQueryKey(repoId: number) { - return ['multi-runs', repoId] as const +function multiRunQueryKey(target: MultiRunTarget | undefined) { + return ['multi-runs', target] as const } -function invalidateMultiRunCaches(queryClient: QueryClient, repoId: number) { - queryClient.invalidateQueries({ queryKey: multiRunQueryKey(repoId) }) - queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(repoId) }) +function invalidateMultiRunCaches(queryClient: QueryClient, target: MultiRunTarget) { + queryClient.invalidateQueries({ queryKey: multiRunQueryKey(target) }) + if (target.kind === 'repo') { + queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(target.repoId) }) + } } function buildStartingFusion(run: MultiRun, request: FuseMultiRunRequest): MultiRunFusion { @@ -45,22 +48,22 @@ function countEntries(run: MultiRun, status: MultiRun['entries'][number]['status return run.entries.filter((entry) => entry.status === status).length } -export function useMultiRuns(repoId: number | undefined, enabled: boolean) { +export function useMultiRuns(target: MultiRunTarget | undefined, enabled: boolean) { return useQuery({ - queryKey: multiRunQueryKey(repoId ?? 0), - queryFn: () => listMultiRuns(repoId!), - enabled: enabled && !!repoId && repoId > 0, + queryKey: multiRunQueryKey(target), + queryFn: () => listMultiRuns(target!), + enabled: enabled && target !== undefined, staleTime: 10_000, }) } -export function useLaunchMultiRun(repoId: number) { +export function useLaunchMultiRun(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: (request: LaunchMultiRunRequest) => launchMultiRun(request), onSuccess: (run) => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) const started = countEntries(run, 'started') const failed = countEntries(run, 'failed') if (failed === 0) { @@ -77,14 +80,14 @@ export function useLaunchMultiRun(repoId: number) { }) } -export function useFuseMultiRun(repoId: number) { +export function useFuseMultiRun(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ runId, request }: { runId: number; request: FuseMultiRunRequest }) => fuseMultiRun(runId, request), onMutate: async ({ runId, request }) => { - const queryKey = multiRunQueryKey(repoId) + const queryKey = multiRunQueryKey(target) await queryClient.cancelQueries({ queryKey }) queryClient.setQueryData(queryKey, (runs) => runs?.map((run) => @@ -95,10 +98,10 @@ export function useFuseMultiRun(repoId: number) { ) }, onSuccess: (run, variables) => { - queryClient.setQueryData(multiRunQueryKey(repoId), (runs) => + queryClient.setQueryData(multiRunQueryKey(target), (runs) => runs?.map((candidate) => (candidate.id === run.id ? run : candidate)), ) - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) const fusion = run.fusions.find((candidate) => candidate.requestId === variables.request.requestId) if (fusion?.status === 'started') { showToast.success('Fusion started') @@ -107,19 +110,19 @@ export function useFuseMultiRun(repoId: number) { } }, onError: () => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) }, }) } -export function useDiscardMultiRunEntry(repoId: number) { +export function useDiscardMultiRunEntry(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ runId, entryId }: { runId: number; entryId: number }) => discardMultiRunEntry(runId, entryId), onSuccess: () => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) showToast.success('Run discarded') }, onError: (error: unknown) => { diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index b9739e676..4a611dd66 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -466,6 +466,19 @@ describe('useCreateSession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/w/a', agent: undefined }, 'local') }) + + it('creates a session on an explicit server for a caller outside the server scope', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useCreateSession('/srv/kitchen', undefined, 'srv_kitchen'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ agent: undefined }) + }) + + expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/kitchen', agent: undefined }, 'srv_kitchen') + }) }) describe('useChildSessionReconciliation', () => { diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 051dec618..63a03aecd 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -246,11 +246,17 @@ export const useChildSessionReconciliation = (sessionID: string | undefined) => }); }; +/** + * Creates a session in `directory` on the route's server, or on `serverIdOverride` for callers rendered + * outside the server scope that act on a linked server. + */ export const useCreateSession = ( directory?: string, onSuccess?: (session: { id: string }) => void, + serverIdOverride?: string, ) => { - const serverId = useOpenCodeServerId(); + const routeServerId = useOpenCodeServerId(); + const serverId = serverIdOverride ?? routeServerId; const queryClient = useQueryClient(); return useMutation({ @@ -611,8 +617,9 @@ export const useSendShell = (directory?: string) => { }); }; -export const useAgents = (directory?: string, options?: { enabled?: boolean }) => { - const serverId = useOpenCodeServerId(); +export const useAgents = (directory?: string, options?: { enabled?: boolean; serverId?: string }) => { + const defaultServerId = useOpenCodeServerId() + const serverId = options?.serverId ?? defaultServerId; return useQuery({ queryKey: serverScopedKey(["opencode", "agents", directory], serverId), queryFn: () => listAgents(directory, serverId), diff --git a/frontend/src/hooks/useOpenCodeFailureToast.ts b/frontend/src/hooks/useOpenCodeFailureToast.ts index 3c23bf728..fb797eec5 100644 --- a/frontend/src/hooks/useOpenCodeFailureToast.ts +++ b/frontend/src/hooks/useOpenCodeFailureToast.ts @@ -12,7 +12,7 @@ export const OPENCODE_FAILURE_TOAST_ID = 'opencode-server-failure' */ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) { const { data: health } = useServerHealth(enabled) - const { setActiveTab } = useSettingsDialog() + const { openTab } = useSettingsDialog() const notifiedFailureRef = useRef(null) const issue = getOpenCodeServerIssue(health) @@ -27,7 +27,7 @@ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) id: OPENCODE_FAILURE_TOAST_ID, duration: Infinity, description: failureMessage, - action: { label: 'View logs', onClick: () => setActiveTab('logs') }, + action: { label: 'View logs', onClick: () => openTab('logs') }, cancel: { label: 'Restart', onClick: onRestart }, }) return @@ -38,5 +38,5 @@ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) showToast.dismiss(OPENCODE_FAILURE_TOAST_ID) showToast.success('OpenCode server is back online') } - }, [failureMessage, isHealthy, setActiveTab, onRestart]) + }, [failureMessage, isHealthy, openTab, onRestart]) } diff --git a/frontend/src/hooks/useOpenCodeServers.ts b/frontend/src/hooks/useOpenCodeServers.ts index b0da8cd78..750101083 100644 --- a/frontend/src/hooks/useOpenCodeServers.ts +++ b/frontend/src/hooks/useOpenCodeServers.ts @@ -58,6 +58,7 @@ export function useOpenCodeServers(options: { enabled?: boolean } = {}) { return { servers: serversQuery.data ?? [], isLoading: serversQuery.isLoading, + isSuccess: serversQuery.isSuccess, error: serversQuery.error, refetch: serversQuery.refetch, createAsync: createMutation.mutateAsync, diff --git a/frontend/src/hooks/useProviders.ts b/frontend/src/hooks/useProviders.ts index c5f77e178..0d8773000 100644 --- a/frontend/src/hooks/useProviders.ts +++ b/frontend/src/hooks/useProviders.ts @@ -5,10 +5,12 @@ import { serverScopedKey } from '@/lib/serverScope' interface UseProvidersOptions { enabled?: boolean + serverId?: string } export function useProviders(directory?: string, options: UseProvidersOptions = {}) { - const serverId = useOpenCodeServerId() + const defaultServerId = useOpenCodeServerId() + const serverId = options.serverId ?? defaultServerId return useQuery({ queryKey: serverScopedKey(['opencode', 'providers', directory], serverId), diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 23e60ad20..ce5f2b928 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -208,7 +208,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) setError(null) syncCurrentSession() invalidateShellCaches(queryClient) - eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) + eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current, serverId) } else { setError('Connection lost. Reconnecting...') } @@ -235,7 +235,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) } const handleVisibilityChange = () => { - subscription.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) + subscription.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current, serverId) } document.addEventListener('visibilitychange', handleVisibilityChange) @@ -247,7 +247,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('focus', handleReconnect) window.removeEventListener('online', handleReconnect) - subscription.reportVisibility(false, undefined) + subscription.reportVisibility(false, undefined, serverId) subscription.dispose() if (eventStreamSubscriptionRef.current === subscription) { eventStreamSubscriptionRef.current = null @@ -257,9 +257,9 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) useEffect(() => { if (isConnected && document.visibilityState === 'visible') { - eventStreamSubscriptionRef.current?.reportVisibility(true, currentSessionId) + eventStreamSubscriptionRef.current?.reportVisibility(true, currentSessionId, serverId) } - }, [currentSessionId, isConnected]) + }, [currentSessionId, isConnected, serverId]) return { isConnected, error, isReconnecting } } diff --git a/frontend/src/hooks/useScheduleModels.ts b/frontend/src/hooks/useScheduleModels.ts index 90f357e09..69abbdfe2 100644 --- a/frontend/src/hooks/useScheduleModels.ts +++ b/frontend/src/hooks/useScheduleModels.ts @@ -1,8 +1,8 @@ import { useProviders } from '@/hooks/useProviders' import type { ModelInfo } from '@opencode-manager/shared/opencode' -export function useScheduleModels(enabled: boolean, directory?: string) { - const providersQuery = useProviders(directory, { enabled }) +export function useScheduleModels(enabled: boolean, directory?: string, serverId?: string) { + const providersQuery = useProviders(directory, { enabled, serverId }) const availableModels: ModelInfo[] | null = providersQuery.isSuccess && !providersQuery.isPlaceholderData ? providersQuery.data?.models ?? [] diff --git a/frontend/src/hooks/useSchedules.ts b/frontend/src/hooks/useSchedules.ts index a3dd7c246..098255ab1 100644 --- a/frontend/src/hooks/useSchedules.ts +++ b/frontend/src/hooks/useSchedules.ts @@ -2,28 +2,55 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { CreateScheduleJobRequest, ScheduleRunWorktreesMode, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' import { cancelRepoScheduleRun, + cancelServerScheduleRun, clearRepoScheduleRuns, + clearServerScheduleRuns, createRepoSchedule, + createServerSchedule, deleteRepoSchedule, deleteRepoScheduleRun, + deleteServerSchedule, + deleteServerScheduleRun, getRepoSchedule, getRepoScheduleRun, + getServerSchedule, + getServerScheduleRun, listAllScheduleRuns, listAllSchedules, listRepoScheduleRuns, + listServerScheduleRuns, listScheduleWorktrees, listUnreadScheduleRuns, markAllScheduleRunsViewed, markScheduleRunViewed, removeScheduleWorktrees, runRepoSchedule, + runServerSchedule, updateRepoSchedule, + updateServerSchedule, } from '@/api/schedules' import { showToast } from '@/lib/toast' import type { ListAllRunsParams, ScheduleJobWithRepo, ScheduleRunWithContext } from '@/api/schedules' export const UNREAD_SCHEDULE_RUNS_QUERY_KEY = ['schedule-runs-unread'] as const +export type ScheduleTargetRef = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string } + +function invalidateScheduleQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedules'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule-run'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-run'] }) + queryClient.invalidateQueries({ queryKey: ['schedule-run-detail'] }) +} + export function useAllSchedules() { return useQuery({ queryKey: ['all-schedules'], @@ -289,3 +316,242 @@ export function useRemoveScheduleWorktrees() { }, }) } + +export function useServerSchedule(serverId: string | undefined, jobId: number | null) { + return useQuery({ + queryKey: ['server-schedule', serverId, jobId], + queryFn: async () => (await getServerSchedule(serverId!, jobId!)).job, + enabled: Boolean(serverId) && jobId !== null, + refetchInterval: jobId !== null ? 5000 : false, + }) +} + +export function useServerScheduleRuns(serverId: string | undefined, jobId: number | null, limit: number = 20) { + return useQuery({ + queryKey: ['server-schedule-runs', serverId, jobId, limit], + queryFn: async () => (await listServerScheduleRuns(serverId!, jobId!, limit)).runs, + enabled: Boolean(serverId) && jobId !== null, + refetchInterval: jobId !== null ? 5000 : false, + }) +} + +export function useServerScheduleRun(serverId: string | undefined, jobId: number | null, runId: number | null) { + return useQuery({ + queryKey: ['server-schedule-run', serverId, jobId, runId], + queryFn: async () => (await getServerScheduleRun(serverId!, jobId!, runId!)).run, + enabled: Boolean(serverId) && jobId !== null && runId !== null, + refetchInterval: runId !== null ? 5000 : false, + }) +} + +export function useScheduleRunDetail(run: { repoId: number | null; serverId?: string; jobId: number; id: number } | null) { + const serverId = run && run.repoId === null ? run.serverId : undefined + return useQuery({ + queryKey: ['schedule-run-detail', run?.repoId ?? null, serverId ?? null, run?.jobId ?? null, run?.id ?? null], + queryFn: async () => { + if (!run) return null + if (run.repoId === null) { + if (!run.serverId) return null + return (await getServerScheduleRun(run.serverId, run.jobId, run.id)).run + } + return (await getRepoScheduleRun(run.repoId, run.jobId, run.id)).run + }, + enabled: run !== null && (run.repoId !== null || Boolean(run.serverId)), + refetchInterval: run !== null ? 5000 : false, + }) +} + +export function useCreateServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: CreateScheduleJobRequest) => (await createServerSchedule(serverId, data)).job, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule created') + }, + onError: (error: unknown) => { + showToast.error(`Failed to create schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useUpdateServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ jobId, data }: { jobId: number; data: UpdateScheduleJobRequest }) => + (await updateServerSchedule(serverId, jobId, data)).job, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule', serverId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule updated') + }, + onError: (error: unknown) => { + showToast.error(`Failed to update schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (jobId: number) => deleteServerSchedule(serverId, jobId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useRunServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (jobId: number) => (await runServerSchedule(serverId, jobId)).run, + onSuccess: (run) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success(run.status === 'running' ? 'Schedule started' : 'Schedule run completed') + }, + onError: (error: unknown) => { + showToast.error(`Failed to run schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useCancelServerScheduleRun(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ jobId, runId }: { jobId: number; runId: number }) => + (await cancelServerScheduleRun(serverId, jobId, runId)).run, + onSuccess: (run) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-run', serverId, run.jobId, run.id] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule run cancelled') + }, + onError: (error: unknown) => { + showToast.error(`Failed to cancel schedule run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useClearServerScheduleRuns(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (jobId: number) => clearServerScheduleRuns(serverId, jobId), + onSuccess: (result, jobId) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + showToast.success(result.cleared > 0 ? `Cleared ${result.cleared} run${result.cleared === 1 ? '' : 's'}` : 'No runs to clear') + }, + onError: (error: unknown) => { + showToast.error(`Failed to clear run history: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteServerScheduleRun(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ jobId, runId }: { jobId: number; runId: number }) => deleteServerScheduleRun(serverId, jobId, runId), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + showToast.success('Run deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useUpdateScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId, data }: { target: ScheduleTargetRef; jobId: number; data: UpdateScheduleJobRequest }) => { + const response = target.kind === 'repo' + ? await updateRepoSchedule(target.repoId, jobId, data) + : await updateServerSchedule(target.serverId, jobId, data) + return response.job + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule updated') + }, + onError: (error: unknown) => { + showToast.error(`Failed to update schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId }: { target: ScheduleTargetRef; jobId: number }) => { + if (target.kind === 'repo') { + await deleteRepoSchedule(target.repoId, jobId) + return + } + await deleteServerSchedule(target.serverId, jobId) + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useRunScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId }: { target: ScheduleTargetRef; jobId: number }) => { + const response = target.kind === 'repo' + ? await runRepoSchedule(target.repoId, jobId) + : await runServerSchedule(target.serverId, jobId) + return response.run + }, + onSuccess: (run) => { + invalidateScheduleQueries(queryClient) + showToast.success(run.status === 'running' ? 'Schedule started' : 'Schedule run completed') + }, + onError: (error: unknown) => { + showToast.error(`Failed to run schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useCancelScheduleTargetRun() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId, runId }: { target: ScheduleTargetRef; jobId: number; runId: number }) => { + const response = target.kind === 'repo' + ? await cancelRepoScheduleRun(target.repoId, jobId, runId) + : await cancelServerScheduleRun(target.serverId, jobId, runId) + return response.run + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule run cancelled') + }, + onError: (error) => { + showToast.error(`Failed to cancel schedule run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} diff --git a/frontend/src/hooks/useSessionGoals.test.tsx b/frontend/src/hooks/useSessionGoals.test.tsx index 36300f6ea..d20f4f418 100644 --- a/frontend/src/hooks/useSessionGoals.test.tsx +++ b/frontend/src/hooks/useSessionGoals.test.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSessionGoal } from './useSessionGoals' import { getLatestSessionGoal } from '@/api/sessionGoals' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import type { SessionGoal, SessionGoalStatus } from '@opencode-manager/shared/schemas' vi.mock('@/api/sessionGoals', () => ({ @@ -21,6 +22,7 @@ vi.mock('@/lib/toast', () => ({ const baseGoal: SessionGoal = { id: 1, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'Ship the feature', status: 'active', @@ -38,9 +40,11 @@ const baseGoal: SessionGoal = { finishedAt: null, } -function createWrapper(queryClient: QueryClient) { +function createWrapper(queryClient: QueryClient, serverId?: string) { return ({ children }: { children: ReactNode }) => ( - {children} + + {serverId ? {children} : children} + ) } @@ -69,4 +73,14 @@ describe('useSessionGoal', () => { expect(refetchInterval!({ state: { data: { status: 'stopped' } } })).toBe(false) expect(refetchInterval!({ state: {} })).toBe(false) }) + + it('reads the goal for the current server and scopes its cache key', async () => { + vi.mocked(getLatestSessionGoal).mockResolvedValue({ ...baseGoal, serverId: 'srv_remote' }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderHook(() => useSessionGoal('ses_1'), { wrapper: createWrapper(queryClient, 'srv_remote') }) + + await waitFor(() => expect(getLatestSessionGoal).toHaveBeenCalledWith('ses_1', 'srv_remote')) + + expect(queryClient.getQueryCache().find({ queryKey: ['session-goal', 'ses_1', 'srv_remote'] })).toBeDefined() + }) }) diff --git a/frontend/src/hooks/useSessionGoals.ts b/frontend/src/hooks/useSessionGoals.ts index 8a2a774b1..56b3acf0e 100644 --- a/frontend/src/hooks/useSessionGoals.ts +++ b/frontend/src/hooks/useSessionGoals.ts @@ -6,17 +6,20 @@ import { resumeSessionGoal, startSessionGoal, } from '@/api/sessionGoals' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { serverScopedKey } from '@/lib/serverScope' import { showToast } from '@/lib/toast' import { SESSION_GOAL_POLL_INTERVAL_MS, type SessionGoal, type StartSessionGoalRequest } from '@opencode-manager/shared/schemas' -function sessionGoalQueryKey(sessionId: string) { - return ['session-goal', sessionId] as const +function sessionGoalQueryKey(sessionId: string, serverId: string) { + return serverScopedKey(['session-goal', sessionId], serverId) } export function useSessionGoal(sessionId: string, options: { enabled?: boolean } = {}) { + const serverId = useOpenCodeServerId() return useQuery({ - queryKey: sessionGoalQueryKey(sessionId), - queryFn: () => getLatestSessionGoal(sessionId), + queryKey: sessionGoalQueryKey(sessionId, serverId), + queryFn: () => getLatestSessionGoal(sessionId, serverId), enabled: options.enabled ?? true, refetchInterval: (query) => (query.state.data?.status === 'active' ? SESSION_GOAL_POLL_INTERVAL_MS : false), }) @@ -30,7 +33,7 @@ function useGoalMutation( return useMutation({ mutationFn, onSuccess: (goal: SessionGoal) => { - queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId), goal) + queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId, goal.serverId), goal) }, onError: (error: unknown) => { showToast.error(error instanceof Error ? error.message : errorMessage) @@ -39,7 +42,11 @@ function useGoalMutation( } export function useStartSessionGoal() { - return useGoalMutation((input: StartSessionGoalRequest) => startSessionGoal(input), 'Failed to start goal') + const serverId = useOpenCodeServerId() + return useGoalMutation( + (input: StartSessionGoalRequest) => startSessionGoal({ ...input, serverId }), + 'Failed to start goal', + ) } export function usePauseSessionGoal() { diff --git a/frontend/src/hooks/useSessionPermissionMode.test.tsx b/frontend/src/hooks/useSessionPermissionMode.test.tsx new file mode 100644 index 000000000..034b134e0 --- /dev/null +++ b/frontend/src/hooks/useSessionPermissionMode.test.tsx @@ -0,0 +1,63 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSessionPermissionMode, useSetSessionPermissionMode } from './useSessionPermissionMode' +import { getSessionPermissionMode, setSessionPermissionMode } from '@/api/sessionPermissionModes' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' + +vi.mock('@/api/sessionPermissionModes', () => ({ + getSessionPermissionMode: vi.fn(), + setSessionPermissionMode: vi.fn(), +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn() }, +})) + +function createWrapper(queryClient: QueryClient, serverId: string) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useSessionPermissionMode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads the mode for the current server and scopes its cache key', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + vi.mocked(getSessionPermissionMode).mockResolvedValue(state) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderHook(() => useSessionPermissionMode('ses_1'), { wrapper: createWrapper(queryClient, 'srv_remote') }) + + await waitFor(() => expect(getSessionPermissionMode).toHaveBeenCalledWith('ses_1', 'srv_remote')) + + expect(queryClient.getQueryCache().find({ queryKey: ['session-permission-mode', 'ses_1', 'srv_remote'] })).toBeDefined() + }) + + it('writes the mode through the current server', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + vi.mocked(setSessionPermissionMode).mockResolvedValue(state) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useSetSessionPermissionMode('ses_1'), { + wrapper: createWrapper(queryClient, 'srv_remote'), + }) + + await act(async () => { + await result.current.mutateAsync({ directory: '/repo', mode: 'auto' }) + }) + + expect(setSessionPermissionMode).toHaveBeenCalledWith( + 'ses_1', + { directory: '/repo', mode: 'auto' }, + 'srv_remote', + ) + expect( + queryClient.getQueryData(['session-permission-mode', 'ses_1', 'srv_remote']), + ).toEqual(state) + }) +}) diff --git a/frontend/src/hooks/useSessionPermissionMode.ts b/frontend/src/hooks/useSessionPermissionMode.ts index 6f49862a9..e85eda916 100644 --- a/frontend/src/hooks/useSessionPermissionMode.ts +++ b/frontend/src/hooks/useSessionPermissionMode.ts @@ -1,16 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getSessionPermissionMode, setSessionPermissionMode } from '@/api/sessionPermissionModes' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { serverScopedKey } from '@/lib/serverScope' import { showToast } from '@/lib/toast' import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' -function sessionPermissionModeQueryKey(sessionId: string) { - return ['session-permission-mode', sessionId] as const +function sessionPermissionModeQueryKey(sessionId: string, serverId: string) { + return serverScopedKey(['session-permission-mode', sessionId], serverId) } export function useSessionPermissionMode(sessionId: string, options: { enabled?: boolean } = {}) { + const serverId = useOpenCodeServerId() return useQuery({ - queryKey: sessionPermissionModeQueryKey(sessionId), - queryFn: () => getSessionPermissionMode(sessionId), + queryKey: sessionPermissionModeQueryKey(sessionId, serverId), + queryFn: () => getSessionPermissionMode(sessionId, serverId), enabled: options.enabled ?? true, staleTime: 30000, }) @@ -18,10 +21,12 @@ export function useSessionPermissionMode(sessionId: string, options: { enabled?: export function useSetSessionPermissionMode(sessionId: string) { const queryClient = useQueryClient() + const serverId = useOpenCodeServerId() return useMutation({ - mutationFn: (input: SetSessionPermissionModeRequest) => setSessionPermissionMode(sessionId, input), + mutationFn: (input: SetSessionPermissionModeRequest) => + setSessionPermissionMode(sessionId, input, serverId), onSuccess: (state: SessionPermissionModeState) => { - queryClient.setQueryData(sessionPermissionModeQueryKey(sessionId), state) + queryClient.setQueryData(sessionPermissionModeQueryKey(sessionId, serverId), state) }, onError: () => { showToast.error('Failed to update permission mode') diff --git a/frontend/src/hooks/useSessionSearch.ts b/frontend/src/hooks/useSessionSearch.ts index 07b222619..f95d495b6 100644 --- a/frontend/src/hooks/useSessionSearch.ts +++ b/frontend/src/hooks/useSessionSearch.ts @@ -10,6 +10,10 @@ export interface UseSessionSearchOptions { limit?: number allDirectories?: boolean project?: { id: string; subpath: string } + /** Searches a linked server instead of the route's server. */ + serverId?: string + /** Seeds the search box, for callers that mount the hook after a query is already entered. */ + initialQuery?: string } /** @@ -20,7 +24,8 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc const limit = options.limit ?? DEFAULT_LIMIT const allDirectories = options.allDirectories ?? false const project = options.project - const [query, setQuery] = useState('') + const serverId = options.serverId + const [query, setQuery] = useState(options.initialQuery ?? '') const trimmedQuery = query.trim() const debouncedQuery = useDebouncedValue(trimmedQuery, 150) const search = trimmedQuery ? debouncedQuery : '' @@ -39,6 +44,7 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc keepPreviousResults: true, allDirectories, project, + serverId, }) const directorySet = useMemo(() => new Set(directories), [directories]) diff --git a/frontend/src/hooks/useSettingsDialog.test.tsx b/frontend/src/hooks/useSettingsDialog.test.tsx index 60644de28..ca81008f9 100644 --- a/frontend/src/hooks/useSettingsDialog.test.tsx +++ b/frontend/src/hooks/useSettingsDialog.test.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { act, render, screen } from '@testing-library/react' -import { MemoryRouter, useNavigate } from 'react-router-dom' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { describe, it, expect } from 'vitest' import { useSettingsDialog } from './useSettingsDialog' import { renderHookWithRouter } from '@/test/test-utils' @@ -164,6 +164,60 @@ describe('useSettingsDialog', () => { expect(screen.getByTestId('dialog-state').textContent).toBe('closed') }) + it('openTab pushes a settings entry, selects the tab, and drops the mobile sheet', () => { + function Harness() { + const { isOpen, activeTab, openTab } = useSettingsDialog() + const navigate = useNavigate() + const location = useLocation() + const [step, setStep] = useState<'start' | 'opened' | 'back'>('start') + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + if (step === 'opened') { + handled.current = true + openTab('servers') + } else if (step === 'back') { + handled.current = true + navigate(-1) + } + }, [step, openTab, navigate]) + + return ( +
+ {isOpen ? 'open' : 'closed'} + {activeTab} + {location.search} + + +
+ ) + } + + render( + + + , + ) + + expect(screen.getByTestId('dialog-state').textContent).toBe('closed') + + act(() => { screen.getByText('open').click() }) + + expect(screen.getByTestId('search').textContent).toBe('?settings=open&settingsTab=servers') + expect(screen.getByTestId('dialog-state').textContent).toBe('open') + expect(screen.getByTestId('active-tab').textContent).toBe('servers') + + act(() => { screen.getByText('back').click() }) + + expect(screen.getByTestId('search').textContent).toBe('?mobileTab=repos') + expect(screen.getByTestId('dialog-state').textContent).toBe('closed') + }) + it('setActiveTab replaces so navigate(-1) does not step through old tab', () => { function Harness() { const { isOpen, activeTab, open, setActiveTab } = useSettingsDialog() diff --git a/frontend/src/hooks/useSettingsDialog.ts b/frontend/src/hooks/useSettingsDialog.ts index f6f4f8c4e..ea1e0b60c 100644 --- a/frontend/src/hooks/useSettingsDialog.ts +++ b/frontend/src/hooks/useSettingsDialog.ts @@ -12,6 +12,11 @@ export function isSettingsContentTab(value: string | null): value is SettingsCon interface UseSettingsDialogReturn { isOpen: boolean open: () => void + /** + * Opens the settings dialog on the given tab by pushing a new history entry, so Back closes + * settings instead of leaving the page, and clears the mobile sheet selection behind the dialog. + */ + openTab: (tab: SettingsContentTab) => void close: () => void toggle: () => void activeTab: SettingsContentTab @@ -30,14 +35,18 @@ export function useSettingsDialog(): UseSettingsDialogReturn { const activeTabRef = useRef(activeTab) activeTabRef.current = activeTab - const open = useCallback(() => { + const openTab = useCallback((tab: SettingsContentTab) => { updateParams((p) => { p.set('settings', 'open') - p.set('settingsTab', activeTabRef.current) + p.set('settingsTab', tab) p.delete('mobileTab') }, 'push') }, [updateParams]) + const open = useCallback(() => { + openTab(activeTabRef.current) + }, [openTab]) + const close = useCallback(() => { updateParams((p) => { p.delete('settings') @@ -64,6 +73,7 @@ export function useSettingsDialog(): UseSettingsDialogReturn { return { isOpen, open, + openTab, close, toggle, activeTab, diff --git a/frontend/src/hooks/useSidebarServerSelection.test.tsx b/frontend/src/hooks/useSidebarServerSelection.test.tsx new file mode 100644 index 000000000..7d0ce93ee --- /dev/null +++ b/frontend/src/hooks/useSidebarServerSelection.test.tsx @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useSidebarServerRouteSync, useSidebarServerSelection } from './useSidebarServerSelection' +import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/sidebarServerStore' + +const mocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const office = { ...kitchen, id: 'srv_office', name: 'Office' } + +function RouteSync() { + useSidebarServerRouteSync() + return null +} + +function SelectionHarness({ syncRoute = true }: { syncRoute?: boolean }) { + const [serverId, selectServer] = useSidebarServerSelection() + const navigate = useNavigate() + + return ( + <> + {syncRoute ? : null} +
{serverId}
+ + + + + ) +} + +function RouteOnlyHarness() { + useSidebarServerRouteSync() + const navigate = useNavigate() + const { pathname } = useLocation() + + return ( + <> +
{pathname}
+ + + + ) +} + +function renderHarness(initialEntry = '/', options?: { syncRoute?: boolean }) { + return render( + + + , + ) +} + +function resetSelection() { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, office], + isLoading: false, + isSuccess: true, + }) +} + +describe('useSidebarServerSelection', () => { + beforeEach(resetSelection) + + it('defaults to the local server', () => { + renderHarness('/', { syncRoute: false }) + + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID) + }) + + it('persists the selection', () => { + renderHarness('/', { syncRoute: false }) + + fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( + 'srv_kitchen', + ) + }) + + it('does not follow the route without the route sync', () => { + renderHarness('/servers/srv_kitchen', { syncRoute: false }) + + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID) + }) +}) + +describe('useSidebarServerRouteSync', () => { + beforeEach(resetSelection) + + it('selects the server named by a linked server route', async () => { + renderHarness('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha') + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + }) + + it('selects local on a local repo route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderHarness('/repos/1') + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('keeps a persisted linked selection on the home route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderHarness('/') + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + }) + + it('keeps a manual selection while the local route stays unchanged', async () => { + renderHarness('/repos/1') + + fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + + expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen') + }) + + it('follows a navigation to another linked server', async () => { + renderHarness('/servers/srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: 'Go to office' })) + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_office')) + }) + + it('follows a navigation back to a local route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + renderHarness('/servers/srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: 'Go to repo' })) + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('falls back to local when the selected server is disabled', async () => { + useSidebarServerStore.setState({ serverId: 'srv_office' }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, { ...office, enabled: false }], + isLoading: false, + isSuccess: true, + }) + + renderHarness() + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('keeps the persisted server when the server list fails to load', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [], + isLoading: false, + isSuccess: false, + error: new Error('offline'), + }) + + renderHarness() + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + expect(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY)).toBeNull() + }) + + it('falls back to local when the selected server no longer exists', async () => { + useSidebarServerStore.setState({ serverId: 'srv_gone' }) + + renderHarness() + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( + LOCAL_OPENCODE_SERVER_ID, + ) + }) + + it('follows a linked server route with no sidebar consumer mounted', async () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open remote project' })) + + await waitFor(() => expect(useSidebarServerStore.getState().serverId).toBe('srv_kitchen')) + + fireEvent.click(screen.getByRole('button', { name: 'Go home' })) + + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) + expect(useSidebarServerStore.getState().serverId).toBe('srv_kitchen') + }) +}) diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts new file mode 100644 index 000000000..e0e18e8ac --- /dev/null +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react' +import { useLocation, useMatch } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' + +function isLocalRoute(pathname: string): boolean { + return parseRepoRoute(pathname).repoId !== null || isAssistantPath(pathname) +} + +/** + * Keeps the sidebar server selection in step with the location and the linked server list: a linked + * server route selects that server, a local repo or assistant route selects local, and a selected + * server that is missing or disabled in a successfully loaded list falls back to local. Mounted once + * by the app shell so the selection follows the route even when no sidebar consumer is rendered. + */ +export function useSidebarServerRouteSync(): void { + const { pathname } = useLocation() + const routeServerId = useMatch('/servers/:serverId/*')?.params.serverId + const { servers, isSuccess } = useOpenCodeServers() + const serverId = useSidebarServerStore((state) => state.serverId) + const selectServer = useSidebarServerStore((state) => state.selectServer) + + useEffect(() => { + const current = useSidebarServerStore.getState().serverId + if (routeServerId) { + if (current !== routeServerId) selectServer(routeServerId) + return + } + if (isLocalRoute(pathname) && current !== LOCAL_OPENCODE_SERVER_ID) { + selectServer(LOCAL_OPENCODE_SERVER_ID) + } + }, [pathname, routeServerId, selectServer]) + + useEffect(() => { + if (!isSuccess || serverId === LOCAL_OPENCODE_SERVER_ID) return + const server = servers.find((candidate) => candidate.id === serverId) + if (!server || !server.enabled) { + selectServer(LOCAL_OPENCODE_SERVER_ID) + } + }, [isSuccess, servers, serverId, selectServer]) +} + +/** + * The linked server selected in the sidebar, defaulting to the local server. Selecting a server + * never navigates; `useSidebarServerRouteSync` keeps the selection aligned with the route. + */ +export function useSidebarServerSelection(): [string, (serverId: string) => void] { + const serverId = useSidebarServerStore((state) => state.serverId) + const selectServer = useSidebarServerStore((state) => state.selectServer) + + return [serverId, selectServer] +} diff --git a/frontend/src/hooks/useTerminalSocket.test.tsx b/frontend/src/hooks/useTerminalSocket.test.tsx index 3e09c7474..b6f963c83 100644 --- a/frontend/src/hooks/useTerminalSocket.test.tsx +++ b/frontend/src/hooks/useTerminalSocket.test.tsx @@ -57,7 +57,7 @@ function metaFrame(cursor: number): ArrayBuffer { function renderSocket(overrides: Partial[0]> = {}) { return renderHook(() => useTerminalSocket({ - repoId: 1, + target: { kind: 'repo', repoId: 1 }, directory: '/work/repo', ptyID: 'pty-1', onOutput: vi.fn(), @@ -87,6 +87,13 @@ describe('useTerminalSocket', () => { expect(url.searchParams.get('directory')).toBe('/work/repo') }) + it('builds the connection url for a linked server target', () => { + renderSocket({ target: { kind: 'server', serverId: 'srv_kitchen' } }) + const url = new URL(FakeWebSocket.instances[0].url) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty-1/connect') + expect(url.searchParams.get('directory')).toBe('/work/repo') + }) + it('accumulates the cursor and sends it when reconnecting after 1006', () => { renderSocket() const first = FakeWebSocket.instances[0] diff --git a/frontend/src/hooks/useTerminalSocket.ts b/frontend/src/hooks/useTerminalSocket.ts index 5cf39a8dd..34735a2be 100644 --- a/frontend/src/hooks/useTerminalSocket.ts +++ b/frontend/src/hooks/useTerminalSocket.ts @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { terminalConnectPath, type TerminalTarget } from '@/lib/terminal-target' import { TerminalFrameDecoder } from '@/lib/terminal-protocol' import { toWebSocketUrl } from '@/lib/websocket-url' export type TerminalSocketStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' export interface UseTerminalSocketOptions { - repoId: number + target: TerminalTarget directory: string | undefined ptyID: string onOutput: (text: string) => void @@ -17,8 +18,7 @@ const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 5000, 5000] const TERMINAL_CLOSE_CODES = new Set([1000, 4404]) function buildSocketUrl( - repoId: number, - ptyID: string, + connectPath: string, directory: string | undefined, cursor: number | undefined, ): string { @@ -26,13 +26,11 @@ function buildSocketUrl( if (directory !== undefined) params.set('directory', directory) if (cursor !== undefined) params.set('cursor', String(cursor)) const query = params.toString() - return toWebSocketUrl( - `/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}/connect${query ? `?${query}` : ''}`, - ) + return toWebSocketUrl(`${connectPath}${query ? `?${query}` : ''}`) } export function useTerminalSocket({ - repoId, + target, directory, ptyID, onOutput, @@ -46,6 +44,8 @@ export function useTerminalSocket({ onOutputRef.current = onOutput onExitRef.current = onExit + const connectPath = terminalConnectPath(target, ptyID) + const [status, setStatus] = useState(enabled ? 'connecting' : 'closed') const send = useCallback((data: string) => { @@ -71,7 +71,7 @@ export function useTerminalSocket({ if (disposed) return setStatus(attempt === 0 ? 'connecting' : 'reconnecting') - const socket = new WebSocket(buildSocketUrl(repoId, ptyID, directory, cursorRef.current)) + const socket = new WebSocket(buildSocketUrl(connectPath, directory, cursorRef.current)) socket.binaryType = 'arraybuffer' socketRef.current = socket @@ -129,7 +129,7 @@ export function useTerminalSocket({ socket.close() } } - }, [repoId, directory, ptyID, enabled]) + }, [connectPath, directory, enabled]) return { send, status } } diff --git a/frontend/src/hooks/useVariants.test.tsx b/frontend/src/hooks/useVariants.test.tsx index d1ef27a9f..290608c9b 100644 --- a/frontend/src/hooks/useVariants.test.tsx +++ b/frontend/src/hooks/useVariants.test.tsx @@ -334,7 +334,7 @@ describe('useVariants', () => { ), }) - it('keeps a remote session variant in the session pick without persisting it', async () => { + it('persists a remote session variant in the session pick and via saveOpenCodeModelVariant', async () => { selectAgentModel() const { result } = renderRemoteVariants(createQueryClient(), { id: 's1', @@ -350,11 +350,15 @@ describe('useVariants', () => { expect(useModelStore.getState().sessionPicks.s1[AGENT_ID].variant).toBe('high') await waitFor(() => expect(result.current.currentVariant).toBe('high')) - expect(mockGetOpenCodeModelState).not.toHaveBeenCalled() - expect(mockSaveOpenCodeModelVariant).not.toHaveBeenCalled() + await waitFor(() => { + expect(mockSaveOpenCodeModelVariant).toHaveBeenCalledWith( + { providerID: PROVIDER_ID, modelID: MODEL_ID }, + 'high', + ) + }) }) - it('does not persist a remote variant outside a session', async () => { + it('persists a remote variant outside a session and drives the selection from the preference', async () => { selectAgentModel() const { result } = renderRemoteVariants(createQueryClient()) @@ -364,9 +368,14 @@ describe('useVariants', () => { result.current.setVariant('high') }) - expect(mockSaveOpenCodeModelVariant).not.toHaveBeenCalled() + await waitFor(() => expect(result.current.currentVariant).toBe('high')) expect(useModelStore.getState().newSessionPicks).toEqual({}) - expect(result.current.currentVariant).toBeUndefined() + await waitFor(() => { + expect(mockSaveOpenCodeModelVariant).toHaveBeenCalledWith( + { providerID: PROVIDER_ID, modelID: MODEL_ID }, + 'high', + ) + }) }) }) }) diff --git a/frontend/src/hooks/useVariants.ts b/frontend/src/hooks/useVariants.ts index eb4d7ad0a..be58d1447 100644 --- a/frontend/src/hooks/useVariants.ts +++ b/frontend/src/hooks/useVariants.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from 'react' import { useModelSelection, useModelStateMutation, type ModelSelectionSession } from './useModelSelection' import { useModelStore, type ModelSelection } from '@/stores/modelStore' -import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { saveOpenCodeModelVariant } from '@/api/providers' import { cycleModelVariant, @@ -28,8 +27,6 @@ export function useVariants( session?: ModelSelectionSession, ): UseVariantsResult { const { model, selection, info } = useModelSelection(directory, session) - const serverId = useOpenCodeServerId() - const isLocalServer = isLocalOpenCodeServer(serverId) const activeAgent = useModelStore((state) => state.activeAgent) const setSessionPick = useModelStore((state) => state.setSessionPick) @@ -52,9 +49,9 @@ export function useVariants( if (session?.id && activeAgent?.id) { setSessionPick(session.id, activeAgent.id, { ...model, variant: normalizeModelVariant(variant) }) } - if (isLocalServer) saveVariant.mutate({ model, value: variant }) + saveVariant.mutate({ model, value: variant }) }, - [activeAgent?.id, isLocalServer, model, saveVariant, session?.id, setSessionPick], + [activeAgent?.id, model, saveVariant, session?.id, setSessionPick], ) const cycleVariant = useCallback( diff --git a/frontend/src/lib/activeWork.test.ts b/frontend/src/lib/activeWork.test.ts index 179b692e2..c3ca14d4f 100644 --- a/frontend/src/lib/activeWork.test.ts +++ b/frontend/src/lib/activeWork.test.ts @@ -6,6 +6,7 @@ import type { RepoSibling, RepoWorktreeSource } from '@opencode-manager/shared/u import { buildActiveWork, buildRepoActivityIndex, + buildServerActivityIndex, buildWorktreeActivity, collectActiveRoots, formatActivityEmptyMessage, @@ -15,6 +16,7 @@ import { repoActivityFromIndex, resolveSessionBranch, rootSessionID, + serverActivityFromIndex, type ActiveWorktree, } from './activeWork' @@ -268,6 +270,127 @@ describe('buildRepoActivityIndex and repoActivityFromIndex', () => { }) }) +describe('buildServerActivityIndex and serverActivityFromIndex', () => { + const noShells = new Map() + + const stateFor = ( + serverId: string, + input: Parameters[0], + ): 'needs-input' | 'running' | null => + serverActivityFromIndex(serverId, buildServerActivityIndex(input)) + + it('ignores local sessions', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', '/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + }) + + expect(state).toBeNull() + }) + + it('marks the server named by a server-keyed directory', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('marks the server named by the session-server index', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', '/home/me/app']]), + sessionServers: new Map([['session', 'srv_kitchen']]), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('rolls a remote needs-input child up to its root on that server', () => { + const input: Parameters[0] = { + statuses: statuses({}), + parents: new Map([['child', 'root'], ['root', null]]), + runningShells: noShells, + directories: new Map([['root', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: ['child'], + } + + expect(stateFor('srv_kitchen', input)).toBe('needs-input') + expect(stateFor('srv_office', input)).toBeNull() + }) + + it('keeps servers apart when the same directory is active on both', () => { + const runningInput: Parameters[0] = { + statuses: statuses({ kitchen: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['kitchen', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + } + + expect(stateFor('srv_kitchen', runningInput)).toBe('running') + expect(stateFor('srv_office', runningInput)).toBeNull() + }) + + it('prefers needs-input over running', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ busy: { type: 'busy' }, waiting: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([ + ['busy', 'srv_kitchen|/home/me/app'], + ['waiting', 'srv_kitchen|/home/me/other'], + ]), + sessionServers: new Map(), + pendingInputSessionIDs: ['waiting'], + }) + + expect(state).toBe('needs-input') + }) + + it('marks a running shell on its server', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({}), + parents: new Map(), + runningShells: new Map([ + ['shell-1', { sessionID: 'orphan', directory: 'srv_kitchen|/home/me/app' }], + ]), + directories: new Map(), + sessionServers: new Map([['orphan', 'srv_kitchen']]), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('reuses the index for the same snapshot references', () => { + const input: Parameters[0] = { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + } + + expect(buildServerActivityIndex(input)).toBe(buildServerActivityIndex(input)) + }) +}) + describe('repoActivityFromIndex with worktrees', () => { const noShells = new Map() const main = createRepo({ id: 1, fullPath: '/repos/a' }) @@ -557,7 +680,7 @@ describe('buildActiveWork', () => { expect(groups.recent).toEqual([]) }) - it('never emits a recent row for a remote session', () => { + it('emits a recent remote root with its server label and server session path', () => { const sessions = [createSession('remote', NOW - 1000, '/home/me/app')] const groups = buildActiveWork({ @@ -569,9 +692,41 @@ describe('buildActiveWork', () => { roots: emptyRoots, outcomes: new Map(), sessionServers: new Map([['remote', 'srv_kitchen']]), + serverNames: new Map([['srv_kitchen', 'Kitchen']]), }) - expect(groups).toEqual({ needsInput: [], running: [], recent: [] }) + expect(groups.recent).toHaveLength(1) + expect(groups.recent[0].repo).toBeNull() + expect(groups.recent[0].serverId).toBe('srv_kitchen') + expect(groups.recent[0].serverName).toBe('Kitchen') + expect(groups.recent[0].state).toBe('recent') + expect(groups.recent[0].path).toBe('/servers/srv_kitchen/sessions/remote') + }) + + it('rolls a remote child up to its remote root and keeps only the root row', () => { + const parents = new Map([ + ['remote-root', null], + ['remote-child', 'remote-root'], + ]) + const sessions = [ + createSession('remote-root', NOW - 4000, '/home/me/app'), + createSession('remote-child', NOW - 1000, '/home/me/app', 'remote-root'), + ] + + const groups = buildActiveWork({ + now: NOW, + windowMs: WINDOW_MS, + repos: [repoA], + sessions, + parents, + roots: emptyRoots, + outcomes: new Map(), + sessionServers: new Map([['remote-root', 'srv_kitchen']]), + serverNames: new Map([['srv_kitchen', 'Kitchen']]), + }) + + expect(groups.recent.map((item) => item.session.id)).toEqual(['remote-root']) + expect(groups.recent[0].updatedAt).toBe(NOW - 1000) }) }) diff --git a/frontend/src/lib/activeWork.ts b/frontend/src/lib/activeWork.ts index fce74ea19..92b7be70f 100644 --- a/frontend/src/lib/activeWork.ts +++ b/frontend/src/lib/activeWork.ts @@ -115,7 +115,7 @@ export function collectActiveRoots(input: { return { needsInput, running } } -interface RepoActivityIndexInput { +interface ActivityIndexInput { statuses: ReadonlyMap parents: ReadonlyMap runningShells: ReadonlyMap @@ -123,67 +123,182 @@ interface RepoActivityIndexInput { pendingInputSessionIDs: Iterable } -let cachedRepoActivityIndex: (RepoActivityIndexInput & { - index: Map -}) | null = null +interface ServerActivityIndexInput extends ActivityIndexInput { + sessionServers: ReadonlyMap +} -function computeRepoActivityIndex(input: RepoActivityIndexInput): Map { - const { statuses, parents, runningShells, directories, pendingInputSessionIDs } = input - const index = new Map() +type ActivityIndex = Map + +/** Caches one built value per input snapshot, matching the input fields the value depends on. */ +function cacheByInput( + compute: (input: I) => V, + sameInput: (left: I, right: I) => boolean, +): (input: I) => V { + let cached: { input: I; value: V } | null = null + + return (input) => { + if (cached && sameInput(cached.input, input)) return cached.value + const value = compute(input) + cached = { input, value } + return value + } +} - const markNeedsInput = (directory?: string | null): void => { - const normalized = normalizeDirectory(localDirectoryOf(directory)) - if (normalized) index.set(normalized, 'needs-input') +function sameActivityInput(left: I, right: I): boolean { + return ( + left.statuses === right.statuses && + left.parents === right.parents && + left.runningShells === right.runningShells && + left.directories === right.directories && + left.pendingInputSessionIDs === right.pendingInputSessionIDs + ) +} + +function sameServerActivityInput( + left: ServerActivityIndexInput, + right: ServerActivityIndexInput, +): boolean { + return sameActivityInput(left, right) && left.sessionServers === right.sessionServers +} + +/** + * Rolls every active or pending session onto the index key its directory resolves to. Pending + * input marks its own and its root's key as needs-input; non-idle sessions and running shells mark + * running unless needs-input already claimed the key. + */ +function computeActivityIndex( + input: ActivityIndexInput, + keyOf: (sessionID: string, directory: string | null | undefined) => string | null, +): ActivityIndex { + const { statuses, parents, runningShells, directories, pendingInputSessionIDs } = input + const index: ActivityIndex = new Map() + + const mark = ( + state: 'needs-input' | 'running', + sessionID: string, + directory?: string | null, + ): void => { + const key = keyOf(sessionID, directory) + if (!key) return + if (state === 'running' && index.get(key) === 'needs-input') return + index.set(key, state) } - const markRunning = (directory?: string | null): void => { - const normalized = normalizeDirectory(localDirectoryOf(directory)) - if (!normalized || index.get(normalized) === 'needs-input') return - index.set(normalized, 'running') + const markSession = (state: 'needs-input' | 'running', sessionID: string): void => { + mark(state, sessionID, directories.get(sessionID)) + mark(state, sessionID, directories.get(rootSessionID(sessionID, parents))) } for (const sessionID of pendingInputSessionIDs) { - markNeedsInput(directories.get(sessionID)) - markNeedsInput(directories.get(rootSessionID(sessionID, parents))) + markSession('needs-input', sessionID) } for (const [sessionID, status] of statuses) { if (status.type === 'idle') continue - markRunning(directories.get(sessionID)) - markRunning(directories.get(rootSessionID(sessionID, parents))) + markSession('running', sessionID) } for (const shell of runningShells.values()) { - markRunning(shell.directory) - markRunning(directories.get(shell.sessionID)) - markRunning(directories.get(rootSessionID(shell.sessionID, parents))) + mark('running', shell.sessionID, shell.directory) + markSession('running', shell.sessionID) } return index } +function localActivityKey(_sessionID: string, directory?: string | null): string | null { + return normalizeDirectory(localDirectoryOf(directory)) +} + +const repoActivityIndex = cacheByInput( + (input) => computeActivityIndex(input, localActivityKey), + sameActivityInput, +) + /** * Indexes active sessions by normalized directory for repo badges. Pending input marks its own and * its root's directory as needs-input; non-idle sessions and running shells mark running unless * needs-input already claimed the directory. Shared across rows per store snapshot by reference. */ -export function buildRepoActivityIndex( - input: RepoActivityIndexInput, -): Map { - if ( - cachedRepoActivityIndex && - cachedRepoActivityIndex.statuses === input.statuses && - cachedRepoActivityIndex.parents === input.parents && - cachedRepoActivityIndex.runningShells === input.runningShells && - cachedRepoActivityIndex.directories === input.directories && - cachedRepoActivityIndex.pendingInputSessionIDs === input.pendingInputSessionIDs - ) { - return cachedRepoActivityIndex.index +export function buildRepoActivityIndex(input: ActivityIndexInput): ActivityIndex { + return repoActivityIndex(input) +} + +/** The server and plain directory named by a session-status directory value. */ +export interface SessionDirectoryScope { + serverId: string + directory: string | null +} + +/** + * Resolves a session-status directory value to its owning server and plain directory. A + * server-keyed value names its owner, otherwise the session-server entry does; everything else + * belongs to the local server. + */ +export function sessionScopeOfDirectory( + directory: string | null | undefined, + sessionServerId?: string, +): SessionDirectoryScope { + const parsed = directory ? parseServerDirectoryKey(directory) : null + const serverId = + parsed && parsed.serverId !== LOCAL_OPENCODE_SERVER_ID + ? parsed.serverId + : sessionServerId ?? LOCAL_OPENCODE_SERVER_ID + return { serverId, directory: parsed?.directory ?? null } +} + +function serverIndexKey(serverId: string, directory?: string | null): string { + return `${serverId}|${normalizeDirectory(directory) ?? ''}` +} + +const serverActivityIndex = cacheByInput( + (input) => + computeActivityIndex(input, (sessionID, directory) => { + const scope = sessionScopeOfDirectory(directory, input.sessionServers.get(sessionID)) + if (scope.serverId === LOCAL_OPENCODE_SERVER_ID) return null + return serverIndexKey(scope.serverId, scope.directory) + }), + sameServerActivityInput, +) + +/** + * Indexes active sessions by owning linked server and directory for server badges. A session + * belongs to the server named by its server-keyed directory, otherwise to its session-server entry; + * local sessions are never indexed. Keys are `|`, with an empty directory when + * the session's directory is unknown. + */ +export function buildServerActivityIndex(input: ServerActivityIndexInput): ActivityIndex { + return serverActivityIndex(input) +} + +/** + * A linked server's activity from a prebuilt server index. Needs-input outranks running. + */ +export function serverActivityFromIndex( + serverId: string, + index: ReadonlyMap, +): 'needs-input' | 'running' | null { + const prefix = `${serverId}|` + let running = false + + for (const [key, state] of index) { + if (!key.startsWith(prefix)) continue + if (state === 'needs-input') return 'needs-input' + if (state === 'running') running = true } - const index = computeRepoActivityIndex(input) - cachedRepoActivityIndex = { ...input, index } - return index + return running ? 'running' : null +} + +/** + * A linked server project's activity from a prebuilt server index, keyed by server and directory. + */ +export function projectActivityFromIndex( + serverId: string, + directory: string, + index: ReadonlyMap, +): 'needs-input' | 'running' | null { + return index.get(serverIndexKey(serverId, directory)) ?? null } /** @@ -322,11 +437,11 @@ export function needsWorktreeLookup(input: { /** * Groups the given sessions into needs-input, running, and recent roots. A session appears in * exactly one group (needs-input over running over recent) and only when it resolves to a local - * repo or to an owning linked server. A session owned by a linked server is only emitted while it - * needs input or is running, so remote work never fills the recent window. A recent session in a - * schedule worktree is omitted so schedules only surface while they need attention. The input may - * mix roots and children; children never become items, but their latest update time rolls up into - * the root's `updatedAt` and decides the recent window and ordering. + * repo or to an owning linked server. A session owned by a linked server keeps its own row in any + * state, opening on that server instead of a local repo. A recent session in a schedule worktree is + * omitted so schedules only surface while they need attention. The input may mix roots and children; + * children never become items, but their latest update time rolls up into the root's `updatedAt` and + * decides the recent window and ordering. */ export function buildActiveWork(input: { now: number @@ -380,7 +495,6 @@ export function buildActiveWork(input: { const serverId = sessionServers?.get(session.id) ?? null if (serverId) { - if (state === 'recent') continue seen.add(session.id) const item: ActiveWorkItem = { key: getSessionKey(session), @@ -395,7 +509,8 @@ export function buildActiveWork(input: { path: getServerSessionPath(serverId, session.id), } if (state === 'needs-input') needsInput.push(item) - else running.push(item) + else if (state === 'running') running.push(item) + else recent.push(item) continue } diff --git a/frontend/src/lib/builtinCommands.ts b/frontend/src/lib/builtinCommands.ts index 923172fb1..4c25db67d 100644 --- a/frontend/src/lib/builtinCommands.ts +++ b/frontend/src/lib/builtinCommands.ts @@ -69,13 +69,13 @@ export function isBuiltinCommand(command: CommandInfo): command is BuiltinComman } const MANAGER_ONLY_COMMAND_ACTIONS: ReadonlySet = new Set([ - 'showSessions', 'showWalkthrough', ]) /** * Built-in composer commands for a server scope. Commands backed by Manager-only features are omitted for - * linked OpenCode servers; MCP and skills stay because their views work against the linked server. + * linked OpenCode servers; MCP, skills, and the session picker stay because their views work against the + * linked server. */ export function getBuiltinCommands(isLocalServer: boolean): readonly BuiltinCommand[] { if (isLocalServer) return BUILTIN_COMMANDS diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index 118351cff..d30c2a35b 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerSessionRoute } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; describe('getAssistantPath', () => { it('returns /assistant', () => { @@ -97,7 +97,6 @@ describe('getSessionPath', () => { describe('linked server paths', () => { it('builds server and server session paths with encoded segments', () => { - expect(getServerPath('srv_kitchen')).toBe('/servers/srv_kitchen'); expect(getServerSessionPath('srv_kitchen', 'ses_1')).toBe('/servers/srv_kitchen/sessions/ses_1'); expect(getServerSessionPath('srv a/b', 'ses 1')).toBe('/servers/srv%20a%2Fb/sessions/ses%201'); }); @@ -141,6 +140,28 @@ describe('parseRepoRoute', () => { }); }); +describe('linked server project paths', () => { + it('builds a project path with encoded server id and directory', () => { + expect(getServerProjectPath('srv_kitchen', '/home/me/app')).toBe( + '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp' + ); + expect(getServerProjectPath('srv a/b', '/srv/my app')).toBe( + '/servers/srv%20a%2Fb/projects?directory=%2Fsrv%2Fmy%20app' + ); + }); + + it('parses the server id and decoded directory from a project route', () => { + expect( + parseServerProjectRoute('/servers/srv_kitchen/projects', '?directory=%2Fhome%2Fme%2Fapp') + ).toEqual({ serverId: 'srv_kitchen', directory: '/home/me/app' }); + }); + + it('returns null without a directory or for other routes', () => { + expect(parseServerProjectRoute('/servers/srv_kitchen/projects', '')).toBeNull(); + expect(parseServerProjectRoute('/servers/srv_kitchen', '?directory=%2Fhome%2Fme')).toBeNull(); + }); +}); + describe('parseServerSessionRoute', () => { it('parses a linked-server session route', () => { expect(parseServerSessionRoute('/servers/srv_kitchen/sessions/ses_1')).toEqual({ @@ -225,19 +246,26 @@ describe('getSwipeBackTarget', () => { }); describe('linked server routes', () => { - it('returns the server page for a remote session detail', () => { - expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/ses_1', '')).toBe('/servers/srv_kitchen'); + it('returns root for a remote session detail', () => { + expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/ses_1', '')).toBe('/'); }); it('returns root for a server page', () => { expect(getSwipeBackTarget('/servers/srv_kitchen', '')).toBe('/'); }); + it('returns root for a remote project page', () => { + expect( + getSwipeBackTarget('/servers/srv_kitchen/projects', '?directory=%2Fhome%2Fme%2Fapp') + ).toBe('/'); + }); + it('does not throw for a malformed server session path', () => { expect(() => getSwipeBackTarget('/servers/%E0%A4%A/sessions/ses_1', '')).not.toThrow(); expect(getSwipeBackTarget('/servers/%E0%A4%A/sessions/ses_1', '')).toBeNull(); expect(() => getSwipeBackTarget('/servers/srv_kitchen/sessions/%E0%A4%A', '')).not.toThrow(); expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/%E0%A4%A', '')).toBeNull(); + expect(() => getSwipeBackTarget('/servers/%E0%A4%A/projects', '?directory=%2Fa')).not.toThrow(); }); }); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 98eee7a99..e90bc0119 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,4 +1,4 @@ -import { ASSISTANT_REPO_ID, buildSessionPath } from '@opencode-manager/shared/utils'; +import { ASSISTANT_REPO_ID, buildServerSessionPath, buildSessionPath } from '@opencode-manager/shared/utils'; import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'; export function getAssistantPath(): string { @@ -28,12 +28,24 @@ export function getSessionPath(repoId: number | string, sessionId: string, optio return buildSessionPath(repoId, sessionId, options); } -export function getServerPath(serverId: string): string { - return `/servers/${encodeURIComponent(serverId)}`; +export function getServerSessionPath(serverId: string, sessionId: string): string { + return buildServerSessionPath(serverId, sessionId); } -export function getServerSessionPath(serverId: string, sessionId: string): string { - return `${getServerPath(serverId)}/sessions/${encodeURIComponent(sessionId)}`; +export function getServerProjectPath(serverId: string, directory: string): string { + return `/servers/${encodeURIComponent(serverId)}/projects?directory=${encodeURIComponent(directory)}`; +} + +export function parseServerProjectRoute( + pathname: string, + search: string, +): { serverId: string; directory: string } | null { + const match = /^\/servers\/([^/]+)\/projects$/.exec(pathname); + if (!match) return null; + const directory = new URLSearchParams(search).get('directory'); + const serverId = decodePathSegment(match[1]); + if (!directory || !serverId) return null; + return { serverId, directory }; } function decodePathSegment(segment: string): string | null { @@ -101,9 +113,14 @@ export function getReturnToPath(search: string, fallback: string): string { } export function getSwipeBackTarget(pathname: string, search = ''): string | null { + const serverProject = parseServerProjectRoute(pathname, search); + if (serverProject) { + return '/'; + } + const serverSession = parseServerSessionRoute(pathname); if (serverSession) { - return getServerPath(serverSession.serverId); + return '/'; } if (/^\/servers\/[^/]+$/.test(pathname)) { diff --git a/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts b/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts index 00ef9d324..490b8e48c 100644 --- a/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts +++ b/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts @@ -249,7 +249,23 @@ describe('OpenCodeEventStream', () => { expect(transport.posts).toContainEqual({ path: '/api/sse/visibility', - body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1' }, + body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1', activeServerId: 'local' }, + }) + }) + + it('reports the active server id for a remote server route', async () => { + const transport = new TestEventStreamTransport() + const stream = new OpenCodeEventStream({ transport }) + + const subscription = stream.subscribeGlobalMonitor({ directories: ['srv_a|/repo'], onEvent: vi.fn() }) + transport.openConnection() + transport.connected('client-1') + subscription.reportVisibility(true, 'session-1', 'srv_a') + await Promise.resolve() + + expect(transport.posts).toContainEqual({ + path: '/api/sse/visibility', + body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1', activeServerId: 'srv_a' }, }) }) diff --git a/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts b/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts index 946de4d0a..3b92b99b1 100644 --- a/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts +++ b/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts @@ -1,5 +1,5 @@ import { DEFAULTS } from '@opencode-manager/shared/config' -import type { SSEEventEnvelope } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, type SSEEventEnvelope } from '@opencode-manager/shared/schemas' import { createBrowserEventStreamTransport } from './browserTransport' import type { EventStreamHealthState, @@ -76,7 +76,8 @@ export class OpenCodeEventStream { return { updateDirectories: (directories) => this.updateSubscriberDirectories(id, directories), reconnect: () => this.reconnect(), - reportVisibility: (visible, activeSessionId) => this.reportVisibility(visible, activeSessionId), + reportVisibility: (visible, activeSessionId, activeServerId) => + this.reportVisibility(visible, activeSessionId, activeServerId), dispose: () => this.removeSubscriber(id), } } @@ -479,13 +480,14 @@ export class OpenCodeEventStream { void this.subscribeToRemoteDirectories(directories) } - private reportVisibility(visible: boolean, activeSessionId?: string): void { + private reportVisibility(visible: boolean, activeSessionId?: string, activeServerId?: string): void { if (!this.clientId || !this.connected) return void this.transport.post('/api/sse/visibility', { clientId: this.clientId, visible, activeSessionId: activeSessionId ?? null, + activeServerId: activeServerId ?? LOCAL_OPENCODE_SERVER_ID, }) } } diff --git a/frontend/src/lib/opencode-event-stream/types.ts b/frontend/src/lib/opencode-event-stream/types.ts index 34da208a1..80274823b 100644 --- a/frontend/src/lib/opencode-event-stream/types.ts +++ b/frontend/src/lib/opencode-event-stream/types.ts @@ -29,7 +29,7 @@ export interface EventStreamTransport { export interface EventStreamSubscription { dispose(): void reconnect(): void - reportVisibility(visible: boolean, activeSessionId?: string): void + reportVisibility(visible: boolean, activeSessionId?: string, activeServerId?: string): void } export interface GlobalMonitorSubscription extends EventStreamSubscription { diff --git a/frontend/src/lib/queryInvalidation.test.ts b/frontend/src/lib/queryInvalidation.test.ts index 672d500a2..2ef7c355a 100644 --- a/frontend/src/lib/queryInvalidation.test.ts +++ b/frontend/src/lib/queryInvalidation.test.ts @@ -1,6 +1,7 @@ import { QueryClient } from '@tanstack/react-query' import { afterEach, describe, expect, it, vi } from 'vitest' import { + activeWorkSessionsQueryKey, invalidateConfigCaches, invalidateProviderCaches, invalidateProviderCachesDebounced, @@ -206,6 +207,35 @@ describe('invalidateSessionListCachesDebounced', () => { expect(queryClient.getQueryState(sessionListKey('/a'))?.isInvalidated).toBe(true) expect(queryClient.getQueryState(sessionListKey('/b'))?.isInvalidated).toBe(true) }) + + it('keeps a remote event directory from invalidating the local list with the same path', () => { + vi.useFakeTimers() + const queryClient = new QueryClient() + const remoteListKey = ['opencode', 'sessions', '/repo', { allDirectories: true }, 'srv_a'] + queryClient.setQueryData(sessionListKey('/repo'), { pages: [], pageParams: [] }) + queryClient.setQueryData(remoteListKey, { pages: [], pageParams: [] }) + + invalidateSessionListCachesDebounced(queryClient, '/repo', 'srv_a') + vi.advanceTimersByTime(200) + + expect(queryClient.getQueryState(remoteListKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(sessionListKey('/repo'))?.isInvalidated).toBe(false) + }) + + it('invalidates the active-work list of the event server only', () => { + vi.useFakeTimers() + const queryClient = new QueryClient() + const localActiveWorkKey = activeWorkSessionsQueryKey('local') + const remoteActiveWorkKey = activeWorkSessionsQueryKey('srv_a') + queryClient.setQueryData(localActiveWorkKey, []) + queryClient.setQueryData(remoteActiveWorkKey, []) + + invalidateSessionListCachesDebounced(queryClient, '/repo', 'srv_a') + vi.advanceTimersByTime(200) + + expect(queryClient.getQueryState(remoteActiveWorkKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(localActiveWorkKey)?.isInvalidated).toBe(false) + }) }) describe('invalidateQueryKeysDebounced', () => { diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index b3bec0759..501666c5d 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -1,4 +1,5 @@ import type { QueryClient } from '@tanstack/react-query' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { GitStatusResponse } from '@/types/git' export function sessionTranscriptQueryKey(sessionID: string | null | undefined) { @@ -182,23 +183,49 @@ export function invalidateSessionCaches(queryClient: QueryClient) { }) } +const SESSION_LIST_QUERY_GROUPS = new Set(['sessions', 'active-work-sessions']) + +/** + * Query key of one server's active-work session list: flat, newest-first, across all directories. + * It is distinct from the paged session-list keys so the two data shapes never share a cache entry. + */ +export function activeWorkSessionsQueryKey(serverId: string): readonly unknown[] { + return ['opencode', 'active-work-sessions', serverId] +} + export function invalidateSessionListCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ predicate: (query) => - query.queryKey[0] === 'opencode' && query.queryKey[1] === 'sessions', + query.queryKey[0] === 'opencode' && SESSION_LIST_QUERY_GROUPS.has(String(query.queryKey[1])), }) } +/** + * The server a session-list query key belongs to: an active-work key carries it third, a paged + * session-list key appends it after its options, and an unscoped key is a local-server key. + */ +function sessionListQueryServerId(queryKey: readonly unknown[]): string { + if (queryKey[1] === 'active-work-sessions') return String(queryKey[2]) + return typeof queryKey[4] === 'string' ? queryKey[4] : LOCAL_OPENCODE_SERVER_ID +} + interface SessionListInvalidationState { timer: ReturnType | null directories: Set invalidateAll: boolean } -const sessionListInvalidationStates = new WeakMap() +const sessionListInvalidationStates = new WeakMap>() -function sessionListQueryMatchesDirectories(queryKey: readonly unknown[], directories: Set) { - if (queryKey[0] !== 'opencode' || queryKey[1] !== 'sessions') return false +function sessionListQueryMatchesDirectories( + queryKey: readonly unknown[], + directories: Set, + serverId: string, +) { + if (queryKey[0] !== 'opencode') return false + if (!SESSION_LIST_QUERY_GROUPS.has(String(queryKey[1]))) return false + if (sessionListQueryServerId(queryKey) !== serverId) return false + if (queryKey[1] === 'active-work-sessions') return true const directoryKey = queryKey[2] if (typeof directoryKey !== 'string') return false return directoryKey.split('|').some((directory) => directories.has(directory)) @@ -207,22 +234,30 @@ function sessionListQueryMatchesDirectories(queryKey: readonly unknown[], direct function flushSessionListInvalidation( queryClient: QueryClient, state: SessionListInvalidationState, + serverId: string, ) { if (state.invalidateAll) { - invalidateSessionListCaches(queryClient) + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'opencode' && + SESSION_LIST_QUERY_GROUPS.has(String(query.queryKey[1])) && + sessionListQueryServerId(query.queryKey) === serverId, + }) return } queryClient.invalidateQueries({ - predicate: (query) => sessionListQueryMatchesDirectories(query.queryKey, state.directories), + predicate: (query) => sessionListQueryMatchesDirectories(query.queryKey, state.directories, serverId), }) } export function invalidateSessionListCachesDebounced( queryClient: QueryClient, directory?: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, delayMs = 200, ) { - const state = sessionListInvalidationStates.get(queryClient) ?? { + const states = sessionListInvalidationStates.get(queryClient) ?? new Map() + const state = states.get(serverId) ?? { timer: null, directories: new Set(), invalidateAll: false, @@ -233,10 +268,11 @@ export function invalidateSessionListCachesDebounced( } else { state.invalidateAll = true } - sessionListInvalidationStates.set(queryClient, state) + states.set(serverId, state) + sessionListInvalidationStates.set(queryClient, states) state.timer = setTimeout(() => { - sessionListInvalidationStates.delete(queryClient) - flushSessionListInvalidation(queryClient, state) + states.delete(serverId) + flushSessionListInvalidation(queryClient, state, serverId) }, delayMs) } diff --git a/frontend/src/lib/terminal-target.ts b/frontend/src/lib/terminal-target.ts new file mode 100644 index 000000000..467ff8d93 --- /dev/null +++ b/frontend/src/lib/terminal-target.ts @@ -0,0 +1,25 @@ +export type TerminalTarget = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string } + +export function terminalTargetScope(target: TerminalTarget): number | string { + return target.kind === 'repo' ? target.repoId : target.serverId +} + +export function terminalTargetKey(target: TerminalTarget): string { + return target.kind === 'repo' ? `repo:${target.repoId}` : `server:${target.serverId}` +} + +export function terminalCollectionPath(target: TerminalTarget): string { + return target.kind === 'repo' + ? `/api/repos/${target.repoId}/terminals` + : `/api/opencode-servers/${encodeURIComponent(target.serverId)}/terminals` +} + +export function terminalItemPath(target: TerminalTarget, ptyID: string): string { + return `${terminalCollectionPath(target)}/${encodeURIComponent(ptyID)}` +} + +export function terminalConnectPath(target: TerminalTarget, ptyID: string): string { + return `${terminalItemPath(target, ptyID)}/connect` +} diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 722649bf7..a4cc229c0 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -103,7 +103,7 @@ export function AssistantRedirect() { /> )} setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName="Assistant" /> - setTerminalOpen(false)} /> + setTerminalOpen(false)} /> setPreviewOpen(false)} directory={assistantDirectory} /> diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 8c57bdef9..02f26a4e6 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -1,7 +1,16 @@ 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 { + useAllSchedules, + useAllScheduleRuns, + useCancelScheduleTargetRun, + useCreateRepoSchedule, + useDeleteScheduleTarget, + useRunScheduleTarget, + useUnreadScheduleRuns, + useUpdateScheduleTarget, + type ScheduleTargetRef, +} from '@/hooks/useSchedules' import { ScheduleJobDialog, PromptsTab, ScheduleJobsTable, ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable, ScheduleRepoSwitcher } from '@/components/schedules' import type { CreateScheduleJobRequest } from '@opencode-manager/shared/types' import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' @@ -20,12 +29,30 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue' import type { ScheduleJobWithRepo, ScheduleRunWithContext } from '@/api/schedules' import { Combobox } from '@/components/ui/combobox' -import { getRepoPath } from '@/lib/navigation' +import { getRepoPath, getServerProjectPath } from '@/lib/navigation' type StatusFilter = 'all' | 'enabled' | 'disabled' type ScheduleModeFilter = 'all' | 'cron' | 'interval' type SortOption = 'nextRun' | 'name' | 'repo' +function jobBucket(job: ScheduleJobWithRepo): string { + return job.repoId === null ? `server:${job.serverId ?? ''}:${job.repoPath}` : job.repoPath +} + +function targetForJob(job: ScheduleJobWithRepo): ScheduleTargetRef | null { + if (job.repoId !== null) return { kind: 'repo', repoId: job.repoId } + return job.serverId ? { kind: 'server', serverId: job.serverId } : null +} + +function targetForRun(run: ScheduleRunWithContext): ScheduleTargetRef | null { + if (run.repoId !== null) return { kind: 'repo', repoId: run.repoId } + return run.serverId ? { kind: 'server', serverId: run.serverId } : null +} + +function getLinkedServerJobPath(job: ScheduleJobWithRepo): string { + return job.serverId && job.directory ? getServerProjectPath(job.serverId, job.directory) : '/' +} + export function GlobalSchedules() { const navigate = useNavigate() const [selectedRepoId, setSelectedRepoId] = useState(undefined) @@ -46,7 +73,7 @@ export function GlobalSchedules() { const { scheduleTab, setScheduleTab, dialog, promptDialog, jobId, runId, templateId, openNewJob, openEditJob, openDeleteJob, openNewTemplate, openEditTemplate, openDeleteTemplate, openImportTemplate, closeDialog, closePromptDialog, selectRun } = useScheduleUrlState() - const cancelRunMutation = useCancelRepoScheduleRun() + const cancelRunMutation = useCancelScheduleTargetRun() const cancelRunPending = cancelRunMutation.isPending useEffect(() => { @@ -100,9 +127,9 @@ export function GlobalSchedules() { }, [nextUnreadRun, selectRun]) const createMutation = useCreateRepoSchedule() - const deleteMutation = useDeleteRepoSchedule() - const runMutation = useRunRepoSchedule() - const updateMutation = useUpdateRepoSchedule() + const deleteMutation = useDeleteScheduleTarget() + const runMutation = useRunScheduleTarget() + const updateMutation = useUpdateScheduleTarget() useEffect(() => { setRunOffset(0) @@ -152,14 +179,15 @@ export function GlobalSchedules() { }, [runsForDisplay, runSortOption]) const uniqueRepos = useMemo(() => { - const repoMap = new Map() + const repoMap = new Map() jobs.forEach((job) => { - repoMap.set(job.repoPath, { name: job.repoName, url: job.repoUrl }) + repoMap.set(jobBucket(job), { name: job.repoName, url: job.repoUrl, repoId: job.repoId }) }) - return Array.from(repoMap.entries()).map(([path, info]) => ({ - path, + return Array.from(repoMap.entries()).map(([bucket, info]) => ({ + path: bucket, name: info.name, url: info.url, + repoId: info.repoId, })) }, [jobs]) @@ -179,7 +207,7 @@ export function GlobalSchedules() { } if (repoFilter !== 'all') { - filtered = filtered.filter((job) => job.repoPath === repoFilter) + filtered = filtered.filter((job) => jobBucket(job) === repoFilter) } filtered = filtered.filter((job) => matchesScheduleJobSearch(job, searchQuery)) @@ -207,7 +235,7 @@ export function GlobalSchedules() { ...uniqueRepos.map((repo) => ({ value: repo.path, label: repo.name, - description: `${jobs.filter((j) => j.repoPath === repo.path).length} jobs`, + description: `${jobs.filter((j) => jobBucket(j) === repo.path).length} jobs`, })), ], [jobs, uniqueRepos]) @@ -251,38 +279,43 @@ export function GlobalSchedules() { const runRepoOptions = useMemo(() => [ { value: 'all', label: 'All Repos', description: '' }, - ...uniqueRepos.map((repo) => ({ - value: `${jobs.find((j) => j.repoPath === repo.path)?.repoId ?? 0}|${repo.path}`, + ...uniqueRepos.filter((repo) => repo.repoId !== null).map((repo) => ({ + value: `${repo.repoId}|${repo.path}`, label: repo.name, description: repo.path, })), - ], [uniqueRepos, jobs]) + ], [uniqueRepos]) const handleDelete = () => { - if (!deletingJob) { + const target = deletingJob ? targetForJob(deletingJob) : null + if (!deletingJob || !target) { return } deleteMutation.mutate( - { repoId: deletingJob.repoId, jobId: deletingJob.id }, + { target, jobId: deletingJob.id }, { onSuccess: () => closeDialog() } ) } - const handleCancelRun = (repoId: number, jobId: number, runId: number) => { - cancelRunMutation.mutate({ repoId, jobId, runId }) + const handleCancelRun = (target: ScheduleTargetRef, jobId: number, runId: number) => { + cancelRunMutation.mutate({ target, jobId, runId }) } const handleToggleEnabled = (job: ScheduleJobWithRepo) => { + const target = targetForJob(job) + if (!target) return updateMutation.mutate({ - repoId: job.repoId, + target, jobId: job.id, data: { enabled: !job.enabled }, }) } const handleRunNow = (job: ScheduleJobWithRepo) => { - runMutation.mutate({ repoId: job.repoId, jobId: job.id }) + const target = targetForJob(job) + if (!target) return + runMutation.mutate({ target, jobId: job.id }) } const handleEdit = (job: ScheduleJobWithRepo) => { @@ -303,10 +336,11 @@ export function GlobalSchedules() { } const handleUpdate = (data: CreateScheduleJobRequest) => { - if (!editingJob) return + const target = editingJob ? targetForJob(editingJob) : null + if (!editingJob || !target) return updateMutation.mutate( { - repoId: editingJob.repoId, + target, jobId: editingJob.id, data: toUpdateScheduleRequest(data), }, @@ -319,12 +353,20 @@ export function GlobalSchedules() { } const handleNavigateToRepo = (repoPath: string) => { - const repoId = jobs.find((j) => j.repoPath === repoPath)?.repoId - if (repoId === undefined) return - navigate(getRepoPath(repoId)) + const job = jobs.find((entry) => jobBucket(entry) === repoPath) + if (!job) return + if (job.repoId === null) { + if (job.serverId) navigate(getLinkedServerJobPath(job)) + return + } + navigate(getRepoPath(job.repoId)) } const handleOpenJobPage = (job: ScheduleJobWithRepo) => { + if (job.repoId === null) { + if (job.serverId) navigate(getLinkedServerJobPath(job)) + return + } navigate(`/repos/${job.repoId}/schedules`) } @@ -338,13 +380,15 @@ export function GlobalSchedules() { } const handleCancelJobRun = (job: ScheduleJobWithRepo) => { - if (!job.lastRun) return - handleCancelRun(job.repoId, job.id, job.lastRun.id) + const target = targetForJob(job) + if (!job.lastRun || !target) return + handleCancelRun(target, job.id, job.lastRun.id) } const handleCancelSelectedRun = () => { - if (!selectedRun) return - handleCancelRun(selectedRun.repoId, selectedRun.jobId, selectedRun.id) + const target = selectedRun ? targetForRun(selectedRun) : null + if (!selectedRun || !target) return + handleCancelRun(target, selectedRun.jobId, selectedRun.id) } const selectedRunIndex = runId !== null ? sortedRuns.findIndex((run) => run.id === runId) : -1 @@ -761,6 +805,9 @@ export function GlobalSchedules() { showRepoSelector repoId={selectedRepoId} onRepoChange={setSelectedRepoId} + serverTarget={editingJob && editingJob.repoId === null && editingJob.serverId && editingJob.directory + ? { serverId: editingJob.serverId, directory: editingJob.directory } + : undefined} /> () + const selectServer = useSidebarServerStore((state) => state.selectServer) + + useEffect(() => { + if (serverId) selectServer(serverId) + }, [serverId, selectServer]) + + return +} diff --git a/frontend/src/pages/RemoteProject.tsx b/frontend/src/pages/RemoteProject.tsx new file mode 100644 index 000000000..3d4563ca2 --- /dev/null +++ b/frontend/src/pages/RemoteProject.tsx @@ -0,0 +1,221 @@ +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { FolderGit2, GitBranch, Loader2, Plus, Server } from 'lucide-react' +import { getVcsInfo, listProjects } from '@/api/opencode' +import { useCreateSession } from '@/hooks/useOpenCode' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSSE } from '@/hooks/useSSE' +import { useMobile } from '@/hooks/useMobile' +import { useToolPanel } from '@/hooks/useToolPanel' +import { useDialogParam } from '@/hooks/useDialogParam' +import { useTerminalDialogParam, useTerminalDirectoryParam } from '@/hooks/useOpenTerminal' +import { serverScopedKey } from '@/lib/serverScope' +import { getServerSessionPath } from '@/lib/navigation' +import { getDirectoryName } from '@/lib/utils' +import { Header } from '@/components/ui/header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { SessionList } from '@/components/session/SessionList' +import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { RemoteSchedulesButton } from '@/components/schedules/RemoteSchedulesButton' +import { RemoteMultiRunButton } from '@/components/repo/RemoteMultiRunButton' +import { RepoMcpDialog } from '@/components/repo/RepoMcpDialog' +import { RepoSkillsDialog } from '@/components/repo/RepoSkillsDialog' +import { RemoteSourceControlPanel } from '@/components/source-control/RemoteSourceControlContent' +import { TerminalPanel } from '@/components/terminal/TerminalPanel' +import { ToolSidePanel } from '@/components/navigation/ToolSidePanel' + +function RemoteProjectContent({ serverId, directory }: { serverId: string; directory?: string }) { + const navigate = useNavigate() + const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { openTab } = useSettingsDialog() + const [mcpDialogOpen, setMcpDialogOpen] = useDialogParam('mcp') + const [skillsDialogOpen, setSkillsDialogOpen] = useDialogParam('skills') + const [sourceControlOpen, setSourceControlOpen] = useDialogParam('sourceControl') + const [terminalOpen, setTerminalOpen] = useTerminalDialogParam() + const terminalDirectory = useTerminalDirectoryParam() + const docked = !useMobile() + const toolPanel = useToolPanel(docked) + + const server = servers.find((candidate) => candidate.id === serverId) + const isDisabled = server !== undefined && !server.enabled + const isReady = server?.enabled === true && Boolean(directory) + const createSession = useCreateSession(directory) + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const vcsQuery = useQuery({ + queryKey: [...serverScopedKey(['opencode', 'vcs', directory ?? ''], serverId), 'info'], + queryFn: () => getVcsInfo(directory ?? '', serverId), + enabled: isReady, + retry: false, + }) + + useSSE(directory) + + const project = (projectsQuery.data ?? []).find((entry) => entry.canonical === directory) + const branch = vcsQuery.data?.branch.current ?? vcsQuery.data?.branch.default + const title = project?.name?.trim() || (directory ? getDirectoryName(directory) : 'Project') + + const openSettings = () => openTab('servers') + + const handleCreateSession = () => { + createSession.mutate({}, { + onSuccess: (session) => navigate(getServerSessionPath(serverId, session.id)), + }) + } + + return ( +
+
+
+
+ + {title} + + + {server?.name ?? 'Linked server'} + + {branch ? ( + + + {branch} + + ) : null} +
+ + {directory ? ( + <> + + + + + + ) : null} + +
+ +
+ {!serversLoading && !server ? ( +
+ +
+ ) : isDisabled ? ( +
+ +
+ ) : !directory ? ( +
+ navigate('/')} + /> +
+ ) : isReady ? ( + navigate(getServerSessionPath(serverId, sessionId))} + /> + ) : null} +
+
+ + {docked && directory ? ( + + ) : null} + + {directory ? ( + <> + + + setSourceControlOpen(false)} + /> + setTerminalOpen(false)} + /> + + ) : null} +
+ ) +} + +export function RemoteProject() { + const { serverId } = useParams<{ serverId: string }>() + const [searchParams] = useSearchParams() + const directory = searchParams.get('directory') ?? undefined + + if (!serverId) { + return null + } + + return +} diff --git a/frontend/src/pages/RemoteServer.tsx b/frontend/src/pages/RemoteServer.tsx deleted file mode 100644 index 8e2a5ce8f..000000000 --- a/frontend/src/pages/RemoteServer.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { useState, type FormEvent } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { formatDistanceToNow } from 'date-fns' -import { - ChevronDown, - ChevronRight, - FolderGit2, - Loader2, - Plus, - Server, - Settings, -} from 'lucide-react' -import type { OpenCodeServerStatus } from '@opencode-manager/shared/schemas' -import { listProjects, type OpenCodeProject } from '@/api/opencode' -import { useCreateSession, useSessionsAcrossDirectories } from '@/hooks/useOpenCode' -import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' -import { useSettingsDialog } from '@/hooks/useSettingsDialog' -import { useSSE } from '@/hooks/useSSE' -import { serverScopedKey } from '@/lib/serverScope' -import { getServerSessionPath } from '@/lib/navigation' -import { getDirectoryName } from '@/lib/utils' -import { Header } from '@/components/ui/header' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' - -function ConnectionStatus({ status, isLoading }: { status?: OpenCodeServerStatus; isLoading: boolean }) { - if (isLoading) { - return Checking connection... - } - - if (!status) { - return ( - - - Unknown - - ) - } - - if (!status.connected) { - return ( - - - Unreachable - {status.error ? · {status.error} : null} - - ) - } - - return ( - - - Connected - {status.version ? · v{status.version} : null} - - ) -} - -function ProjectSessions({ serverId, directory }: { serverId: string; directory: string }) { - useSSE(directory) - const { data: sessions, isLoading } = useSessionsAcrossDirectories([directory]) - - if (isLoading) { - return ( -
- - Loading sessions... -
- ) - } - - if (sessions.length === 0) { - return

No sessions in this project yet.

- } - - return ( -
    - {sessions.map((session) => ( -
  • - - - - {session.title || 'Untitled Session'} - - - {formatDistanceToNow(new Date(session.time.updated), { addSuffix: true })} - - -
  • - ))} -
- ) -} - -function ProjectRow({ project, serverId }: { project: OpenCodeProject; serverId: string }) { - const navigate = useNavigate() - const [expanded, setExpanded] = useState(false) - const createSession = useCreateSession(project.canonical) - const name = project.name?.trim() || getDirectoryName(project.canonical) - - const handleNewSession = async () => { - try { - const session = await createSession.mutateAsync({ agent: undefined }) - navigate(getServerSessionPath(serverId, session.id)) - } catch { - return - } - } - - return ( - -
- - -
- {expanded ? : null} -
- ) -} - -function RemoteServerContent({ serverId }: { serverId: string }) { - const navigate = useNavigate() - const { servers, isLoading: serversLoading } = useOpenCodeServers() - const { statusById, isLoading: statusLoading } = useOpenCodeServerStatuses() - const { setActiveTab } = useSettingsDialog() - const createSession = useCreateSession() - const [openDirectory, setOpenDirectory] = useState('') - - const server = servers.find((candidate) => candidate.id === serverId) - const status = statusById[serverId] - const isDisabled = server !== undefined && !server.enabled - - const projectsQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'projects'], serverId), - queryFn: () => listProjects(serverId), - enabled: server?.enabled === true, - retry: false, - }) - - const openSettings = () => setActiveTab('servers') - - const handleOpenDirectory = async (event: FormEvent) => { - event.preventDefault() - const directory = openDirectory.trim() - if (!directory) return - try { - const session = await createSession.mutateAsync({ directory }) - navigate(getServerSessionPath(serverId, session.id)) - } catch { - return - } - } - - return ( -
-
-
- - {server?.name ?? 'Linked server'} - - {server?.baseUrl} - -
- - - - -
- -
- {!serversLoading && !server ? ( - - -
-

Server not found

-

- This linked server is no longer configured. -

-
- -
- ) : null} - - {isDisabled ? ( - - -
-

This server is disabled

-

- Enable it in Settings → Servers to browse its projects and sessions. -

-
- -
- ) : null} - - {server && !isDisabled && projectsQuery.isError ? ( - - -
-

Could not load projects

-

- {projectsQuery.error instanceof Error - ? projectsQuery.error.message - : 'The server did not respond.'} -

-
- -
- ) : null} - - {server && !isDisabled && !projectsQuery.isError ? ( -
-
-

Projects

- {projectsQuery.isFetching ? ( - - - Refreshing - - ) : null} -
- - {projectsQuery.isLoading ? ( - - - Loading projects... - - ) : (projectsQuery.data ?? []).length === 0 ? ( - - -

No projects on this server yet

-

- Start a session in a directory below to create one. -

-
- ) : ( - (projectsQuery.data ?? []).map((project) => ( - - )) - )} -
- ) : null} - - {server && !isDisabled ? ( -
-

Open directory

-
- setOpenDirectory(event.target.value)} - placeholder="/absolute/path/on/the/server" - aria-label="Directory path" - autoComplete="off" - name="remote-directory" - className="font-mono text-sm" - /> - -
-
- ) : null} -
-
- ) -} - -export function RemoteServer() { - const { serverId } = useParams<{ serverId: string }>() - - if (!serverId) { - return null - } - - return -} diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index a26b79bf8..59a2cbe50 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -37,6 +37,7 @@ import { PendingActionsGroup } from "@/components/notifications/PendingActionsGr import { getRepoDisplayName } from "@/lib/utils"; import { notifyWorktreeSetup } from "@/lib/worktreeSetup"; import { getRepoDirectoryNameError, isWorktreeSibling } from "@opencode-manager/shared/utils"; +import type { MultiRunTarget } from "@opencode-manager/shared/schemas"; export function RepoDetail() { const { id } = useParams<{ id: string }>(); @@ -117,7 +118,8 @@ export function RepoDetail() { }, [baseDirectory, nonScheduleWorkspaceDirectories, activeScheduleDirectories]); const showWorktrees = activeTab === 'workspaces'; - const { data: multiRuns } = useMultiRuns(repoId, showWorktrees); + const multiRunTarget = useMemo(() => ({ kind: 'repo', repoId }), [repoId]); + const { data: multiRuns } = useMultiRuns(repoId > 0 ? multiRunTarget : undefined, showWorktrees); const sessionListDirectories = useMemo(() => { if (!showWorktrees) return baseDirectory ? [baseDirectory] : []; return Array.from(new Set([...nonScheduleWorkspaceDirectories, ...activeScheduleDirectories])); @@ -342,7 +344,7 @@ export function RepoDetail() { /> setTerminalOpen(false)} @@ -361,7 +363,7 @@ export function RepoDetail() { /> + + + + + + ); +} export function Repos() { const navigate = useNavigate(); @@ -18,6 +42,8 @@ export function Repos() { const [fileBrowserOpen, setFileBrowserOpen] = useDialogParam('files'); const docked = !useMobile(); const toolPanel = useToolPanel(docked); + const [selectedServerId] = useSidebarServerSelection(); + const isLocalServer = selectedServerId === LOCAL_OPENCODE_SERVER_ID; const handleCloseFileBrowser = () => { setFileBrowserOpen(false); @@ -34,15 +60,19 @@ export function Repos() {
- + {isLocalServer ? ( + + ) : ( + + )} - + {isLocalServer ? ( + + ) : null}
- - + {isLocalServer ? : }
- - + {isLocalServer ? ( + + ) : null} + {isLocalServer ? ( + + ) : null}
- {docked && } + {isLocalServer && docked ? : null}
); } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index f23e0d4ed..7720333d5 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -10,7 +10,7 @@ import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; import { Badge } from "@/components/ui/badge"; import { SessionPickerDialog } from "@/components/session/SessionPickerDialog"; -import { getRepoPath, getServerPath, getServerSessionPath, getSessionListPath, getSessionPath } from '@/lib/navigation' +import { getRepoPath, getServerProjectPath, getServerSessionPath, getSessionListPath, getSessionPath } from '@/lib/navigation' import { FetchError } from '@/api/fetchWrapper' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; @@ -49,6 +49,7 @@ import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog import { RepoSkillsDialog } from "@/components/repo/RepoSkillsDialog"; import { compactSession, forkSession, listProjects, listSessionMessages } from "@/api/opencode"; import { useOpenCodeServerId, isLocalOpenCodeServer } from "@/contexts/OpenCodeServerContext"; +import { useSwipeNavigation } from "@/contexts/SwipeNavigationContext"; import { useOpenCodeServers, useOpenCodeServerStatuses } from "@/hooks/useOpenCodeServers"; import { serverScopedKey } from "@/lib/serverScope"; import { useSessionStatus } from "@/stores/sessionStatusStore"; @@ -61,7 +62,7 @@ import { formatOpenCodeModelRef } from "@opencode-manager/shared/opencode"; import { FormPrompt } from "@/components/session/FormPrompt"; import { MinimizedFormIndicator } from "@/components/session/MinimizedFormIndicator"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; -import { SourceControlPanel } from "@/components/source-control"; +import { SourceControlPanel, RemoteSourceControlPanel } from "@/components/source-control"; import { TerminalPanel } from "@/components/terminal/TerminalPanel"; import { PreviewPanel } from "@/components/preview/PreviewPanel"; import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; @@ -128,10 +129,11 @@ export function SessionDetail() { const { id, sessionId } = useParams<{ id: string; sessionId: string }>(); const navigate = useNavigate(); const location = useLocation(); + const swipeNav = useSwipeNavigation(); const repoId = Number(id) || 0; const isAssistantSession = new URLSearchParams(location.search).get('assistant') === '1'; const { preferences, updateSettings } = useSettings(); - const { open: openSettings, setActiveTab: setSettingsTab } = useSettingsDialog(); + const { open: openSettings, openTab: openSettingsTab } = useSettingsDialog(); const messageContainerRef = useRef(null); const promptInputRef = useRef(null); const [sessionsDialogOpen, setSessionsDialogOpen] = useState(false); @@ -198,6 +200,17 @@ export function SessionDetail() { : undefined ); const sessionDirectory = isRemoteServer ? resolvedDirectory : resolvedDirectory ?? repoDirectory; + const remoteProjectBackPath = isRemoteServer && sessionDirectory + ? getServerProjectPath(serverId, sessionDirectory) + : '/'; + + useEffect(() => { + if (!isRemoteServer) return; + swipeNav?.setBackTarget(remoteProjectBackPath); + return () => { + swipeNav?.setBackTarget(null); + }; + }, [isRemoteServer, remoteProjectBackPath, swipeNav]); const { data: repoSiblings } = useRepoSiblings(!isAssistantSession && !isRemoteServer && repo && !repo.isWorktree ? repoId : undefined); @@ -354,7 +367,7 @@ export function SessionDetail() { ? requestedWalkthroughSource : undefined; const handleSkillLoaded = useCallback((skill: SkillFileInfo) => showToast.success(`Loaded skill: ${skill.name}`), []); - const handleConnectProvider = useCallback(() => setSettingsTab('providers'), [setSettingsTab]); + const handleConnectProvider = useCallback(() => openSettingsTab('providers'), [openSettingsTab]); useAutoOpenWalkthrough({ sessionId, @@ -501,12 +514,12 @@ export function SessionDetail() { const handleCloseSession = useCallback(() => { if (isRemoteServer) { - navigate(getServerPath(serverId)) + navigate(remoteProjectBackPath) return } const tab = new URLSearchParams(location.search).get('repoTab') ?? undefined; navigate(getSessionListPath(repoId, isAssistantSession, tab)) - }, [navigate, repoId, isAssistantSession, isRemoteServer, serverId, location.search]) + }, [navigate, repoId, isAssistantSession, isRemoteServer, remoteProjectBackPath, location.search]) const handleOpenModelDialog = useCallback(() => { promptInputRef.current?.openModelPicker(); @@ -716,8 +729,8 @@ export function SessionDetail() { return ( ); } @@ -727,8 +740,8 @@ export function SessionDetail() { return ( ); } @@ -755,7 +768,7 @@ export function SessionDetail() { : getRepoDisplayName(repo); const tabFromUrl = new URLSearchParams(location.search).get('repoTab') ?? undefined; const sessionBackPath = isRemoteServer - ? getServerPath(serverId) + ? remoteProjectBackPath : getSessionListPath(repoId, isAssistantSession, tabFromUrl); return ( @@ -792,7 +805,14 @@ export function SessionDetail() { onChange={handleRenameSession} subtitle={ - {workspaceDisplayName} + {isRemoteServer ? ( + <> + {linkedServer?.name ?? 'Linked server'} + {sessionDirectory && ` · ${getDirectoryName(sessionDirectory)}`} + + ) : ( + workspaceDisplayName + )} {sessionBranch && · {sessionBranch}} } @@ -823,7 +843,7 @@ export function SessionDetail() { isReconnecting={streamReconnecting} revertMessageID={revertMessageID} /> - {!isRemoteServer && } + @@ -911,7 +931,7 @@ export function SessionDetail() { /> )} - {!isRemoteServer && } + )} - {!isRemoteServer && ( - setSourceControlOpen(false)} - currentBranch={repo?.currentBranch || repo?.branch || "main"} - repoName={workspaceDisplayName} - /> - )} + {isRemoteServer + ? sessionDirectory && ( + setSourceControlOpen(false)} + /> + ) + : ( + setSourceControlOpen(false)} + currentBranch={repo?.currentBranch || repo?.branch || "main"} + repoName={workspaceDisplayName} + /> + )} - {!isRemoteServer && ( - setTerminalOpen(false)} - /> - )} + setTerminalOpen(false)} + /> {!isRemoteServer && ( ({ useAllSchedules: vi.fn(), useAllScheduleRuns: vi.fn(), useCreateRepoSchedule: vi.fn(), - useUpdateRepoSchedule: vi.fn(), - useDeleteRepoSchedule: vi.fn(), - useRunRepoSchedule: vi.fn(), - useCancelRepoScheduleRun: vi.fn(), + useUpdateScheduleTarget: vi.fn(), + useDeleteScheduleTarget: vi.fn(), + useRunScheduleTarget: vi.fn(), + useCancelScheduleTargetRun: vi.fn(), useUnreadScheduleRuns: vi.fn(), useScheduleUrlState: vi.fn(), ScheduleJobsTable: vi.fn(() => null), @@ -23,10 +24,10 @@ vi.mock('@/hooks/useSchedules', () => ({ useAllSchedules: mocks.useAllSchedules, useAllScheduleRuns: mocks.useAllScheduleRuns, useCreateRepoSchedule: mocks.useCreateRepoSchedule, - useUpdateRepoSchedule: mocks.useUpdateRepoSchedule, - useDeleteRepoSchedule: mocks.useDeleteRepoSchedule, - useRunRepoSchedule: mocks.useRunRepoSchedule, - useCancelRepoScheduleRun: mocks.useCancelRepoScheduleRun, + useUpdateScheduleTarget: mocks.useUpdateScheduleTarget, + useDeleteScheduleTarget: mocks.useDeleteScheduleTarget, + useRunScheduleTarget: mocks.useRunScheduleTarget, + useCancelScheduleTargetRun: mocks.useCancelScheduleTargetRun, useUnreadScheduleRuns: mocks.useUnreadScheduleRuns, })) @@ -153,6 +154,19 @@ const renderGlobalSchedules = () => { ) } +const renderGlobalSchedulesWithLocation = () => { + const capturedPathname = { current: '' } + const capturedSearch = { current: '' } + render( + + + + , + { wrapper: createWrapper() } + ) + return { capturedPathname, capturedSearch } +} + const runsTableProps = () => mocks.ScheduleRunsTable.mock.calls.at(-1)?.[0] as { runs: Array<{ id: number }> selectedRunId: number | null @@ -182,10 +196,10 @@ describe('GlobalSchedules run history', () => { ) mocks.useAllSchedules.mockReturnValue({ data: [], isLoading: false, error: null }) mocks.useCreateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useUpdateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useDeleteRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useRunRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useCancelRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useUpdateScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useDeleteScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useRunScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useCancelScheduleTargetRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.useUnreadScheduleRuns.mockReturnValue({ data: { runs: [], total: 0, failed: 0 } }) mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState()) }) @@ -333,6 +347,23 @@ describe('GlobalSchedules run history', () => { expect(selectRun).toHaveBeenCalledWith(5) }) + it('opens a linked-server job on its project page', async () => { + const job = makeJob({ repoId: null, serverId: 'srv_kitchen', directory: '/home/me/app', lastRun: null }) + mocks.useAllSchedules.mockReturnValue({ data: [job], isLoading: false, error: null }) + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'jobs', runId: null })) + + const { capturedPathname, capturedSearch } = renderGlobalSchedulesWithLocation() + + act(() => { + jobsTableProps().onOpen(job) + }) + + await waitFor(() => { + expect(capturedPathname.current).toBe('/servers/srv_kitchen/projects') + }) + expect(capturedSearch.current).toBe('?directory=%2Fhome%2Fme%2Fapp') + }) + it('keeps the jobs tab visible with the empty state when there are no jobs', () => { mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'jobs', runId: null })) diff --git a/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx b/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx new file mode 100644 index 000000000..6869841b7 --- /dev/null +++ b/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { LinkedServerRedirect } from '../LinkedServerRedirect' +import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/sidebarServerStore' + +function renderRedirect(serverId = 'srv_x') { + return render( + + + } /> + home
} /> + + , + ) +} + +describe('LinkedServerRedirect', () => { + beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + }) + + it('lands on home with the server selected', async () => { + renderRedirect('srv_x') + + await waitFor(() => expect(screen.getByText('home')).toBeInTheDocument()) + expect(useSidebarServerStore.getState().serverId).toBe('srv_x') + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe('srv_x') + }) +}) diff --git a/frontend/src/pages/__tests__/RemoteProject.test.tsx b/frontend/src/pages/__tests__/RemoteProject.test.tsx new file mode 100644 index 000000000..1c4053a2f --- /dev/null +++ b/frontend/src/pages/__tests__/RemoteProject.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { RemoteProject } from '../RemoteProject' + +interface SessionListCapture { + directory?: string + createDirectory?: string + onSelectSession?: (sessionId: string) => void +} + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + getVcsInfo: vi.fn(), + useCreateSession: vi.fn(), + useOpenCodeServers: vi.fn(), + useSettingsDialog: vi.fn(), + useSSE: vi.fn(), + createSession: vi.fn(), + lastSessionListProps: { current: undefined as SessionListCapture | undefined }, + openTab: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + listProjects: mocks.listProjects, + getVcsInfo: mocks.getVcsInfo, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useCreateSession: mocks.useCreateSession, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: mocks.useSettingsDialog, +})) + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: mocks.useSSE, +})) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: (props: SessionListCapture) => { + mocks.lastSessionListProps.current = props + return
{props.directory}
+ }, +})) + +vi.mock('@/components/navigation/ToolSidePanel', () => ({ + ToolSidePanel: () =>
, +})) + +const server = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const project = { + id: 'prj_1', + canonical: '/home/me/app', + name: 'App', + time: { created: 1, updated: 2, active: 2 }, + sandboxes: [], +} + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function renderProject(path = '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') { + return render( + + + + } /> + } /> + } /> + + + , + ) +} + +describe('RemoteProject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.lastSessionListProps.current = undefined + mocks.listProjects.mockResolvedValue([project]) + mocks.getVcsInfo.mockResolvedValue({ branch: { current: 'main', default: 'main' } }) + mocks.useCreateSession.mockReturnValue({ mutate: mocks.createSession, isPending: false }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) + mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + }) + + it('renders the project name, server name and branch from the linked server', async () => { + renderProject() + + expect(await screen.findByText('App')).toBeInTheDocument() + expect(screen.getByText('Kitchen')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('main')).toBeInTheDocument() + }) + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + expect(mocks.getVcsInfo).toHaveBeenCalledWith('/home/me/app', 'srv_kitchen') + }) + + it('falls back to the directory name when the server does not list the project', async () => { + mocks.listProjects.mockResolvedValue([]) + renderProject() + + expect(await screen.findByText('app')).toBeInTheDocument() + }) + + it('renders the session list for the project directory and subscribes to its events', () => { + renderProject() + + expect(screen.getByTestId('session-list')).toHaveTextContent('/home/me/app') + expect(mocks.lastSessionListProps.current?.createDirectory).toBe('/home/me/app') + expect(mocks.useSSE).toHaveBeenCalledWith('/home/me/app') + }) + + it('opens a session from the session list on the remote session route', () => { + renderProject() + + act(() => { + mocks.lastSessionListProps.current?.onSelectSession?.('ses_42') + }) + + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + + it('creates a session in the project directory and navigates to it', async () => { + mocks.useCreateSession.mockReturnValue({ + mutate: (_input: unknown, options?: { onSuccess?: (session: { id: string }) => void }) => { + options?.onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }) + renderProject() + + fireEvent.click(screen.getAllByRole('button', { name: 'New Session' })[0]) + + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + expect(mocks.useCreateSession).toHaveBeenCalledWith('/home/me/app') + }) + + it('shows a missing-directory state without rendering the session list', async () => { + renderProject('/servers/srv_kitchen/projects') + + expect(await screen.findByText('No project selected')).toBeInTheDocument() + expect(screen.queryByTestId('session-list')).not.toBeInTheDocument() + }) + + it('goes back to the projects list from the missing-directory state', async () => { + renderProject('/servers/srv_kitchen/projects') + + fireEvent.click(await screen.findByRole('button', { name: 'Back to projects' })) + + expect(screen.getByTestId('home-route')).toBeInTheDocument() + }) + + it('shows the disabled state without loading projects', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProject() + + expect(await screen.findByText('This server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + expect(screen.queryByTestId('session-list')).not.toBeInTheDocument() + }) + + it('shows the missing-server state', async () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + + renderProject() + + expect(await screen.findByText('Server not found')).toBeInTheDocument() + }) + + it('opens server settings from the disabled state', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProject() + + fireEvent.click(await screen.findByRole('button', { name: 'Open server settings' })) + + expect(mocks.openTab).toHaveBeenCalledWith('servers') + }) + + it('goes back to the projects list', async () => { + renderProject() + + fireEvent.click(await screen.findByRole('button', { name: 'Go back' })) + + expect(screen.getByTestId('home-route')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/RemoteServer.test.tsx b/frontend/src/pages/__tests__/RemoteServer.test.tsx deleted file mode 100644 index 3689be291..000000000 --- a/frontend/src/pages/__tests__/RemoteServer.test.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { RemoteServer } from '../RemoteServer' - -const mocks = vi.hoisted(() => ({ - listProjects: vi.fn(), - useSessionsAcrossDirectories: vi.fn(), - useCreateSession: vi.fn(), - useOpenCodeServers: vi.fn(), - useOpenCodeServerStatuses: vi.fn(), - useSettingsDialog: vi.fn(), - useSSE: vi.fn(), - setActiveTab: vi.fn(), - createSession: vi.fn(), -})) - -vi.mock('@/api/opencode', () => ({ - listProjects: mocks.listProjects, -})) - -vi.mock('@/hooks/useOpenCode', () => ({ - useSessionsAcrossDirectories: mocks.useSessionsAcrossDirectories, - useCreateSession: mocks.useCreateSession, -})) - -vi.mock('@/hooks/useOpenCodeServers', () => ({ - useOpenCodeServers: mocks.useOpenCodeServers, - useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, -})) - -vi.mock('@/hooks/useSettingsDialog', () => ({ - useSettingsDialog: mocks.useSettingsDialog, -})) - -vi.mock('@/hooks/useSSE', () => ({ - useSSE: mocks.useSSE, -})) - -const server = { - id: 'srv_kitchen', - name: 'Kitchen', - baseUrl: 'https://kitchen.example.com', - username: 'opencode', - enabled: true, - hasPassword: true, - createdAt: 1, - updatedAt: 2, -} - -const project = { - id: 'prj_1', - canonical: '/home/me/app', - name: 'App', - time: { created: 1, updated: 2, active: 2 }, - sandboxes: [], -} - -const session = { - id: 'ses_1', - projectID: 'prj_1', - title: 'Fix the bug', - time: { created: 1, updated: Date.now() - 60000 }, - location: { directory: '/home/me/app' }, -} - -function createQueryClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }) -} - -function renderServer(serverId = 'srv_kitchen') { - return render( - - - - } /> - } /> - - - , - ) -} - -describe('RemoteServer', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.listProjects.mockResolvedValue([project]) - mocks.useSessionsAcrossDirectories.mockReturnValue({ data: [session], isLoading: false }) - mocks.useCreateSession.mockReturnValue({ mutateAsync: mocks.createSession, isPending: false }) - mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) - mocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: { - srv_kitchen: { - serverId: 'srv_kitchen', - connected: true, - version: '2.0.15', - error: null, - }, - }, - isLoading: false, - }) - mocks.useSettingsDialog.mockReturnValue({ setActiveTab: mocks.setActiveTab }) - mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) - }) - - it('renders the server identity and connection status', async () => { - renderServer() - - expect(screen.getByText('Kitchen')).toBeInTheDocument() - expect(screen.getByText('https://kitchen.example.com')).toBeInTheDocument() - await waitFor(() => { - expect(screen.getByText(/Connected/)).toBeInTheDocument() - }) - expect(screen.getByText(/v2\.0\.15/)).toBeInTheDocument() - }) - - it('lists projects from the remote server', async () => { - renderServer() - - await waitFor(() => { - expect(screen.getByText('App')).toBeInTheDocument() - }) - expect(screen.getByText('/home/me/app')).toBeInTheDocument() - expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') - }) - - it('links each expanded project session to the remote session route', async () => { - renderServer() - - fireEvent.click(await screen.findByRole('button', { name: /App/ })) - - const link = await screen.findByRole('link', { name: /Fix the bug/ }) - expect(link).toHaveAttribute('href', '/servers/srv_kitchen/sessions/ses_1') - expect(mocks.useSessionsAcrossDirectories).toHaveBeenCalledWith(['/home/me/app']) - }) - - it('creates a session in a project directory and navigates to it', async () => { - mocks.createSession.mockResolvedValue({ id: 'ses_new' }) - renderServer() - - await screen.findByText('App') - fireEvent.click(screen.getByRole('button', { name: /New session/ })) - - await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) - }) - await waitFor(() => { - expect(screen.getByTestId('session-route')).toBeInTheDocument() - }) - }) - - it('opens a session in an arbitrary directory', async () => { - mocks.createSession.mockResolvedValue({ id: 'ses_other' }) - renderServer() - - fireEvent.change(screen.getByLabelText('Directory path'), { target: { value: '/srv/other' } }) - fireEvent.click(screen.getByRole('button', { name: /Start session/ })) - - await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/other' }) - }) - await waitFor(() => { - expect(screen.getByTestId('session-route')).toBeInTheDocument() - }) - }) - - it('shows the disabled state without loading projects', async () => { - mocks.useOpenCodeServers.mockReturnValue({ - servers: [{ ...server, enabled: false }], - isLoading: false, - }) - - renderServer() - - expect(screen.getByText('This server is disabled')).toBeInTheDocument() - expect(mocks.listProjects).not.toHaveBeenCalled() - }) - - it('shows an empty state when the server has no projects', async () => { - mocks.listProjects.mockResolvedValue([]) - - renderServer() - - await waitFor(() => { - expect(screen.getByText('No projects on this server yet')).toBeInTheDocument() - }) - }) - - it('shows the unreachable state from the status hook', async () => { - mocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: { - srv_kitchen: { - serverId: 'srv_kitchen', - connected: false, - version: null, - error: 'connection refused', - }, - }, - isLoading: false, - }) - - renderServer() - - expect(screen.getByText('Unreachable')).toBeInTheDocument() - expect(screen.getByText(/connection refused/)).toBeInTheDocument() - }) - - it('opens the servers settings tab from the header', () => { - renderServer() - - fireEvent.click(screen.getByRole('button', { name: /Manage servers/ })) - - expect(mocks.setActiveTab).toHaveBeenCalledWith('servers') - }) -}) diff --git a/frontend/src/pages/__tests__/Repos.test.tsx b/frontend/src/pages/__tests__/Repos.test.tsx new file mode 100644 index 000000000..4fb6460af --- /dev/null +++ b/frontend/src/pages/__tests__/Repos.test.tsx @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { Repos } from '../Repos' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' + +const mocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), + openTab: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: () => ({ + isOpen: false, + open: vi.fn(), + openTab: mocks.openTab, + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + selectedTab: null, + setActiveTab: vi.fn(), + }), +})) + +vi.mock('@/hooks/useMobile', () => ({ + isMobileViewport: () => false, + useMobile: () => false, +})) + +vi.mock('@/components/repo/RepoList', () => ({ + RepoList: () =>
local-repos
, +})) + +vi.mock('@/components/repo/AddRepoDialog', () => ({ + AddRepoDialog: () => null, +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: () => null, +})) + +vi.mock('@/components/navigation/ToolSidePanel', () => ({ + ToolSidePanel: () => null, +})) + +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ + PendingActionsGroup: () => null, +})) + +vi.mock('@/components/servers/RemoteProjectList', () => ({ + RemoteProjectList: ({ serverId }: { serverId: string }) => ( +
remote-projects:{serverId}
+ ), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +function LocationDisplay() { + return
{useLocation().pathname}
+} + +function renderRepos() { + return render( + + + + } /> + + , + ) +} + +describe('Repos', () => { + beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen], + isLoading: false, + isSuccess: true, + }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { + srv_kitchen: { + serverId: 'srv_kitchen', + connected: true, + version: '2.0.15', + error: null, + }, + }, + isLoading: false, + }) + }) + + it('shows the local repositories and local-only actions', () => { + renderRepos() + + expect(screen.getByText('local-repos')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Repo' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open files' })).toBeInTheDocument() + expect(screen.queryByText(/remote-projects/)).not.toBeInTheDocument() + }) + + it('shows the selected linked server projects and hides local-only actions', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + expect(screen.getByText('remote-projects:srv_kitchen')).toBeInTheDocument() + expect(screen.queryByText('local-repos')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Repo' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Open files' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Manage servers/ })).toHaveClass('hidden', 'sm:flex') + expect(screen.getByRole('button', { name: 'All Schedules' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument() + }) + + it('swaps the center section without navigating when the selection changes', () => { + renderRepos() + + act(() => useSidebarServerStore.getState().selectServer('srv_kitchen')) + + expect(screen.getByText('remote-projects:srv_kitchen')).toBeInTheDocument() + expect(screen.queryByText('local-repos')).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') + + act(() => useSidebarServerStore.getState().selectServer(LOCAL_OPENCODE_SERVER_ID)) + + expect(screen.getByText('local-repos')).toBeInTheDocument() + expect(screen.queryByText(/remote-projects/)).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('shows the connection status of the selected linked server', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + expect(screen.getByText(/Connected/)).toBeInTheDocument() + expect(screen.getByText(/v2\.0\.15/)).toBeInTheDocument() + }) + + it('shows an unreachable linked server with its error', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { + srv_kitchen: { + serverId: 'srv_kitchen', + connected: false, + version: null, + error: 'connection refused', + }, + }, + isLoading: false, + }) + + renderRepos() + + expect(screen.getByText(/Unreachable/)).toBeInTheDocument() + expect(screen.getByText(/connection refused/)).toBeInTheDocument() + }) + + it('opens the servers settings tab from the header', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + fireEvent.click(screen.getByRole('button', { name: /Manage servers/ })) + + expect(mocks.openTab).toHaveBeenCalledWith('servers') + }) +}) diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index caf887a6a..d05406b6c 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ useAutoScroll: vi.fn(), dialogSetters: {} as Record>, openSettings: vi.fn(), - setSettingsTab: vi.fn(), + openSettingsTab: vi.fn(), useSessionStatusForSession: vi.fn(), compactSession: vi.fn(), listSessionMessages: vi.fn(), @@ -118,7 +118,7 @@ vi.mock('@/hooks/useSettings', () => ({ })) vi.mock('@/hooks/useSettingsDialog', () => ({ - useSettingsDialog: vi.fn(() => ({ open: mocks.openSettings, setActiveTab: mocks.setSettingsTab })), + useSettingsDialog: vi.fn(() => ({ open: mocks.openSettings, openTab: mocks.openSettingsTab })), })) vi.mock('@/hooks/useMobile', () => ({ @@ -733,7 +733,7 @@ describe('SessionDetail command actions', () => { await connectProvider() }) - expect(mocks.setSettingsTab).toHaveBeenCalledWith('providers') + expect(mocks.openSettingsTab).toHaveBeenCalledWith('providers') }) it('exports complete history across pages in oldest-first order without duplicates', async () => { diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index 0217ffd8a..fcadbc868 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor, act } from '@testing-library/react' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { FetchError } from '@/api/fetchWrapper' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' +import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' +import { getServerProjectPath } from '@/lib/navigation' import { LocationCatcher } from '@/test/test-utils' import { MessageThread } from '@/components/message/MessageThread' import { ContextUsageIndicator } from '@/components/session/ContextUsageIndicator' @@ -31,6 +34,7 @@ const mocks = vi.hoisted(() => ({ getRepo: vi.fn(), useSessionStatus: vi.fn(), SourceControlPanel: vi.fn(() => null), + RemoteSourceControlPanel: vi.fn(() => null), TerminalPanel: vi.fn(() => null), PreviewPanel: vi.fn(() => null), FileBrowserSheet: vi.fn(() => null), @@ -145,7 +149,10 @@ vi.mock('@/components/notifications/PendingActionsGroup', () => ({ vi.mock('@/components/navigation/SessionMoreButton', () => ({ SessionMoreButton: mocks.SessionMoreButton })) vi.mock('@/components/navigation/ToolSidePanel', () => ({ ToolSidePanel: mocks.ToolSidePanel })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: mocks.FileBrowserSheet })) -vi.mock('@/components/source-control', () => ({ SourceControlPanel: mocks.SourceControlPanel })) +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: mocks.SourceControlPanel, + RemoteSourceControlPanel: mocks.RemoteSourceControlPanel, +})) vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalPanel: mocks.TerminalPanel })) vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewPanel: mocks.PreviewPanel })) vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: mocks.ProjectActionsMenu })) @@ -179,23 +186,36 @@ function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } +type SwipeNavigation = ReturnType + +function SwipeNavigationProbe({ captured }: { captured: { current: SwipeNavigation } }) { + captured.current = useSwipeNavigation() + return null +} + function renderRemoteSession(sessionId = 'ses_remote') { const capturedPathname = { current: '' } const capturedSearch = { current: '' } + const capturedSwipeNavigation = { current: null as SwipeNavigation } const rendered = render( - - - - - } /> - - - + + + + + + + + + } /> + + + + , ) - return { ...rendered, capturedPathname } + return { ...rendered, capturedPathname, capturedSearch, capturedSwipeNavigation } } describe('SessionDetail remote server mode', () => { @@ -231,7 +251,7 @@ describe('SessionDetail remote server mode', () => { preferences: { expandToolCalls: false }, updateSettings: vi.fn(), }) - mocks.useSettingsDialog.mockReturnValue({ open: vi.fn(), setActiveTab: vi.fn() }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn(), openTab: vi.fn() }) mocks.useMobile.mockReturnValue(false) mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn(), scrollByUser: vi.fn() }) @@ -262,8 +282,50 @@ describe('SessionDetail remote server mode', () => { renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen · app')).toBeInTheDocument() + expect(screen.getByText('Kitchen')).toHaveClass('text-info') + }) + expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + }) + + it('returns to the project page from the header back button', async () => { + const { capturedPathname, capturedSearch } = renderRemoteSession() + + await waitFor(() => { + expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') }) + + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + + expect(capturedPathname.current).toBe('/servers/srv_kitchen/projects') + expect(capturedSearch.current).toBe('?directory=%2Fhome%2Fme%2Fapp') + }) + + it('registers the remote project page as the swipe-back target once the directory resolves', async () => { + const { capturedSwipeNavigation, unmount } = renderRemoteSession() + + await waitFor(() => { + expect(capturedSwipeNavigation.current?.backTarget()).toBe( + getServerProjectPath('srv_kitchen', '/home/me/app'), + ) + }) + + unmount() + + expect(capturedSwipeNavigation.current?.backTarget()).toBeNull() + }) + + it('falls back to the projects list when the session is not found', async () => { + mocks.useSession.mockReturnValue({ + data: undefined, + isLoading: false, + error: new FetchError('Session not found', 404), + }) + + const { capturedPathname } = renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Back to projects' })) + + expect(capturedPathname.current).toBe('/') }) it('uses the session directory as the only directory source', async () => { @@ -283,20 +345,64 @@ describe('SessionDetail remote server mode', () => { }) expect(mocks.SourceControlPanel).not.toHaveBeenCalled() - expect(mocks.TerminalPanel).not.toHaveBeenCalled() expect(mocks.PreviewPanel).not.toHaveBeenCalled() expect(mocks.FileBrowserSheet).not.toHaveBeenCalled() expect(mocks.ProjectActionsMenu).not.toHaveBeenCalled() expect(mocks.RepoActionsDialog).not.toHaveBeenCalled() expect(mocks.ResetPermissionsDialog).not.toHaveBeenCalled() expect(mocks.ChangesWalkthroughSheet).not.toHaveBeenCalled() - expect(mocks.SessionGoalBar).not.toHaveBeenCalled() - expect(mocks.SessionMoreButton).not.toHaveBeenCalled() + expect(mocks.SessionMoreButton).toHaveBeenCalled() expect(vi.mocked(PendingActionsGroup).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ showScheduleReports: false }), ) }) + it('renders the More button so the drawer stays reachable on a remote session', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SessionMoreButton).toHaveBeenCalled() + }) + + it('shows the session goal bar for a remote session', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SessionGoalBar).toHaveBeenCalled() + expect(mocks.SessionGoalBar.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ sessionID: 'ses_remote' }), + ) + }) + + it('exposes terminals for the linked server', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.TerminalPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + target: { kind: 'server', serverId: 'srv_kitchen' }, + directory: '/home/me/app', + isOpen: false, + }), + ) + expect(mocks.ToolSidePanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + repoId: undefined, + terminalTarget: { kind: 'server', serverId: 'srv_kitchen' }, + directory: '/home/me/app', + }), + ) + }) + it('offers the MCP and skills views scoped to the remote session directory', async () => { renderRemoteSession() @@ -315,6 +421,23 @@ describe('SessionDetail remote server mode', () => { ) }) + it('opens the remote source control panel instead of the Manager one', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SourceControlPanel).not.toHaveBeenCalled() + expect(mocks.RemoteSourceControlPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + serverId: 'srv_kitchen', + directory: '/home/me/app', + isOpen: false, + }), + ) + }) + it('treats the stream as disconnected when the remote server status is disconnected', async () => { mocks.useOpenCodeServerStatuses.mockReturnValue({ statusById: { diff --git a/frontend/src/stores/sidebarServerStore.ts b/frontend/src/stores/sidebarServerStore.ts new file mode 100644 index 000000000..b69cdd3df --- /dev/null +++ b/frontend/src/stores/sidebarServerStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { readStoredValue, writeStoredValue } from '@/lib/persistentStorage' + +export const SIDEBAR_SERVER_STORAGE_KEY = 'oc:sidebar:server' + +function readStoredServerId(): string { + const stored = readStoredValue(SIDEBAR_SERVER_STORAGE_KEY) + if (stored === null) return LOCAL_OPENCODE_SERVER_ID + try { + const parsed = JSON.parse(stored) + return typeof parsed === 'string' && parsed.length > 0 ? parsed : LOCAL_OPENCODE_SERVER_ID + } catch { + return LOCAL_OPENCODE_SERVER_ID + } +} + +interface SidebarServerStore { + serverId: string + selectServer: (serverId: string) => void +} + +/** + * The server whose projects and sessions the sidebar lists. Shared by the desktop sidebar, its + * collapsed switcher, and the mobile repo sheet, and persisted across reloads. + */ +export const useSidebarServerStore = create((set) => ({ + serverId: readStoredServerId(), + selectServer: (serverId) => { + writeStoredValue(SIDEBAR_SERVER_STORAGE_KEY, serverId) + set({ serverId }) + }, +})) diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts index 00668cebf..a47643608 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -7,6 +7,7 @@ const BASE_URL = 'http://localhost:5003' const goal: SessionGoal = { id: 7, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'fix the flaky test', status: 'active', @@ -27,6 +28,8 @@ const goal: SessionGoal = { const run: MultiRun = { id: 3, repoId: 1, + serverId: 'local', + directory: null, name: 'sweep', prompt: 'hello', isolated: true, diff --git a/shared/src/schemas/multi-runs.ts b/shared/src/schemas/multi-runs.ts index ef7d50dc1..419cbb853 100644 --- a/shared/src/schemas/multi-runs.ts +++ b/shared/src/schemas/multi-runs.ts @@ -62,9 +62,15 @@ export const MultiRunFusionSchema = z.object({ export type MultiRunFusion = z.infer; +export type MultiRunTarget = + | { kind: "repo"; repoId: number } + | { kind: "server"; serverId: string; directory: string }; + export const MultiRunSchema = z.object({ id: z.number().int(), - repoId: z.number().int(), + repoId: z.number().int().nullable(), + serverId: z.string(), + directory: z.string().nullable(), name: z.string(), prompt: z.string(), isolated: z.boolean(), @@ -76,19 +82,30 @@ export const MultiRunSchema = z.object({ export type MultiRun = z.infer; -export const LaunchMultiRunRequestSchema = z.object({ - repoId: z.number().int(), - name: z.string().trim().min(1).max(MULTI_RUN_NAME_MAX_LENGTH), - prompt: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), - models: z - .array(z.string().min(3)) - .min(1) - .max(MULTI_RUN_MAX_MODELS) - .refine((models) => new Set(models).size === models.length, { message: "Models must be unique" }), - isolate: z.boolean(), - baseRef: z.string().trim().min(1).optional(), - agent: z.string().optional(), -}); +export const LaunchMultiRunRequestSchema = z + .object({ + repoId: z.number().int().optional(), + serverId: z.string().trim().min(1).optional(), + directory: z.string().trim().min(1).optional(), + name: z.string().trim().min(1).max(MULTI_RUN_NAME_MAX_LENGTH), + prompt: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), + models: z + .array(z.string().min(3)) + .min(1) + .max(MULTI_RUN_MAX_MODELS) + .refine((models) => new Set(models).size === models.length, { message: "Models must be unique" }), + isolate: z.boolean(), + baseRef: z.string().trim().min(1).optional(), + agent: z.string().optional(), + }) + .refine((data) => (data.repoId === undefined) !== (data.serverId === undefined), { + message: "Provide either repoId or serverId", + path: ["repoId"], + }) + .refine((data) => (data.serverId === undefined) === (data.directory === undefined), { + message: "A server target needs a directory", + path: ["directory"], + }); export type LaunchMultiRunRequest = z.infer; diff --git a/shared/src/schemas/notifications.ts b/shared/src/schemas/notifications.ts index fcd21e710..f1bac5aa8 100644 --- a/shared/src/schemas/notifications.ts +++ b/shared/src/schemas/notifications.ts @@ -70,6 +70,8 @@ export const PushNotificationPayloadSchema = z.object({ directory: z.string().optional(), repoId: z.number().optional(), repoName: z.string().optional(), + serverId: z.string().optional(), + serverName: z.string().optional(), priority: z.enum(["normal", "high"]).optional(), }) .optional(), diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index e9bb5d3ed..315bbc238 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -13,8 +13,10 @@ 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. + * `in-place` runs directly in the target directory and is the only mode available for a + * project on a linked remote OpenCode server, which has no local checkout to branch from. */ -export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree']) +export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree', 'in-place']) export type ScheduleWorkspaceMode = z.infer /** @@ -171,7 +173,9 @@ export function buildSchedulePermissionRuleset( export const ScheduleJobSchema = z.object({ id: z.number(), - repoId: z.number(), + repoId: z.number().nullable(), + serverId: z.string().optional(), + directory: z.string().nullable().optional(), name: z.string(), description: z.string().nullable(), enabled: z.boolean(), @@ -197,7 +201,8 @@ export type ScheduleJob = z.infer export const ScheduleRunSchema = z.object({ id: z.number(), jobId: z.number(), - repoId: z.number(), + repoId: z.number().nullable(), + serverId: z.string().optional(), triggerSource: ScheduleRunTriggerSourceSchema, status: ScheduleRunStatusSchema, startedAt: z.number(), @@ -227,6 +232,7 @@ const ScheduleJobBaseRequestSchema = z.object({ mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), workspaceMode: ScheduleWorkspaceModeSchema.optional(), + directory: z.string().min(1).max(4096).optional(), }) export const CreateScheduleJobRequestSchema = z.discriminatedUnion('scheduleMode', [ @@ -258,6 +264,7 @@ export const UpdateScheduleJobRequestSchema = z.object({ mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), workspaceMode: ScheduleWorkspaceModeSchema.optional(), + directory: z.string().min(1).max(4096).nullable().optional(), }) export type UpdateScheduleJobRequest = z.infer diff --git a/shared/src/schemas/session-goals.ts b/shared/src/schemas/session-goals.ts index 2a6295864..51f96cb4a 100644 --- a/shared/src/schemas/session-goals.ts +++ b/shared/src/schemas/session-goals.ts @@ -29,6 +29,7 @@ export type SessionGoalTurnState = z.infer; export const SessionGoalSchema = z.object({ id: z.number().int(), sessionId: z.string(), + serverId: z.string(), directory: z.string(), objective: z.string(), status: SessionGoalStatusSchema, @@ -66,6 +67,7 @@ export function isTerminalSessionGoal(goal: SessionGoal | null | undefined): goa export const StartSessionGoalRequestSchema = z.object({ sessionId: z.string().min(1), + serverId: z.string().min(1).optional(), directory: z.string().min(1), objective: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), maxContinuations: z.number().int().min(GOAL_MAX_CONTINUATIONS_MIN).max(GOAL_MAX_CONTINUATIONS_MAX).optional(), diff --git a/shared/src/schemas/session-permissions.ts b/shared/src/schemas/session-permissions.ts index 0f0248929..514ce6f53 100644 --- a/shared/src/schemas/session-permissions.ts +++ b/shared/src/schemas/session-permissions.ts @@ -11,6 +11,7 @@ export type SessionLockReason = z.infer; export const SetSessionPermissionModeRequestSchema = z.object({ directory: z.string().min(1), mode: SessionPermissionModeSchema, + serverId: z.string().min(1).optional(), }); export type SetSessionPermissionModeRequest = z.infer; diff --git a/shared/src/schemas/sse.ts b/shared/src/schemas/sse.ts index af21d71e3..776c9f8c7 100644 --- a/shared/src/schemas/sse.ts +++ b/shared/src/schemas/sse.ts @@ -10,6 +10,7 @@ export const SSEVisibilitySchema = z.object({ clientId: z.string().min(1), visible: z.boolean(), activeSessionId: z.string().nullable().optional(), + activeServerId: z.string().min(1).optional(), }); export type SSESubscribeRequest = z.infer; diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index a12efff08..694a3b9b4 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -22,6 +22,10 @@ export function buildSessionPath( return `/repos/${repoId}/sessions/${sessionId}${search ? `?${search}` : ''}` } +export function buildServerSessionPath(serverId: string, sessionId: string): string { + return `/servers/${encodeURIComponent(serverId)}/sessions/${encodeURIComponent(sessionId)}` +} + function trimTrailingChar(value: string, char: string): string { let end = value.length while (end > 0 && value[end - 1] === char) end--