Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a3aaba8
section 1: Phase 1: One owner for reading a session's latest assistant
chriswritescode-dev Oct 3, 2026
66e6c20
section 2: Phase 2: Session permission mode storage, resolution and API
chriswritescode-dev Oct 3, 2026
f85531f
section 3: Phase 3: Auto-accept engine, default stamping and notificati
chriswritescode-dev Oct 3, 2026
dd4db97
section 4: Phase 4: Permission mode toggle in the composer and default
chriswritescode-dev Oct 3, 2026
74b00e3
section 5: Phase 5: Session goal storage and lifecycle API
chriswritescode-dev Oct 3, 2026
40bbe5a
section 6: Phase 6: Goal audit loop: quiet-period audit, continuation a
chriswritescode-dev Oct 3, 2026
4e9f27a
section 7: Phase 7: Goal recovery on restart and outcome notifications
chriswritescode-dev Oct 3, 2026
8cd00e1
section 8: Phase 8: Goal mode in the composer, goal status bar and goal
chriswritescode-dev Oct 3, 2026
f402e84
section 9: Phase 9: One owner for creating and removing OpenCode worksp
chriswritescode-dev Oct 3, 2026
2061134
section 10: Phase 10: Session launcher owner
chriswritescode-dev Oct 3, 2026
fc623fd
section 11: Phase 11: Internal session routes, `ocm` tool allow-list and
chriswritescode-dev Oct 3, 2026
891f542
section 12: Phase 12: Multi-run backend: run groups, parallel launch and
chriswritescode-dev Oct 3, 2026
444a43a
section 13: Phase 13: Multi-run launcher and run groups in the repo page
chriswritescode-dev Oct 3, 2026
8463db0
section 14: Phase 14: Documentation and full repository checks
chriswritescode-dev Oct 3, 2026
2af2577
loop: agent-orchestration completed after 23 iterations
chriswritescode-dev Oct 3, 2026
96e02e9
feat(sessions): add permission modes, session goals, ocm session tool…
chriswritescode-dev Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/src/db/migrations/025-session-permission-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Migration } from '../migration-runner'
import { ensureSessionPermissionModesTable } from '../session-permission-modes'

const migration: Migration = {
version: 25,
name: 'session-permission-modes',
up(db) {
ensureSessionPermissionModesTable(db)
},
down(db) {
db.run('DROP TABLE IF EXISTS session_permission_modes')
},
}

export default migration
15 changes: 15 additions & 0 deletions backend/src/db/migrations/026-session-goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Migration } from '../migration-runner'
import { ensureSessionGoalsTable } from '../session-goals'

const migration: Migration = {
version: 26,
name: 'session-goals',
up(db) {
ensureSessionGoalsTable(db)
},
down(db) {
db.run('DROP TABLE IF EXISTS session_goals')
},
}

export default migration
16 changes: 16 additions & 0 deletions backend/src/db/migrations/027-multi-runs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Migration } from '../migration-runner'
import { ensureMultiRunTables } from '../multi-runs'

const migration: Migration = {
version: 27,
name: 'multi-runs',
up(db) {
ensureMultiRunTables(db)
},
down(db) {
db.run('DROP TABLE IF EXISTS multi_run_entries')
db.run('DROP TABLE IF EXISTS multi_runs')
},
}

export default migration
6 changes: 6 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import migration021 from './021-drop-schedule-run-workspace-id'
import migration022 from './022-schedule-runs-session-index'
import migration023 from './023-schedule-mcp-servers'
import migration024 from './024-schedule-runs-viewed-at'
import migration025 from './025-session-permission-modes'
import migration026 from './026-session-goals'
import migration027 from './027-multi-runs'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -49,4 +52,7 @@ export const allMigrations: Migration[] = [
migration022,
migration023,
migration024,
migration025,
migration026,
migration027,
]
265 changes: 265 additions & 0 deletions backend/src/db/multi-runs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import type { Database } from 'bun:sqlite'
import type { MultiRunEntryStatus } from '@opencode-manager/shared/schemas'

export interface MultiRunEntryRecord {
id: number
model: string
status: MultiRunEntryStatus
sessionId: string | null
directory: string | null
isolated: boolean
error: string | null
createdAt: number
updatedAt: number
}

export interface MultiRunRecord {
id: number
repoId: number
name: string
prompt: string
isolated: boolean
baseRef: string | null
createdAt: number
entries: MultiRunEntryRecord[]
}

export interface CreateMultiRunGroup {
repoId: number
name: string
prompt: string
isolated: boolean
baseRef: string | null
}

export interface MultiRunEntryPatch {
status?: MultiRunEntryStatus
sessionId?: string | null
directory?: string | null
error?: string | null
}

interface MultiRunRow {
id: number
repo_id: number
name: string
prompt: string
isolated: number
base_ref: string | null
created_at: number
}

interface MultiRunEntryRow {
id: number
multi_run_id: number
model: string
status: MultiRunEntryStatus
session_id: string | null
directory: string | null
error: string | null
created_at: number
updated_at: number
}

const MULTI_RUN_COLUMNS = 'id, repo_id, 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'

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,
name TEXT NOT NULL,
prompt TEXT NOT NULL,
isolated INTEGER NOT NULL,
base_ref TEXT,
created_at INTEGER NOT NULL
)
`)
db.run(`
CREATE TABLE IF NOT EXISTS multi_run_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
multi_run_id INTEGER NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('starting','started','failed','discarded')),
session_id TEXT,
directory TEXT,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
db.run(`
CREATE INDEX IF NOT EXISTS idx_multi_run_entries_group
ON multi_run_entries(multi_run_id)
`)
db.run(`
CREATE INDEX IF NOT EXISTS idx_multi_runs_repo
ON multi_runs(repo_id, created_at DESC)
`)
}

