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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ 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
# Upgrade strategy: 'pull' fetches OCM_IMAGE from the registry; 'build'
# rebuilds the image from the compose project source directory as-is
# (update the source on the host first). Use 'build' for source-built deployments.
# OCM_UPGRADE_STRATEGY=pull

# 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 \
&& rm -rf /var/lib/apt/lists/*

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

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

export type { ManagerUpgradeJob, ManagerUpgradeJobStatus }

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 ManagerUpgradeJobStatus,
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: ManagerUpgradeJobStatus
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: ManagerUpgradeJobStatus
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/019-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: 19,
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 @@ -17,6 +17,7 @@ import migration015 from './015-schedule-worktree-isolation'
import migration016 from './016-schedule-permission-config'
import migration017 from './017-schedule-run-workspace-id'
import migration018 from './018-session-pins'
import migration019 from './019-manager-upgrade-jobs'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -37,4 +38,5 @@ export const allMigrations: Migration[] = [
migration016,
migration017,
migration018,
migration019,
]
38 changes: 29 additions & 9 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ 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 {
const packageUrl = new URL('../../package.json', import.meta.url)
const packageJsonRaw = await readFile(packageUrl, 'utf-8')
const packageJson = JSON.parse(packageJsonRaw) as { version?: string }
return packageJson.version ?? 'unknown'
} catch {
return 'unknown'
}
let cachedAppVersion: Promise<string> | null = null

function getAppVersion(): Promise<string> {
cachedAppVersion ??= (async () => {
try {
const packageUrl = new URL('../../package.json', import.meta.url)
const packageJsonRaw = await readFile(packageUrl, 'utf-8')
const packageJson = JSON.parse(packageJsonRaw) as { version?: string }
return packageJson.version ?? 'unknown'
} catch {
return 'unknown'
}
})()
return cachedAppVersion
}
import { createProvidersRoutes } from './routes/providers'
import { createOAuthRoutes } from './routes/oauth'
Expand Down Expand Up @@ -51,6 +57,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 { OpenCodeRestartCoordinator } from './services/opencode-restart-coordinator'
import { setOpenCodeRestartCoordinator } from './services/opencode-restart'
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
Expand Down Expand Up @@ -334,6 +342,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',
strategy: process.env.OCM_UPGRADE_STRATEGY === 'build' ? 'build' as const : 'pull' as const,
}),
})

app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))
Expand All @@ -358,6 +377,7 @@ protectedApi.route('/notifications', createNotificationRoutes(notificationServic
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/session-pins', createSessionPinRoutes(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