From 6439e5200b307be65bc66fc5372b435191f096d1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 11:54:32 -0400 Subject: [PATCH] feat(worker): per-user box status and lifecycle helpers Centralize per-user box home-path derivation, status inspection, and lifecycle (start/stop/restart) in a new box.ts so the HTTP routes and the terminal WS bridge share one source of truth. Add inspectUserBoxStatus to docker.ts (single `inspect --format` call, all-null/false on non-zero exit) and resetBox to user-container.ts (clears the idle timer and drops the registry entry so a stopped box isn't treated as held). Manual verification (docker required, after Task 4 wires the routes): with the worker running and a box up, `curl -H "Authorization: Bearer $TOKEN" localhost:3921/box/status?user=` shows running:true + image + startedAt; `POST /box/lifecycle {"action":"stop"}` flips status to running:false; `start` brings it back. --- apps/agent-worker/src/box.ts | 45 +++++++++++++++++++ apps/agent-worker/src/runtime/docker.ts | 36 +++++++++++++++ apps/agent-worker/src/user-container.ts | 9 ++++ .../agent-worker/tests/unit/box-paths.test.ts | 29 ++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 apps/agent-worker/src/box.ts create mode 100644 apps/agent-worker/tests/unit/box-paths.test.ts diff --git a/apps/agent-worker/src/box.ts b/apps/agent-worker/src/box.ts new file mode 100644 index 0000000..69bf9b8 --- /dev/null +++ b/apps/agent-worker/src/box.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; + +import { env } from './env'; +import { + ensureUserContainer, + inspectUserBoxStatus, + stopWorkspaceContainer, + userContainerName, +} from './runtime/docker'; +import { resetBox } from './user-container'; + +// Per-user box home layout, shared by the HTTP routes and the terminal WS bridge +// so path derivation lives in exactly one place (must match worker.ts). +export const boxHomePaths = (username: string) => ({ + homeDir: path.resolve(env.workdir, 'homes', username), + containerHome: path.posix.join('/home', username), +}); + +export type BoxStatus = { + running: boolean; + image: string | null; + startedAt: string | null; + memoryLimitBytes: number | null; + containerName: string; +}; + +export const getBoxStatus = async (username: string): Promise => { + const status = await inspectUserBoxStatus(username); + return { ...status, containerName: userContainerName(username) }; +}; + +export const startBox = async (username: string): Promise => { + const { homeDir, containerHome } = boxHomePaths(username); + await ensureUserContainer({ username, workdir: homeDir, containerHome }); +}; + +export const stopBox = async (username: string): Promise => { + await stopWorkspaceContainer(userContainerName(username)); + resetBox(username); +}; + +export const restartBox = async (username: string): Promise => { + await stopBox(username); + await startBox(username); +}; diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index 617c5d4..7421d40 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -214,6 +214,42 @@ export const ensureUserContainer = async (args: { return name; }; +// Inspect the per-user box in one shot. Non-zero exit (no such container) yields +// an all-null/false status. `--memory 0` means unlimited, which the Go template +// prints as `0`; we normalize that (and any unparseable value) to null. +export const inspectUserBoxStatus = async ( + username: string, +): Promise<{ + running: boolean; + image: string | null; + startedAt: string | null; + memoryLimitBytes: number | null; +}> => { + const result = await execa( + containerRuntime(), + [ + 'inspect', + '--format', + '{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}', + userContainerName(username), + ], + { reject: false, stdin: 'ignore' }, + ); + if (result.exitCode !== 0) { + return { running: false, image: null, startedAt: null, memoryLimitBytes: null }; + } + const [runningField, imageField, startedAtField, memoryField] = result.stdout + .trim() + .split('|'); + const running = runningField === 'true'; + return { + running, + image: imageField || null, + startedAt: running ? startedAtField || null : null, + memoryLimitBytes: Number(memoryField) || null, + }; +}; + const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; // The marker is embedded literally into a `# comment` line and into a `pgrep` diff --git a/apps/agent-worker/src/user-container.ts b/apps/agent-worker/src/user-container.ts index 97c5763..f5ac6d5 100644 --- a/apps/agent-worker/src/user-container.ts +++ b/apps/agent-worker/src/user-container.ts @@ -117,6 +117,15 @@ export const reconcileExistingBoxes = async (): Promise => { export const runningBoxUsernames = (): Set => new Set(boxes.keys()); +// Drop a box from the in-memory registry (used after an explicit stop so a +// stopped box isn't treated as "held" by the idle reaper or reconcile logic). +export const resetBox = (username: string): void => { + const box = boxes.get(username); + if (!box) return; + if (box.idleTimer) clearTimeout(box.idleTimer); + boxes.delete(username); +}; + export const _resetBoxRegistryForTests = () => { for (const box of boxes.values()) { if (box.idleTimer) clearTimeout(box.idleTimer); diff --git a/apps/agent-worker/tests/unit/box-paths.test.ts b/apps/agent-worker/tests/unit/box-paths.test.ts new file mode 100644 index 0000000..ed524f6 --- /dev/null +++ b/apps/agent-worker/tests/unit/box-paths.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const load = async () => { + vi.resetModules(); + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; + process.env.GITHUB_APP_ID = '123'; + process.env.GITHUB_APP_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----'; + process.env.SPOON_AGENT_WORKDIR = '/work'; + return await import('../../src/box'); +}; + +describe('boxHomePaths', () => { + afterEach(() => vi.resetModules()); + + test('derives the per-user home dir and container home', async () => { + const { boxHomePaths } = await load(); + const paths = boxHomePaths('gib'); + expect(paths.homeDir.endsWith('homes/gib')).toBe(true); + expect(paths.containerHome).toBe('/home/gib'); + }); + + test('distinct usernames produce distinct home dirs', async () => { + const { boxHomePaths } = await load(); + expect(boxHomePaths('gib').homeDir).not.toBe( + boxHomePaths('other').homeDir, + ); + }); +});