export function createMultiRunWithEntries(
db: Database,
group: CreateMultiRunGroup,
models: string[],
): MultiRunRecord {
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)
const multiRunId = Number(result.lastInsertRowid)

const insertEntry = db.prepare(`
INSERT INTO multi_run_entries(multi_run_id, model, status, session_id, directory, error, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?)
`)
for (const model of models) {
insertEntry.run(multiRunId, model, 'starting', null, null, null, now, now)
}

return multiRunId
})

const record = getMultiRun(db, create())
if (!record) {
throw new Error('Failed to create multi-run')
}
return record
}

export function getMultiRun(db: Database, id: number): MultiRunRecord | null {
const row = db
.prepare(`SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs WHERE id = ?`)
.get(id) as MultiRunRow | undefined
if (!row) {
return null
}
return toMultiRunRecord(row, loadEntries(db, row.id))
}

export function listMultiRuns(db: Database, repoId: number, limit: number): MultiRunRecord[] {
const rows = db
.prepare(`
SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs
WHERE repo_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
`)
.all(repoId, limit) as MultiRunRow[]
if (rows.length === 0) {
return []
}

const placeholders = rows.map(() => '?').join(', ')
const entryRows = db
.prepare(`
SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries
WHERE multi_run_id IN (${placeholders})
ORDER BY multi_run_id ASC, id ASC
`)
.all(...rows.map((row) => row.id)) as MultiRunEntryRow[]

const entriesByRun = new Map<number, MultiRunEntryRow[]>()
for (const row of rows) {
entriesByRun.set(row.id, [])
}
for (const entryRow of entryRows) {
entriesByRun.get(entryRow.multi_run_id)?.push(entryRow)
}

return rows.map((row) => toMultiRunRecord(row, entriesByRun.get(row.id) ?? []))
}

export function getMultiRunEntry(db: Database, multiRunId: number, entryId: number): MultiRunEntryRecord | null {
const row = db
.prepare(`SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries WHERE id = ? AND multi_run_id = ?`)
.get(entryId, multiRunId) as MultiRunEntryRow | undefined
if (!row) {
return null
}

const group = db.prepare('SELECT isolated FROM multi_runs WHERE id = ?').get(multiRunId) as
| { isolated: number }
| undefined
return mapEntryRow(row, group?.isolated === 1)
}

export function updateMultiRunEntry(
db: Database,
entryId: number,
fromStatuses: MultiRunEntryStatus[],
patch: MultiRunEntryPatch,
): MultiRunEntryRecord | null {
const assignments: string[] = ['updated_at = ?']
const values: (string | number | null)[] = [Date.now()]

if (patch.status !== undefined) {
assignments.push('status = ?')
values.push(patch.status)
}
if (patch.sessionId !== undefined) {
assignments.push('session_id = ?')
values.push(patch.sessionId)
}
if (patch.directory !== undefined) {
assignments.push('directory = ?')
values.push(patch.directory)
}
if (patch.error !== undefined) {
assignments.push('error = ?')
values.push(patch.error)
}

const placeholders = fromStatuses.map(() => '?').join(', ')
const result = db
.prepare(`UPDATE multi_run_entries SET ${assignments.join(', ')} WHERE id = ? AND status IN (${placeholders})`)
.run(...values, entryId, ...fromStatuses)

if (result.changes === 0) {
return null
}

const row = db.prepare('SELECT multi_run_id FROM multi_run_entries WHERE id = ?').get(entryId) as
| { multi_run_id: number }
| undefined
if (!row) {
return null
}
return getMultiRunEntry(db, row.multi_run_id, entryId)
}

function loadEntries(db: Database, multiRunId: number): MultiRunEntryRow[] {
return db
.prepare(`SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries WHERE multi_run_id = ? ORDER BY id ASC`)
.all(multiRunId) as MultiRunEntryRow[]
}

function toMultiRunRecord(row: MultiRunRow, entries: MultiRunEntryRow[]): MultiRunRecord {
return {
id: row.id,
repoId: row.repo_id,
name: row.name,
prompt: row.prompt,
isolated: row.isolated === 1,
baseRef: row.base_ref,
createdAt: row.created_at,
entries: entries.map((entry) => mapEntryRow(entry, row.isolated === 1)),
}
}

function mapEntryRow(row: MultiRunEntryRow, isolated: boolean): MultiRunEntryRecord {
return {
id: row.id,
model: row.model,
status: row.status,
sessionId: row.session_id,
directory: row.directory,
isolated,
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
14 changes: 9 additions & 5 deletions backend/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,13 @@ export function deleteRepo(db: Database, id: number): void {
return
}

for (const table of TABLES_WITH_REPO_ID) {
db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id)
}
const stmt = db.prepare('DELETE FROM repos WHERE id = ?')
stmt.run(id)
const remove = db.transaction(() => {
db.prepare('DELETE FROM multi_run_entries WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)').run(id)
db.prepare('DELETE FROM multi_runs WHERE repo_id = ?').run(id)
for (const table of TABLES_WITH_REPO_ID) {
db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id)
}
db.prepare('DELETE FROM repos WHERE id = ?').run(id)
})
remove()
}
Loading
Loading