diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts new file mode 100644 index 0000000..b046c3c --- /dev/null +++ b/src/backfill-container-configs.ts @@ -0,0 +1,77 @@ +/** + * One-time backfill: seed `container_configs` rows from existing + * `groups//container.json` files and `agent_groups.agent_provider`. + * + * Runs after migrations, before channel adapters start. Idempotent — skips + * groups that already have a config row. + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from './config.js'; +import type { McpServerConfig, AdditionalMountConfig } from './container-config.js'; +import { getAllAgentGroups } from './db/agent-groups.js'; +import { getContainerConfig, createContainerConfig } from './db/container-configs.js'; +import { log } from './log.js'; +import type { ContainerConfigRow } from './types.js'; + +interface LegacyContainerJson { + mcpServers?: Record; + packages?: { apt?: string[]; npm?: string[] }; + imageTag?: string; + additionalMounts?: AdditionalMountConfig[]; + skills?: string[] | 'all'; + provider?: string; + assistantName?: string; + maxMessagesPerPrompt?: number; +} + +export function backfillContainerConfigs(): void { + const groups = getAllAgentGroups(); + let backfilled = 0; + + for (const group of groups) { + // Skip if already has a config row + if (getContainerConfig(group.id)) continue; + + // Read legacy container.json from disk + const filePath = path.join(GROUPS_DIR, group.folder, 'container.json'); + let legacy: LegacyContainerJson = {}; + if (fs.existsSync(filePath)) { + try { + legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson; + } catch (err) { + log.warn('Backfill: failed to parse container.json, using defaults', { + folder: group.folder, + err: String(err), + }); + } + } + + // DB agent_provider wins over file provider (matches old cascade) + const provider = group.agent_provider || legacy.provider || null; + + const row: ContainerConfigRow = { + agent_group_id: group.id, + provider, + model: null, + effort: null, + image_tag: legacy.imageTag ?? null, + assistant_name: legacy.assistantName ?? null, + max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null, + skills: JSON.stringify(legacy.skills ?? 'all'), + mcp_servers: JSON.stringify(legacy.mcpServers ?? {}), + packages_apt: JSON.stringify(legacy.packages?.apt ?? []), + packages_npm: JSON.stringify(legacy.packages?.npm ?? []), + additional_mounts: JSON.stringify(legacy.additionalMounts ?? []), + updated_at: new Date().toISOString(), + }; + + createContainerConfig(row); + backfilled++; + } + + if (backfilled > 0) { + log.info('Backfilled container_configs from disk', { count: backfilled }); + } +} diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index c0519e4..64ad799 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -18,7 +18,8 @@ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; -import { readContainerConfig } from './container-config.js'; +import type { McpServerConfig } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void { } // Desired fragment set. - const config = readContainerConfig(group.folder); + const configRow = getContainerConfig(group.id); + const mcpServers: Record = configRow + ? (JSON.parse(configRow.mcp_servers) as Record) + : {}; const desired = new Map(); // Skill fragments — every skill that ships an `instructions.md`. @@ -91,7 +95,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // MCP server fragments — inline instructions from container.json for // user-added external MCP servers. - for (const [name, mcp] of Object.entries(config.mcpServers)) { + for (const [name, mcp] of Object.entries(mcpServers)) { if (mcp.instructions) { desired.set(`mcp-${name}.md`, { type: 'inline', diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index e334fc1..3b721d9 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,5 +1,32 @@ +import type { McpServerConfig } from '../../container-config.js'; +import { restartAgentGroupContainers } from '../../container-restart.js'; +import { + getContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, +} from '../../db/container-configs.js'; +import type { ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; +/** Deserialize JSON columns for display. */ +function presentConfig(row: ContainerConfigRow): Record { + return { + agent_group_id: row.agent_group_id, + provider: row.provider, + model: row.model, + effort: row.effort, + image_tag: row.image_tag, + assistant_name: row.assistant_name, + max_messages_per_prompt: row.max_messages_per_prompt, + skills: JSON.parse(row.skills), + mcp_servers: JSON.parse(row.mcp_servers), + packages_apt: JSON.parse(row.packages_apt), + packages_npm: JSON.parse(row.packages_npm), + additional_mounts: JSON.parse(row.additional_mounts), + updated_at: row.updated_at, + }; +} + registerResource({ name: 'group', plural: 'groups', @@ -26,12 +53,169 @@ registerResource({ { name: 'agent_provider', type: 'string', - description: - 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', - updatable: true, + description: 'Deprecated — use `ncl groups config-update --provider`. Kept for backwards compat.', + updatable: false, default: null, }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, + customOperations: { + 'config-get': { + access: 'open', + description: 'Show the container config for a group. Use --id .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + return presentConfig(row); + }, + }, + 'config-update': { + access: 'approval', + description: + 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + > + > = {}; + if (args.provider !== undefined) updates.provider = args.provider as string; + if (args.model !== undefined) updates.model = args.model as string; + if (args.effort !== undefined) updates.effort = args.effort as string; + if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string; + if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string; + if (args.max_messages_per_prompt !== undefined) + updates.max_messages_per_prompt = Number(args.max_messages_per_prompt); + + if (Object.keys(updates).length === 0) { + throw new Error( + 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt', + ); + } + + updateContainerConfigScalars(id, updates); + restartAgentGroupContainers(id, 'config updated via ncl'); + + const updated = getContainerConfig(id)!; + return presentConfig(updated); + }, + }, + 'config-add-mcp-server': { + access: 'approval', + description: + 'Add an MCP server to a group. Use --id --name --command [--args ] [--env ].', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + const command = args.command as string; + if (!command) throw new Error('--command is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + servers[name] = { + command, + args: args.args ? (JSON.parse(args.args as string) as string[]) : [], + env: args.env ? (JSON.parse(args.env as string) as Record) : {}, + }; + updateContainerConfigJson(id, 'mcp_servers', servers); + restartAgentGroupContainers(id, `mcp server "${name}" added via ncl`); + + return { added: name, servers }; + }, + }, + 'config-remove-mcp-server': { + access: 'approval', + description: 'Remove an MCP server from a group. Use --id --name .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + if (!servers[name]) throw new Error(`MCP server "${name}" not found`); + delete servers[name]; + updateContainerConfigJson(id, 'mcp_servers', servers); + restartAgentGroupContainers(id, `mcp server "${name}" removed via ncl`); + + return { removed: name }; + }, + }, + 'config-add-package': { + access: 'approval', + description: 'Add a package to a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + if (!existing.includes(apt)) { + existing.push(apt); + updateContainerConfigJson(id, 'packages_apt', existing); + } + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + if (!existing.includes(npm)) { + existing.push(npm); + updateContainerConfigJson(id, 'packages_npm', existing); + } + } + + return { added: { apt: apt || null, npm: npm || null } }; + }, + }, + 'config-remove-package': { + access: 'approval', + description: 'Remove a package from a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + const filtered = existing.filter((p) => p !== apt); + updateContainerConfigJson(id, 'packages_apt', filtered); + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + const filtered = existing.filter((p) => p !== npm); + updateContainerConfigJson(id, 'packages_npm', filtered); + } + + return { removed: { apt: apt || null, npm: npm || null } }; + }, + }, + }, }); diff --git a/src/container-config.ts b/src/container-config.ts index d972842..597ca92 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,26 +1,25 @@ /** - * Per-group container config, stored as a plain JSON file at - * `groups//container.json`. Mounted read-only inside the container - * at `/workspace/agent/container.json` — the runner reads it at startup but - * cannot modify it. Config changes go through the self-mod approval flow. + * Container config types and materialization. * - * All fields are optional — a missing file or a partial file both resolve - * to sensible defaults. Writes are atomic-enough (write-then-rename is not - * worth the ceremony here since there's only one writer in practice: the - * host, from the delivery thread that processes approved system actions). + * Source of truth is the `container_configs` table in the central DB. + * This module provides: + * - Type definitions for the file shape (read by the container runner) + * - `materializeContainerJson()` — writes `groups//container.json` + * from the DB at spawn time + * - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group */ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import type { AgentGroup, ContainerConfigRow } from './types.js'; export interface McpServerConfig { command: string; args?: string[]; env?: Record; - // Optional always-in-context guidance. When set, the host writes the - // content to `.claude-fragments/mcp-.md` at spawn and imports it - // into the composed CLAUDE.md. instructions?: string; } @@ -30,101 +29,61 @@ export interface AdditionalMountConfig { readonly?: boolean; } +/** Shape of the materialized `container.json` file read by the container runner. */ export interface ContainerConfig { mcpServers: Record; packages: { apt: string[]; npm: string[] }; imageTag?: string; additionalMounts: AdditionalMountConfig[]; - /** Which skills to enable — array of skill names or "all" (default). */ skills: string[] | 'all'; - /** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */ provider?: string; - /** Agent group display name (used in transcript archiving). */ groupName?: string; - /** Assistant display name (used in system prompt / responses). */ assistantName?: string; - /** Agent group ID — set by the host, read by the runner. */ agentGroupId?: string; - /** Max messages per prompt. Falls back to code default if unset. */ maxMessagesPerPrompt?: number; + model?: string; + effort?: string; } -function emptyConfig(): ContainerConfig { +/** Build a `ContainerConfig` from a DB row + agent group identity. */ +export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig { return { - mcpServers: {}, - packages: { apt: [], npm: [] }, - additionalMounts: [], - skills: 'all', + mcpServers: JSON.parse(row.mcp_servers) as Record, + packages: { + apt: JSON.parse(row.packages_apt) as string[], + npm: JSON.parse(row.packages_npm) as string[], + }, + imageTag: row.image_tag ?? undefined, + additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[], + skills: JSON.parse(row.skills) as string[] | 'all', + provider: row.provider ?? undefined, + groupName: group.name, + assistantName: row.assistant_name ?? group.name, + agentGroupId: group.id, + maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined, + model: row.model ?? undefined, + effort: row.effort ?? undefined, }; } -function configPath(folder: string): string { - return path.join(GROUPS_DIR, folder, 'container.json'); -} - /** - * Read the container config for a group, returning sensible defaults for - * any missing fields (or an entirely empty config if the file is absent). - * Never throws for missing / malformed files — corruption logs a warning - * via console.error and falls back to empty. + * Materialize `container.json` from the DB. Called at spawn time so the + * container always sees fresh config. Returns the `ContainerConfig` for + * use by the caller (buildMounts, buildContainerArgs, etc.). */ -export function readContainerConfig(folder: string): ContainerConfig { - const p = configPath(folder); - if (!fs.existsSync(p)) return emptyConfig(); - try { - const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial; - return { - mcpServers: raw.mcpServers ?? {}, - packages: { - apt: raw.packages?.apt ?? [], - npm: raw.packages?.npm ?? [], - }, - imageTag: raw.imageTag, - additionalMounts: raw.additionalMounts ?? [], - skills: raw.skills ?? 'all', - provider: raw.provider, - groupName: raw.groupName, - assistantName: raw.assistantName, - agentGroupId: raw.agentGroupId, - maxMessagesPerPrompt: raw.maxMessagesPerPrompt, - }; - } catch (err) { - console.error(`[container-config] failed to parse ${p}: ${String(err)}`); - return emptyConfig(); - } -} +export function materializeContainerJson(agentGroupId: string): ContainerConfig { + const group = getAgentGroup(agentGroupId); + if (!group) throw new Error(`Agent group not found: ${agentGroupId}`); -/** - * Write the container config for a group, creating the groups// - * directory if necessary. Pretty-printed JSON so diffs in the activation - * flow are reviewable. - */ -export function writeContainerConfig(folder: string, config: ContainerConfig): void { - const p = configPath(folder); + const row = getContainerConfig(agentGroupId); + if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`); + + const config = configFromDb(row, group); + + const p = path.join(GROUPS_DIR, group.folder, 'container.json'); const dir = path.dirname(p); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n'); -} -/** - * Apply a mutator function to a group's container config and persist the - * result. Convenient for append-style changes like `install_packages` and - * `add_mcp_server` handlers. - */ -export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig { - const config = readContainerConfig(folder); - mutate(config); - writeContainerConfig(folder, config); return config; } - -/** - * Initialize an empty container.json for a group if one doesn't already - * exist. Idempotent — used from `group-init.ts`. - */ -export function initContainerConfig(folder: string): boolean { - const p = configPath(folder); - if (fs.existsSync(p)) return false; - writeContainerConfig(folder, emptyConfig()); - return true; -} diff --git a/src/container-restart.ts b/src/container-restart.ts new file mode 100644 index 0000000..ff6ff51 --- /dev/null +++ b/src/container-restart.ts @@ -0,0 +1,44 @@ +/** + * Helper to restart all running containers for an agent group. + * + * Used by: + * - self-mod approval handlers (after config change) + * - ncl config-update (after CLI config change) + */ +import { killContainer } from './container-runner.js'; +import { getSessionsByAgentGroup } from './db/sessions.js'; +import { log } from './log.js'; +import { writeSessionMessage } from './session-manager.js'; + +/** + * Kill all running containers for an agent group and schedule wake messages + * so the host sweep respawns them with fresh config. + */ +export function restartAgentGroupContainers(agentGroupId: string, reason: string): void { + const sessions = getSessionsByAgentGroup(agentGroupId).filter((s) => s.status === 'active'); + + for (const session of sessions) { + killContainer(session.id, reason); + writeSessionMessage(agentGroupId, session.id, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: agentGroupId, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `Container restarted: ${reason}. Resuming.`, + sender: 'system', + senderId: 'system', + }), + processAfter: new Date(Date.now() + 5000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''), + }); + } + + if (sessions.length > 0) { + log.info('Restarted agent group containers', { agentGroupId, reason, count: sessions.length }); + } +} diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index cd18a72..3c188f9 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest'; import { resolveProviderName } from './container-runner.js'; describe('resolveProviderName', () => { - it('prefers session over group and container.json', () => { - expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + it('prefers session over container config', () => { + expect(resolveProviderName('codex', 'claude')).toBe('codex'); }); - it('falls back to group when session is null', () => { - expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); - }); - - it('falls back to container.json when session and group are null', () => { - expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + it('falls back to container config when session is null', () => { + expect(resolveProviderName(null, 'opencode')).toBe('opencode'); }); it('defaults to claude when nothing is set', () => { - expect(resolveProviderName(null, null, undefined)).toBe('claude'); + expect(resolveProviderName(null, undefined)).toBe('claude'); }); it('lowercases the resolved name', () => { - expect(resolveProviderName('CODEX', null, null)).toBe('codex'); - expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); - expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + expect(resolveProviderName('CODEX', null)).toBe('codex'); + expect(resolveProviderName(null, 'Claude')).toBe('claude'); }); it('treats empty string as unset (falls through)', () => { - expect(resolveProviderName('', 'codex', null)).toBe('codex'); - expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + expect(resolveProviderName('', 'opencode')).toBe('opencode'); + expect(resolveProviderName(null, '')).toBe('claude'); }); }); diff --git a/src/container-runner.ts b/src/container-runner.ts index 27b0f5c..cdf93f2 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -19,7 +19,9 @@ import { ONECLI_URL, TIMEZONE, } from './config.js'; -import { readContainerConfig, writeContainerConfig } from './container-config.js'; +import { materializeContainerJson } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; import { getAgentGroup } from './db/agent-groups.js'; @@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise { } writeSessionRouting(agentGroup.id, session.id); - // Read container config once — threaded through provider resolution, - // buildMounts, and buildContainerArgs so we don't re-read the file. - const containerConfig = readContainerConfig(agentGroup.folder); - - // Ensure container.json has the agent group identity fields the runner needs. - // Written at spawn time so the runner can read them from the RO mount. - ensureRuntimeFields(containerConfig, agentGroup); + // Materialize container.json from DB — writes fresh file and returns + // the config object, threaded through provider resolution, buildMounts, + // and buildContainerArgs so we don't re-read. + const containerConfig = materializeContainerJson(agentGroup.id); // Resolve the effective provider + any host-side contribution it declares // (extra mounts, env passthrough). Computed once and threaded through both @@ -204,22 +203,19 @@ export function killContainer(sessionId: string, reason: string): void { } /** - * Resolve the provider name for a session using the precedence documented in - * the provider-install skills: + * Resolve the provider name for a session: * * sessions.agent_provider - * → agent_groups.agent_provider - * → container.json `provider` + * → container_configs.provider * → 'claude' * * Pure so the precedence can be unit-tested without a DB or filesystem. */ export function resolveProviderName( sessionProvider: string | null | undefined, - agentGroupProvider: string | null | undefined, containerConfigProvider: string | null | undefined, ): string { - return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); + return (sessionProvider || containerConfigProvider || 'claude').toLowerCase(); } function resolveProviderContribution( @@ -227,7 +223,7 @@ function resolveProviderContribution( agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); + const provider = resolveProviderName(session.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ @@ -396,34 +392,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain } } -/** - * Ensure container.json has the runtime identity fields the runner needs. - * Written at spawn time so they're always current even if the DB values - * change (e.g. group rename). Only writes if values differ to avoid - * unnecessary file churn. - */ -function ensureRuntimeFields( - containerConfig: import('./container-config.js').ContainerConfig, - agentGroup: AgentGroup, -): void { - let dirty = false; - if (containerConfig.agentGroupId !== agentGroup.id) { - containerConfig.agentGroupId = agentGroup.id; - dirty = true; - } - if (containerConfig.groupName !== agentGroup.name) { - containerConfig.groupName = agentGroup.name; - dirty = true; - } - if (containerConfig.assistantName !== agentGroup.name) { - containerConfig.assistantName = agentGroup.name; - dirty = true; - } - if (dirty) { - writeContainerConfig(agentGroup.folder, containerConfig); - } -} - async function buildContainerArgs( mounts: VolumeMount[], containerName: string, @@ -497,9 +465,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise const agentGroup = getAgentGroup(agentGroupId); if (!agentGroup) throw new Error('Agent group not found'); - const containerConfig = readContainerConfig(agentGroup.folder); - const aptPackages = containerConfig.packages.apt; - const npmPackages = containerConfig.packages.npm; + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) throw new Error('Container config not found'); + const aptPackages = JSON.parse(configRow.packages_apt) as string[]; + const npmPackages = JSON.parse(configRow.packages_npm) as string[]; if (aptPackages.length === 0 && npmPackages.length === 0) { throw new Error('No packages to install. Use install_packages first.'); @@ -536,9 +505,8 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise fs.unlinkSync(tmpDockerfile); } - // Store the image tag in groups//container.json - containerConfig.imageTag = imageTag; - writeContainerConfig(agentGroup.folder, containerConfig); + // Store the image tag in the DB + updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag }); log.info('Per-agent-group image built', { agentGroupId, imageTag }); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts new file mode 100644 index 0000000..2e1ce9e --- /dev/null +++ b/src/db/container-configs.ts @@ -0,0 +1,84 @@ +import type { ContainerConfigRow } from '../types.js'; +import { getDb } from './connection.js'; + +export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined { + return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as + | ContainerConfigRow + | undefined; +} + +export function getAllContainerConfigs(): ContainerConfigRow[] { + return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[]; +} + +/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */ +export function createContainerConfig(config: ContainerConfigRow): void { + getDb() + .prepare( + `INSERT INTO container_configs ( + agent_group_id, provider, model, effort, image_tag, assistant_name, + max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm, + additional_mounts, updated_at + ) VALUES ( + @agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name, + @max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm, + @additional_mounts, @updated_at + )`, + ) + .run(config); +} + +/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */ +export function ensureContainerConfig(agentGroupId: string): void { + getDb() + .prepare( + `INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at) + VALUES (?, ?)`, + ) + .run(agentGroupId, new Date().toISOString()); +} + +/** Update scalar fields on a config row. Only touches fields present in `updates`. */ +export function updateContainerConfigScalars( + agentGroupId: string, + updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + > + >, +): void { + const fields: string[] = []; + const values: Record = { agent_group_id: agentGroupId }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + fields.push('updated_at = @updated_at'); + values.updated_at = new Date().toISOString(); + + getDb() + .prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`) + .run(values); +} + +/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */ +export function updateContainerConfigJson( + agentGroupId: string, + column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts', + value: unknown, +): void { + const now = new Date().toISOString(); + getDb() + .prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`) + .run(JSON.stringify(value), now, agentGroupId); +} + +export function deleteContainerConfig(agentGroupId: string): void { + getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId); +} diff --git a/src/db/index.ts b/src/db/index.ts index 0e4285a..57a1013 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -42,3 +42,12 @@ export { deletePendingApproval, getPendingApprovalsByAction, } from './sessions.js'; +export { + getContainerConfig, + getAllContainerConfigs, + createContainerConfig, + ensureContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, + deleteContainerConfig, +} from './container-configs.js'; diff --git a/src/db/migrations/014-container-configs.ts b/src/db/migrations/014-container-configs.ts new file mode 100644 index 0000000..b9e3968 --- /dev/null +++ b/src/db/migrations/014-container-configs.ts @@ -0,0 +1,26 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration014: Migration = { + version: 14, + name: 'container-configs', + up(db: Database.Database) { + db.exec(` + CREATE TABLE container_configs ( + agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE, + provider TEXT, + model TEXT, + effort TEXT, + image_tag TEXT, + assistant_name TEXT, + max_messages_per_prompt INTEGER, + skills TEXT NOT NULL DEFAULT '"all"', + mcp_servers TEXT NOT NULL DEFAULT '{}', + packages_apt TEXT NOT NULL DEFAULT '[]', + packages_npm TEXT NOT NULL DEFAULT '[]', + additional_mounts TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b46e678..a181cb3 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,6 +10,7 @@ import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; +import { migration014 } from './014-container-configs.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -31,6 +32,7 @@ const migrations: Migration[] = [ migration011, migration012, migration013, + migration014, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/group-init.ts b/src/group-init.ts index b325150..e6d919b 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; -import { initContainerConfig } from './container-config.js'; +import { ensureContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -65,12 +65,10 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('CLAUDE.local.md'); } - // groups//container.json — empty container config, replaces the - // former agent_groups.container_config DB column. Self-modification flows - // read and write this file directly. - if (initContainerConfig(group.folder)) { - initialized.push('container.json'); - } + // Ensure container_configs row exists in the DB. Idempotent — no-op if + // the row already exists (e.g. created by backfill or group creation). + ensureContainerConfig(group.id); + initialized.push('container_configs'); // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); diff --git a/src/index.ts b/src/index.ts index f16992a..6af9b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import { backfillContainerConfigs } from './backfill-container-configs.js'; import { DATA_DIR } from './config.js'; import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; @@ -74,7 +75,11 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); - // 1b. One-time filesystem cutover — idempotent, no-op after first run. + // 1b. Backfill container_configs from legacy container.json files. + // Idempotent — skips groups that already have a config row. + backfillContainerConfigs(); + + // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); // 2. Container runtime diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index 5291937..da5b356 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -3,17 +3,16 @@ * * The approvals module calls these when an admin clicks Approve on a * pending_approvals row whose action matches. Each handler mutates the - * container config, rebuilds/kills the container as needed, and lets the - * host sweep respawn it on the new image on the next message. + * container config in the DB, rebuilds/kills the container as needed, + * and lets the host sweep respawn it on the next message. * - * install_packages: rebuild image + kill container (apt/npm global installs - * must be baked into the image layer). - * add_mcp_server: kill container only — bun runs TS directly, so a pure - * MCP wiring change needs nothing more than a process restart. + * install_packages: update DB + rebuild image + kill container. + * add_mcp_server: update DB + kill container only. */ -import { updateContainerConfig } from '../../container-config.js'; import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js'; +import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { ApprovalHandler } from '../approvals/index.js'; @@ -24,10 +23,24 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, notify('install_packages approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); - if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('install_packages approved but container config missing.'); + return; + } + + // Append new packages to existing lists in the DB + if (payload.apt) { + const existing = JSON.parse(configRow.packages_apt) as string[]; + existing.push(...(payload.apt as string[])); + updateContainerConfigJson(agentGroup.id, 'packages_apt', existing); + } + if (payload.npm) { + const existing = JSON.parse(configRow.packages_npm) as string[]; + existing.push(...(payload.npm as string[])); + updateContainerConfigJson(agentGroup.id, 'packages_npm', existing); + } const pkgs = [ ...((payload.apt as string[] | undefined) || []), @@ -37,8 +50,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, try { await buildAgentGroupImage(session.agent_group_id); killContainer(session.id, 'rebuild applied'); - // Schedule a follow-up prompt a few seconds after kill so the host sweep - // respawns the container on the new image and the agent verifies + reports. writeSessionMessage(session.agent_group_id, session.id, { id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', @@ -71,13 +82,21 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use notify('add_mcp_server approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - cfg.mcpServers[payload.name as string] = { - command: payload.command as string, - args: (payload.args as string[]) || [], - env: (payload.env as Record) || {}, - }; - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('add_mcp_server approved but container config missing.'); + return; + } + + // Add the new MCP server to the existing map in the DB + const servers = JSON.parse(configRow.mcp_servers) as Record; + servers[payload.name as string] = { + command: payload.command as string, + args: (payload.args as string[]) || [], + env: (payload.env as Record) || {}, + }; + updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers); killContainer(session.id, 'mcp server added'); notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); diff --git a/src/types.ts b/src/types.ts index b3e2470..ece7b76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,29 @@ export interface AgentGroup { id: string; name: string; folder: string; + /** @deprecated Use container_configs.provider instead. */ agent_provider: string | null; created_at: string; } +/** Per-agent-group container runtime config. Source of truth in the DB; + * materialized to `groups//container.json` at spawn time. */ +export interface ContainerConfigRow { + agent_group_id: string; + provider: string | null; + model: string | null; + effort: string | null; + image_tag: string | null; + assistant_name: string | null; + max_messages_per_prompt: number | null; + skills: string; // JSON: '"all"' | '["skill1","skill2"]' + mcp_servers: string; // JSON: Record + packages_apt: string; // JSON: string[] + packages_npm: string; // JSON: string[] + additional_mounts: string; // JSON: AdditionalMountConfig[] + updated_at: string; +} + export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public'; export interface MessagingGroup {