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 {