From b28e39b383726965391ccf08de1f21ed37300e17 Mon Sep 17 00:00:00 2001 From: Ross Douglas Date: Thu, 26 Feb 2026 09:00:35 +0200 Subject: [PATCH] feat: add remount command to recreate containers preserving writable layer Adds a `remount` action that commits the container's current state into its image, removes the old container, then creates a fresh one from the updated image. The new container picks up any new mounts, env vars, or network config from createContainer() while preserving installed packages and configs from the writable layer. Useful when createContainer() gains new volume mounts (like /board) and existing creatures need to pick them up without losing their self-installed tools (supervisord, python packages, etc). - supervisor: remount() method (docker commit + rm + spawnCreature) - orchestrator: POST /api/creatures/:name/remount endpoint - dashboard: remount button with confirmation dialog Made-with: Cursor --- dashboard/src/api.ts | 2 +- dashboard/src/components/CreatureDetail.tsx | 29 +++++++++++++++------ src/host/index.ts | 21 +++++++++++++++ src/host/supervisor.ts | 28 ++++++++++++++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 6e02bf0..ac072f2 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -72,7 +72,7 @@ export async function fetchGenomes(): Promise { return res.json(); } -export async function creatureAction(name: string, action: 'start' | 'stop' | 'restart' | 'rebuild' | 'wake' | 'archive', method = 'POST'): Promise { +export async function creatureAction(name: string, action: 'start' | 'stop' | 'restart' | 'rebuild' | 'remount' | 'wake' | 'archive', method = 'POST'): Promise { await fetch(`/api/creatures/${name}/${action}`, { method }); } diff --git a/dashboard/src/components/CreatureDetail.tsx b/dashboard/src/components/CreatureDetail.tsx index c7432e9..8f3de75 100644 --- a/dashboard/src/components/CreatureDetail.tsx +++ b/dashboard/src/components/CreatureDetail.tsx @@ -46,6 +46,18 @@ export function CreatureDetail() { const refresh = useStore(s => s.refresh); const degraded = useStore(s => s.health.status !== 'healthy'); const eventsEndRef = useRef(null); + const [busy, setBusy] = useState(null); + + const doAction = async (action: string, needsConfirm?: string) => { + if (needsConfirm && !confirm(needsConfirm)) return; + setBusy(action); + try { + await api.creatureAction(name, action as any); + refresh(); + } finally { + setBusy(null); + } + }; useEffect(() => { if (tab === 'log') { @@ -71,14 +83,15 @@ export function CreatureDetail() { {c?.janeeVersion && <> ยท janee {c.janeeVersion}} - - - - + + + + + {/* Tab bar */} diff --git a/src/host/index.ts b/src/host/index.ts index 1774214..369a65a 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -320,6 +320,12 @@ export class Orchestrator { await supervisor.restart(); } + async remountCreature(name: string): Promise { + const supervisor = this.supervisors.get(name); + if (!supervisor) throw new Error(`creature "${name}" is not running`); + await supervisor.remount(); + } + async spawnCreature(name: string, _dir: string, purpose?: string, genome = 'dreamer', model?: string): Promise { console.log(`[orchestrator] spawning "${name}"...`); const result = await spawnCreature({ name, purpose, genome, model }); @@ -800,6 +806,21 @@ export class Orchestrator { return; } + if (action === 'remount' && req.method === 'POST') { + const health = getStatus(); + if (health.status !== 'healthy') { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Orchestrator degraded โ€” cannot remount creatures', dependencies: health.dependencies })); + return; + } + try { + console.log(`[${name}] developer-initiated remount`); + await this.remountCreature(name); + res.writeHead(200); res.end('ok'); + } catch (err: any) { res.writeHead(400); res.end(err.message); } + return; + } + if (action === 'wake' && req.method === 'POST') { try { const supervisor = this.supervisors.get(name); diff --git a/src/host/supervisor.ts b/src/host/supervisor.ts index 781798a..78b332d 100644 --- a/src/host/supervisor.ts +++ b/src/host/supervisor.ts @@ -114,6 +114,34 @@ export class CreatureSupervisor { this.status = 'starting'; await this.spawnCreature(); } + // Recreate container preserving its writable layer (installed packages, + // configs, etc.). Commits the container state into the image, removes the old + // container, then creates a fresh one from the updated image โ€” picking up any + // new mounts, env vars, or network config. Running processes stop but their + // installed artifacts survive. + async remount(): Promise { + this.expectingExit = true; + this.clearTimers(); + this.healthyAt = null; + const cname = this.containerName(); + + console.log(`[${this.name}] remounting: committing container state to image`); + try { execSync(`docker stop ${cname}`, { stdio: 'ignore', timeout: 15_000 }); } catch {} + try { + execSync(`docker commit ${cname} ${cname}`, { stdio: 'ignore', timeout: 60_000 }); + } catch (err) { + console.error(`[${this.name}] remount: commit failed โ€” container preserved, aborting`, err); + this.expectingExit = false; + throw err; + } + try { execSync(`docker rm -f ${cname}`, { stdio: 'ignore' }); } catch {} + + this.creature = null; + this.currentSHA = getCurrentSHA(this.dir); + this.status = 'starting'; + await this.spawnCreature(); + } + // Full rebuild: destroys container (writable layer lost). Developer-only. async rebuild(): Promise {