Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ DATABASE_PATH=./data/opencode.db
# ============================================
WORKSPACE_PATH=./workspace

# ============================================
# Manager Self-Upgrade (Docker)
# ============================================
# The container image tag to pull for self-upgrade.
# OCM_IMAGE=ghcr.io/chriswritescode-dev/opencode-manager:latest
# Enable or disable the self-upgrade mechanism.
# OCM_MANAGER_UPGRADE_ENABLED=true

# Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md
# OCM_REPOS_HOST_PATH=/Users/you/Development
# OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | d
&& apt-get update && apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://download.docker.com/linux/debian/gpg | dd of=/usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update && apt-get install -y docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*

RUN corepack enable && corepack prepare pnpm@latest --activate

RUN curl -fsSL https://bun.sh/install | bash && \
Expand Down
117 changes: 117 additions & 0 deletions backend/src/db/manager-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { Database } from 'bun:sqlite'

export type ManagerUpgradeStatus = 'pending' | 'pulling' | 'recreating' | 'completed' | 'failed'

export interface ManagerUpgradeJob {
id: number
status: ManagerUpgradeStatus
fromVersion: string | null
toVersion: string | null
targetImage: string | null
error: string | null
startedAt: number
finishedAt: number | null
}

interface ManagerUpgradeJobRow {
id: number
status: string
from_version: string | null
to_version: string | null
target_image: string | null
error: string | null
started_at: number
finished_at: number | null
}

function rowToJob(row: ManagerUpgradeJobRow): ManagerUpgradeJob {
return {
id: row.id,
status: row.status as ManagerUpgradeStatus,
fromVersion: row.from_version,
toVersion: row.to_version,
targetImage: row.target_image,
error: row.error,
startedAt: row.started_at,
finishedAt: row.finished_at,
}
}

export function insertUpgradeJob(
db: Database,
data: {
status: ManagerUpgradeStatus
fromVersion?: string
toVersion?: string
targetImage?: string
startedAt: number
},
): ManagerUpgradeJob {
const stmt = db.prepare(`
INSERT INTO manager_upgrade_jobs (status, from_version, to_version, target_image, started_at)
VALUES (?, ?, ?, ?, ?)
`)

const result = stmt.run(
data.status,
data.fromVersion ?? null,
data.toVersion ?? null,
data.targetImage ?? null,
data.startedAt,
)

const row = db.prepare('SELECT * FROM manager_upgrade_jobs WHERE id = ?')
.get(Number(result.lastInsertRowid)) as ManagerUpgradeJobRow | undefined

if (!row) {
throw new Error('Failed to retrieve newly created upgrade job')
}

return rowToJob(row)
}

export function updateUpgradeJob(
db: Database,
id: number,
patch: Partial<{
status: ManagerUpgradeStatus
error: string | null
finishedAt: number | null
}>,
): void {
const sets: string[] = []
const values: unknown[] = []

if (patch.status !== undefined) {
sets.push('status = ?')
values.push(patch.status)
}
if (patch.error !== undefined) {
sets.push('error = ?')
values.push(patch.error)
}
if (patch.finishedAt !== undefined) {
sets.push('finished_at = ?')
values.push(patch.finishedAt)
}

if (sets.length === 0) return

values.push(id)
db.prepare(`UPDATE manager_upgrade_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values as never)
}

export function getLatestUpgradeJob(db: Database): ManagerUpgradeJob | null {
const row = db.prepare('SELECT * FROM manager_upgrade_jobs ORDER BY id DESC LIMIT 1')
.get() as ManagerUpgradeJobRow | undefined

return row ? rowToJob(row) : null
}

export function getActiveUpgradeJob(db: Database): ManagerUpgradeJob | null {
const row = db.prepare(
"SELECT * FROM manager_upgrade_jobs WHERE status IN ('pending', 'pulling', 'recreating') ORDER BY id DESC LIMIT 1",
).get() as ManagerUpgradeJobRow | undefined

return row ? rowToJob(row) : null
}
27 changes: 27 additions & 0 deletions backend/src/db/migrations/016-manager-upgrade-jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 16,
name: 'manager-upgrade-jobs',

up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS manager_upgrade_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
from_version TEXT,
to_version TEXT,
target_image TEXT,
error TEXT,
started_at INTEGER NOT NULL,
finished_at INTEGER
)
`)
},

down(db) {
db.run('DROP TABLE IF EXISTS manager_upgrade_jobs')
},
}

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 @@ -14,6 +14,7 @@ import migration012 from './012-opencode-model-state'
import migration013 from './013-app-secrets'
import migration014 from './014-repos-add-name'
import migration015 from './015-schedule-worktree-isolation'
import migration016 from './016-manager-upgrade-jobs'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -31,4 +32,5 @@ export const allMigrations: Migration[] = [
migration013,
migration014,
migration015,
migration016,
]
15 changes: 15 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createTTSRoutes, cleanupExpiredCache } from './routes/tts';
import { createSTTRoutes } from './routes/stt'
import { createFileRoutes } from './routes/files'
import { createScheduleRoutes } from './routes/schedules'
import { createManagerUpgradeRoutes } from './routes/manager-upgrade'

async function getAppVersion(): Promise<string> {
try {
Expand Down Expand Up @@ -50,6 +51,8 @@ import { migrateGlobalSkills } from './services/skills'
import { installAssistantWorkspace } from './services/assistant-mode'
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'
import { OpenCodeSupervisor } from './services/opencode-supervisor'
import { ManagerUpgradeService, createDockerRunner } from './services/manager-upgrade'
import { isRunningInDocker, isDockerSocketAvailable } from './utils/runtime-env'
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
import { parse as parseJsonc } from 'jsonc-parser'
import { getModelStatePath, ModelStateSchema } from './routes/providers'
Expand Down Expand Up @@ -324,6 +327,17 @@ void scheduleRunnerInstance.start()

const settingsService = new SettingsService(db)

const managerUpgradeService = new ManagerUpgradeService(db, {
runner: createDockerRunner(),
getCurrentVersion: () => getAppVersion(),
capability: () => ({
inDocker: isRunningInDocker(),
socket: isDockerSocketAvailable(),
enabled: process.env.OCM_MANAGER_UPGRADE_ENABLED !== 'false',
}),
})
managerUpgradeService.reconcile()

app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))
Expand All @@ -347,6 +361,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
protectedApi.route('/manager-upgrade', createManagerUpgradeRoutes(managerUpgradeService))

app.route('/api', protectedApi)

Expand Down
31 changes: 31 additions & 0 deletions backend/src/routes/manager-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { ManagerUpgradeService, ManagerUpgradeError } from '../services/manager-upgrade'
import { handleServiceError } from '../utils/route-helpers'

export function createManagerUpgradeRoutes(service: ManagerUpgradeService) {
const app = new Hono()

app.get('/status', async (c) => {
try {
const status = await service.getStatus()
return c.json(status)
} catch (error) {
return handleServiceError(c, error, 'Failed to get manager upgrade status', ManagerUpgradeError)
}
})

app.post('/', async (c) => {
try {
const bodyText = await c.req.text()
const raw = bodyText.trim() === '' ? {} : JSON.parse(bodyText)
const { version } = z.object({ version: z.string().min(1).optional() }).parse(raw)
const job = await service.startUpgrade(version)
return c.json({ job }, 202)
} catch (error) {
return handleServiceError(c, error, 'Manager upgrade failed', ManagerUpgradeError)
}
})

return app
}
Loading