Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions backend/src/db/migrations/202610251900-schedule-sandbox-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Migration } from '../migration-runner'

interface ColumnInfo {
name: string
}

const migration: Migration = {
id: '202610251900-schedule-sandbox-config',

up(db) {
const columns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
if (!columns.some((column) => column.name === 'sandbox_config')) {
db.run('ALTER TABLE schedule_jobs ADD COLUMN sandbox_config TEXT')
}
},

down(db) {
db.run('ALTER TABLE schedule_jobs DROP COLUMN sandbox_config')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import migration202610181400 from './202610181400-secrets'
import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-scope-index'
import migration202610221800 from './202610221800-session-device-name'
import migration202610231200 from './202610231200-push-subscription-session-id'
import migration202610251900 from './202610251900-schedule-sandbox-config'

export const allMigrations: Migration[] = [
migration001,
Expand Down Expand Up @@ -85,4 +86,5 @@ export const allMigrations: Migration[] = [
secretsGlobalScopeIndexMigration,
migration202610221800,
migration202610231200,
migration202610251900,
]
28 changes: 26 additions & 2 deletions backend/src/db/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ScheduleMcpServersSchema,
SchedulePermissionConfigSchema,
ScheduleRunSchema,
ScheduleSandboxConfigSchema,
ScheduleSkillMetadataSchema,
ScheduleWorkspaceModeSchema,
type ScheduleJob,
Expand All @@ -14,6 +15,7 @@ import {
type ScheduleRun,
type ScheduleRunStatus,
type ScheduleRunTriggerSource,
type ScheduleSandboxConfig,
type ScheduleWorkspaceMode,
} from '@opencode-manager/shared/schemas'
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_NAME, ASSISTANT_REPO_PATH, getRepoDisplayName } from '@opencode-manager/shared/utils'
Expand All @@ -39,6 +41,7 @@ interface ScheduleJobRow {
mcp_servers: string | null
branch: string | null
workspace_mode: ScheduleWorkspaceMode | null
sandbox_config: string | null
created_at: number
updated_at: number
last_run_at: number | null
Expand Down Expand Up @@ -107,6 +110,19 @@ function parseMcpServers(raw: string | null): ScheduleMcpServer[] {
}
}

function parseSandboxConfig(raw: string | null): ScheduleSandboxConfig | null {
if (!raw) {
return null
}

try {
const result = ScheduleSandboxConfigSchema.safeParse(JSON.parse(raw))
return result.success ? result.data : null
} catch {
return null
}
}

function normalizeWorkspaceMode(raw: string | null): ScheduleWorkspaceMode {
const result = ScheduleWorkspaceModeSchema.safeParse(raw)
return result.success ? result.data : 'worktree'
Expand All @@ -133,6 +149,7 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob {
mcpServers: parseMcpServers(row.mcp_servers),
branch: row.branch,
workspaceMode: normalizeWorkspaceMode(row.workspace_mode),
sandbox: parseSandboxConfig(row.sandbox_config),
createdAt: row.created_at,
updatedAt: row.updated_at,
lastRunAt: row.last_run_at,
Expand Down Expand Up @@ -183,6 +200,10 @@ function serializeMcpServers(mcpServers: ScheduleJobPersistenceInput['mcpServers
return mcpServers.length > 0 ? JSON.stringify(mcpServers) : null
}

function serializeSandboxConfig(sandbox: ScheduleJobPersistenceInput['sandbox']): string | null {
return sandbox ? JSON.stringify(sandbox) : null
}

export function listScheduleJobsByRepo(db: Database, repoId: number): ScheduleJob[] {
const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC')
const rows = stmt.all(repoId) as ScheduleJobRow[]
Expand Down Expand Up @@ -227,9 +248,10 @@ export function createScheduleJob(
mcp_servers,
branch,
workspace_mode,
sandbox_config,
created_at, updated_at, last_run_at, next_run_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

const result = stmt.run(
Expand All @@ -251,6 +273,7 @@ export function createScheduleJob(
serializeMcpServers(input.mcpServers),
input.branch,
input.workspaceMode,
serializeSandboxConfig(input.sandbox),
now,
now,
null,
Expand All @@ -275,7 +298,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu
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 = ?, directory = ?, updated_at = ?, next_run_at = ?
agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, sandbox_config = ?, directory = ?, updated_at = ?, next_run_at = ?
WHERE repo_id IS ? AND id = ?
`)

Expand All @@ -295,6 +318,7 @@ export function updateScheduleJob(db: Database, repoId: number | null, jobId: nu
serializeMcpServers(input.mcpServers),
input.branch,
input.workspaceMode,
serializeSandboxConfig(input.sandbox),
input.directory ?? null,
now,
input.nextRunAt,
Expand Down
18 changes: 11 additions & 7 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const gitService = createGitService(gitAuthService)
const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService)
const secretsService = new SecretsService(db)
const repoEnvService = new RepoEnvService(db, projectConfigService, secretsService)
const sandboxRuntimeService = new SandboxRuntimeService(db, repoEnvService)
let openCodeSupervisor: OpenCodeSupervisor | undefined
async function ensureOpenCodeConfigFileExists(): Promise<void> {
await withOpenCodeConfigLock(foldLegacyConfigJsonSource)
Expand Down Expand Up @@ -248,7 +249,7 @@ try {

opencodeServerManager.setDatabase(db)
detectSandboxCapability()
void new SandboxRuntimeService(db, repoEnvService).prepareSandboxOnBoot().catch((error) => {
void sandboxRuntimeService.prepareSandboxOnBoot().catch((error) => {
logger.warn('Sandbox preparation failed:', error)
})
const openCodeStatus = await openCodeSupervisor.start()
Expand Down Expand Up @@ -279,8 +280,16 @@ const resolveServerTerminalService = (serverId: string): TerminalService => {
includeLocalEnv: false,
})
}
const sessionSettingsService = new SettingsService(db)
const sessionAncestry = new SessionAncestry((serverId) => opencodeServerRegistry.getClient(serverId))
const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, {
removeSessionSandbox: (rootSessionId) => sandboxRuntimeService.removeSessionSandbox(rootSessionId),
})
const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db)
const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry)
const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry, {
sessions: sessionSandboxService,
runtime: sandboxRuntimeService,
})
scheduleService.setWorktreeRemovedHandler(async (directory) => {
try {
await terminalService.removeAll(directory)
Expand All @@ -292,12 +301,7 @@ const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthServi
const scheduleRunnerInstance = new ScheduleRunner(scheduleService)

const notificationService = new NotificationService(db, opencodeServerRegistry)
const sessionSettingsService = new SettingsService(db)
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, opencodeServerRegistry, sessionSettingsService, {
onOutcome: (goal) => {
void notificationService.notifyGoalOutcome(goal).catch((error) => {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ Body matches \`CreateScheduleJobRequest\` schema (discriminated union with \`sch

Optional \`workspaceMode\` sets where runs execute: \`worktree\` (default, a fresh worktree removed after each run), \`kept-worktree\` (a fresh worktree kept after each run), or \`shared-worktree\` (one worktree and branch reused by every run). Kept and shared worktrees are listed with \`GET /repos/:repoId/schedules/:jobId/worktrees\`. Removing schedule worktrees is done by the user in the Manager UI.

Optional \`sandbox: { enabled, resources: { cpus, memoryMib } | null }\` runs the scheduled session and its subagents in a dedicated sandbox microVM with Docker available. It works only for schedules on the local OpenCode server, requires server sandboxing to be enabled in settings, and the VM and its Docker volume are removed after each run. The run fails if the VM cannot start.

\`\`\`json
{
"action": "request",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/services/schedule-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ScheduleMcpServer,
ScheduleMode,
SchedulePermissionConfig,
ScheduleSandboxConfig,
ScheduleSkillMetadata,
ScheduleWorkspaceMode,
UpdateScheduleJobRequest,
Expand All @@ -28,6 +29,7 @@ export interface ScheduleJobPersistenceInput {
mcpServers: ScheduleMcpServer[]
branch: string | null
workspaceMode: ScheduleWorkspaceMode
sandbox: ScheduleSandboxConfig | null
directory?: string | null
nextRunAt: number | null
}
Expand Down Expand Up @@ -105,6 +107,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ
skillMetadata: input.skillMetadata,
branch: input.branch?.trim() || null,
workspaceMode: input.workspaceMode ?? 'worktree',
sandbox: input.sandbox ?? null,
...(input.directory !== undefined ? { directory: input.directory?.trim() || null } : {}),
}

Expand Down Expand Up @@ -168,6 +171,7 @@ export function buildUpdatedSchedulePersistenceInput(
mcpServers: input.mcpServers ?? existing.mcpServers,
branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null),
workspaceMode: input.workspaceMode ?? existing.workspaceMode,
sandbox: input.sandbox !== undefined ? input.sandbox : existing.sandbox,
...(input.directory !== undefined || existing.directory !== undefined
? { directory: input.directory === undefined ? (existing.directory ?? null) : (input.directory?.trim() || null) }
: {}),
Expand Down
Loading