feat(db): move container config from filesystem to DB
Source of truth for container runtime config moves from
groups/<folder>/container.json to a new container_configs table.
The file becomes a materialized view written at spawn time.
- New container_configs table with scalar columns (provider, model,
effort, image_tag, assistant_name, max_messages_per_prompt) and
JSON columns (mcp_servers, packages_apt, packages_npm, skills,
additional_mounts)
- Startup backfill seeds DB from existing container.json files
- materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields
- Self-mod handlers (install_packages, add_mcp_server) write to DB
- Provider cascade simplified: session -> container_configs -> 'claude'
- ncl groups config-{get,update,add-mcp-server,remove-mcp-server,
add-package,remove-package} custom operations
- restartAgentGroupContainers() helper for config change propagation
- Container side unchanged (still reads /workspace/agent/container.json)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
src/backfill-container-configs.ts
Normal file
77
src/backfill-container-configs.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* One-time backfill: seed `container_configs` rows from existing
|
||||||
|
* `groups/<folder>/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<string, McpServerConfig>;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { GROUPS_DIR } from './config.js';
|
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 { log } from './log.js';
|
||||||
import type { AgentGroup } from './types.js';
|
import type { AgentGroup } from './types.js';
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Desired fragment set.
|
// Desired fragment set.
|
||||||
const config = readContainerConfig(group.folder);
|
const configRow = getContainerConfig(group.id);
|
||||||
|
const mcpServers: Record<string, McpServerConfig> = configRow
|
||||||
|
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
|
||||||
|
: {};
|
||||||
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
||||||
|
|
||||||
// Skill fragments — every skill that ships an `instructions.md`.
|
// 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
|
// MCP server fragments — inline instructions from container.json for
|
||||||
// user-added external MCP servers.
|
// 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) {
|
if (mcp.instructions) {
|
||||||
desired.set(`mcp-${name}.md`, {
|
desired.set(`mcp-${name}.md`, {
|
||||||
type: 'inline',
|
type: 'inline',
|
||||||
|
|||||||
@@ -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';
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
/** Deserialize JSON columns for display. */
|
||||||
|
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
||||||
|
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({
|
registerResource({
|
||||||
name: 'group',
|
name: 'group',
|
||||||
plural: 'groups',
|
plural: 'groups',
|
||||||
@@ -26,12 +53,169 @@ registerResource({
|
|||||||
{
|
{
|
||||||
name: 'agent_provider',
|
name: 'agent_provider',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description:
|
description: 'Deprecated — use `ncl groups config-update --provider`. Kept for backwards compat.',
|
||||||
'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-<provider>.',
|
updatable: false,
|
||||||
updatable: true,
|
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
],
|
],
|
||||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
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 <group-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 <group-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 <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
|
||||||
|
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<string, McpServerConfig>;
|
||||||
|
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<string, string>) : {},
|
||||||
|
};
|
||||||
|
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 <group-id> --name <server-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<string, McpServerConfig>;
|
||||||
|
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 <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||||
|
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 <pkg> or --npm <pkg>');
|
||||||
|
|
||||||
|
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 <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||||
|
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 <pkg> or --npm <pkg>');
|
||||||
|
|
||||||
|
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 } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Per-group container config, stored as a plain JSON file at
|
* Container config types and materialization.
|
||||||
* `groups/<folder>/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.
|
|
||||||
*
|
*
|
||||||
* All fields are optional — a missing file or a partial file both resolve
|
* Source of truth is the `container_configs` table in the central DB.
|
||||||
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
|
* This module provides:
|
||||||
* worth the ceremony here since there's only one writer in practice: the
|
* - Type definitions for the file shape (read by the container runner)
|
||||||
* host, from the delivery thread that processes approved system actions).
|
* - `materializeContainerJson()` — writes `groups/<folder>/container.json`
|
||||||
|
* from the DB at spawn time
|
||||||
|
* - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group
|
||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { GROUPS_DIR } from './config.js';
|
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 {
|
export interface McpServerConfig {
|
||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
// Optional always-in-context guidance. When set, the host writes the
|
|
||||||
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
|
|
||||||
// into the composed CLAUDE.md.
|
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,101 +29,61 @@ export interface AdditionalMountConfig {
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape of the materialized `container.json` file read by the container runner. */
|
||||||
export interface ContainerConfig {
|
export interface ContainerConfig {
|
||||||
mcpServers: Record<string, McpServerConfig>;
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
packages: { apt: string[]; npm: string[] };
|
packages: { apt: string[]; npm: string[] };
|
||||||
imageTag?: string;
|
imageTag?: string;
|
||||||
additionalMounts: AdditionalMountConfig[];
|
additionalMounts: AdditionalMountConfig[];
|
||||||
/** Which skills to enable — array of skill names or "all" (default). */
|
|
||||||
skills: string[] | 'all';
|
skills: string[] | 'all';
|
||||||
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
|
|
||||||
provider?: string;
|
provider?: string;
|
||||||
/** Agent group display name (used in transcript archiving). */
|
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
/** Assistant display name (used in system prompt / responses). */
|
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
/** Agent group ID — set by the host, read by the runner. */
|
|
||||||
agentGroupId?: string;
|
agentGroupId?: string;
|
||||||
/** Max messages per prompt. Falls back to code default if unset. */
|
|
||||||
maxMessagesPerPrompt?: number;
|
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 {
|
return {
|
||||||
mcpServers: {},
|
mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
|
||||||
packages: { apt: [], npm: [] },
|
packages: {
|
||||||
additionalMounts: [],
|
apt: JSON.parse(row.packages_apt) as string[],
|
||||||
skills: 'all',
|
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
|
* Materialize `container.json` from the DB. Called at spawn time so the
|
||||||
* any missing fields (or an entirely empty config if the file is absent).
|
* container always sees fresh config. Returns the `ContainerConfig` for
|
||||||
* Never throws for missing / malformed files — corruption logs a warning
|
* use by the caller (buildMounts, buildContainerArgs, etc.).
|
||||||
* via console.error and falls back to empty.
|
|
||||||
*/
|
*/
|
||||||
export function readContainerConfig(folder: string): ContainerConfig {
|
export function materializeContainerJson(agentGroupId: string): ContainerConfig {
|
||||||
const p = configPath(folder);
|
const group = getAgentGroup(agentGroupId);
|
||||||
if (!fs.existsSync(p)) return emptyConfig();
|
if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
|
||||||
try {
|
|
||||||
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const row = getContainerConfig(agentGroupId);
|
||||||
* Write the container config for a group, creating the groups/<folder>/
|
if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`);
|
||||||
* directory if necessary. Pretty-printed JSON so diffs in the activation
|
|
||||||
* flow are reviewable.
|
const config = configFromDb(row, group);
|
||||||
*/
|
|
||||||
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
|
const p = path.join(GROUPS_DIR, group.folder, 'container.json');
|
||||||
const p = configPath(folder);
|
|
||||||
const dir = path.dirname(p);
|
const dir = path.dirname(p);
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
|
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;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
44
src/container-restart.ts
Normal file
44
src/container-restart.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { resolveProviderName } from './container-runner.js';
|
import { resolveProviderName } from './container-runner.js';
|
||||||
|
|
||||||
describe('resolveProviderName', () => {
|
describe('resolveProviderName', () => {
|
||||||
it('prefers session over group and container.json', () => {
|
it('prefers session over container config', () => {
|
||||||
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
|
expect(resolveProviderName('codex', 'claude')).toBe('codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to group when session is null', () => {
|
it('falls back to container config when session is null', () => {
|
||||||
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
|
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to container.json when session and group are null', () => {
|
|
||||||
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to claude when nothing is set', () => {
|
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', () => {
|
it('lowercases the resolved name', () => {
|
||||||
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
|
expect(resolveProviderName('CODEX', null)).toBe('codex');
|
||||||
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
|
expect(resolveProviderName(null, 'Claude')).toBe('claude');
|
||||||
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats empty string as unset (falls through)', () => {
|
it('treats empty string as unset (falls through)', () => {
|
||||||
expect(resolveProviderName('', 'codex', null)).toBe('codex');
|
expect(resolveProviderName('', 'opencode')).toBe('opencode');
|
||||||
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
|
expect(resolveProviderName(null, '')).toBe('claude');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
ONECLI_URL,
|
ONECLI_URL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} 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 { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||||
import { getAgentGroup } from './db/agent-groups.js';
|
import { getAgentGroup } from './db/agent-groups.js';
|
||||||
@@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
}
|
}
|
||||||
writeSessionRouting(agentGroup.id, session.id);
|
writeSessionRouting(agentGroup.id, session.id);
|
||||||
|
|
||||||
// Read container config once — threaded through provider resolution,
|
// Materialize container.json from DB — writes fresh file and returns
|
||||||
// buildMounts, and buildContainerArgs so we don't re-read the file.
|
// the config object, threaded through provider resolution, buildMounts,
|
||||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
// and buildContainerArgs so we don't re-read.
|
||||||
|
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Resolve the effective provider + any host-side contribution it declares
|
// Resolve the effective provider + any host-side contribution it declares
|
||||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
// (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
|
* Resolve the provider name for a session:
|
||||||
* the provider-install skills:
|
|
||||||
*
|
*
|
||||||
* sessions.agent_provider
|
* sessions.agent_provider
|
||||||
* → agent_groups.agent_provider
|
* → container_configs.provider
|
||||||
* → container.json `provider`
|
|
||||||
* → 'claude'
|
* → 'claude'
|
||||||
*
|
*
|
||||||
* Pure so the precedence can be unit-tested without a DB or filesystem.
|
* Pure so the precedence can be unit-tested without a DB or filesystem.
|
||||||
*/
|
*/
|
||||||
export function resolveProviderName(
|
export function resolveProviderName(
|
||||||
sessionProvider: string | null | undefined,
|
sessionProvider: string | null | undefined,
|
||||||
agentGroupProvider: string | null | undefined,
|
|
||||||
containerConfigProvider: string | null | undefined,
|
containerConfigProvider: string | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
|
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProviderContribution(
|
function resolveProviderContribution(
|
||||||
@@ -227,7 +223,7 @@ function resolveProviderContribution(
|
|||||||
agentGroup: AgentGroup,
|
agentGroup: AgentGroup,
|
||||||
containerConfig: import('./container-config.js').ContainerConfig,
|
containerConfig: import('./container-config.js').ContainerConfig,
|
||||||
): { provider: string; contribution: ProviderContainerContribution } {
|
): { 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 fn = getProviderContainerConfig(provider);
|
||||||
const contribution = fn
|
const contribution = fn
|
||||||
? 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(
|
async function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -497,9 +465,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
|||||||
const agentGroup = getAgentGroup(agentGroupId);
|
const agentGroup = getAgentGroup(agentGroupId);
|
||||||
if (!agentGroup) throw new Error('Agent group not found');
|
if (!agentGroup) throw new Error('Agent group not found');
|
||||||
|
|
||||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
const configRow = getContainerConfig(agentGroup.id);
|
||||||
const aptPackages = containerConfig.packages.apt;
|
if (!configRow) throw new Error('Container config not found');
|
||||||
const npmPackages = containerConfig.packages.npm;
|
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) {
|
if (aptPackages.length === 0 && npmPackages.length === 0) {
|
||||||
throw new Error('No packages to install. Use install_packages first.');
|
throw new Error('No packages to install. Use install_packages first.');
|
||||||
@@ -536,9 +505,8 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
|||||||
fs.unlinkSync(tmpDockerfile);
|
fs.unlinkSync(tmpDockerfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the image tag in groups/<folder>/container.json
|
// Store the image tag in the DB
|
||||||
containerConfig.imageTag = imageTag;
|
updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag });
|
||||||
writeContainerConfig(agentGroup.folder, containerConfig);
|
|
||||||
|
|
||||||
log.info('Per-agent-group image built', { agentGroupId, imageTag });
|
log.info('Per-agent-group image built', { agentGroupId, imageTag });
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/db/container-configs.ts
Normal file
84
src/db/container-configs.ts
Normal file
@@ -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<string, unknown> = { 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);
|
||||||
|
}
|
||||||
@@ -42,3 +42,12 @@ export {
|
|||||||
deletePendingApproval,
|
deletePendingApproval,
|
||||||
getPendingApprovalsByAction,
|
getPendingApprovalsByAction,
|
||||||
} from './sessions.js';
|
} from './sessions.js';
|
||||||
|
export {
|
||||||
|
getContainerConfig,
|
||||||
|
getAllContainerConfigs,
|
||||||
|
createContainerConfig,
|
||||||
|
ensureContainerConfig,
|
||||||
|
updateContainerConfigScalars,
|
||||||
|
updateContainerConfigJson,
|
||||||
|
deleteContainerConfig,
|
||||||
|
} from './container-configs.js';
|
||||||
|
|||||||
26
src/db/migrations/014-container-configs.ts
Normal file
26
src/db/migrations/014-container-configs.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { migration010 } from './010-engage-modes.js';
|
|||||||
import { migration011 } from './011-pending-sender-approvals.js';
|
import { migration011 } from './011-pending-sender-approvals.js';
|
||||||
import { migration012 } from './012-channel-registration.js';
|
import { migration012 } from './012-channel-registration.js';
|
||||||
import { migration013 } from './013-approval-render-metadata.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 { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const migrations: Migration[] = [
|
|||||||
migration011,
|
migration011,
|
||||||
migration012,
|
migration012,
|
||||||
migration013,
|
migration013,
|
||||||
|
migration014,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: Database.Database): void {
|
export function runMigrations(db: Database.Database): void {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
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 { log } from './log.js';
|
||||||
import type { AgentGroup } from './types.js';
|
import type { AgentGroup } from './types.js';
|
||||||
|
|
||||||
@@ -65,12 +65,10 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
|||||||
initialized.push('CLAUDE.local.md');
|
initialized.push('CLAUDE.local.md');
|
||||||
}
|
}
|
||||||
|
|
||||||
// groups/<folder>/container.json — empty container config, replaces the
|
// Ensure container_configs row exists in the DB. Idempotent — no-op if
|
||||||
// former agent_groups.container_config DB column. Self-modification flows
|
// the row already exists (e.g. created by backfill or group creation).
|
||||||
// read and write this file directly.
|
ensureContainerConfig(group.id);
|
||||||
if (initContainerConfig(group.folder)) {
|
initialized.push('container_configs');
|
||||||
initialized.push('container.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { backfillContainerConfigs } from './backfill-container-configs.js';
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
|
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
|
||||||
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
|
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
|
||||||
@@ -74,7 +75,11 @@ async function main(): Promise<void> {
|
|||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
log.info('Central DB ready', { path: dbPath });
|
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();
|
migrateGroupsToClaudeLocal();
|
||||||
|
|
||||||
// 2. Container runtime
|
// 2. Container runtime
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
*
|
*
|
||||||
* The approvals module calls these when an admin clicks Approve on a
|
* The approvals module calls these when an admin clicks Approve on a
|
||||||
* pending_approvals row whose action matches. Each handler mutates the
|
* pending_approvals row whose action matches. Each handler mutates the
|
||||||
* container config, rebuilds/kills the container as needed, and lets the
|
* container config in the DB, rebuilds/kills the container as needed,
|
||||||
* host sweep respawn it on the new image on the next message.
|
* and lets the host sweep respawn it on the next message.
|
||||||
*
|
*
|
||||||
* install_packages: rebuild image + kill container (apt/npm global installs
|
* install_packages: update DB + rebuild image + kill container.
|
||||||
* must be baked into the image layer).
|
* add_mcp_server: update DB + kill container only.
|
||||||
* add_mcp_server: kill container only — bun runs TS directly, so a pure
|
|
||||||
* MCP wiring change needs nothing more than a process restart.
|
|
||||||
*/
|
*/
|
||||||
import { updateContainerConfig } from '../../container-config.js';
|
|
||||||
import { buildAgentGroupImage, killContainer } from '../../container-runner.js';
|
import { buildAgentGroupImage, killContainer } from '../../container-runner.js';
|
||||||
import { getAgentGroup } from '../../db/agent-groups.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 { log } from '../../log.js';
|
||||||
import { writeSessionMessage } from '../../session-manager.js';
|
import { writeSessionMessage } from '../../session-manager.js';
|
||||||
import type { ApprovalHandler } from '../approvals/index.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.');
|
notify('install_packages approved but agent group missing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateContainerConfig(agentGroup.folder, (cfg) => {
|
|
||||||
if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[]));
|
const configRow = getContainerConfig(agentGroup.id);
|
||||||
if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[]));
|
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 = [
|
const pkgs = [
|
||||||
...((payload.apt as string[] | undefined) || []),
|
...((payload.apt as string[] | undefined) || []),
|
||||||
@@ -37,8 +50,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload,
|
|||||||
try {
|
try {
|
||||||
await buildAgentGroupImage(session.agent_group_id);
|
await buildAgentGroupImage(session.agent_group_id);
|
||||||
killContainer(session.id, 'rebuild applied');
|
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, {
|
writeSessionMessage(session.agent_group_id, session.id, {
|
||||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
@@ -71,13 +82,21 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use
|
|||||||
notify('add_mcp_server approved but agent group missing.');
|
notify('add_mcp_server approved but agent group missing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateContainerConfig(agentGroup.folder, (cfg) => {
|
|
||||||
cfg.mcpServers[payload.name as string] = {
|
const configRow = getContainerConfig(agentGroup.id);
|
||||||
command: payload.command as string,
|
if (!configRow) {
|
||||||
args: (payload.args as string[]) || [],
|
notify('add_mcp_server approved but container config missing.');
|
||||||
env: (payload.env as Record<string, string>) || {},
|
return;
|
||||||
};
|
}
|
||||||
});
|
|
||||||
|
// Add the new MCP server to the existing map in the DB
|
||||||
|
const servers = JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>;
|
||||||
|
servers[payload.name as string] = {
|
||||||
|
command: payload.command as string,
|
||||||
|
args: (payload.args as string[]) || [],
|
||||||
|
env: (payload.env as Record<string, string>) || {},
|
||||||
|
};
|
||||||
|
updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers);
|
||||||
|
|
||||||
killContainer(session.id, 'mcp server added');
|
killContainer(session.id, 'mcp server added');
|
||||||
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
|
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
|
||||||
|
|||||||
19
src/types.ts
19
src/types.ts
@@ -4,10 +4,29 @@ export interface AgentGroup {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
|
/** @deprecated Use container_configs.provider instead. */
|
||||||
agent_provider: string | null;
|
agent_provider: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-agent-group container runtime config. Source of truth in the DB;
|
||||||
|
* materialized to `groups/<folder>/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<string, McpServerConfig>
|
||||||
|
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 type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public';
|
||||||
|
|
||||||
export interface MessagingGroup {
|
export interface MessagingGroup {
|
||||||
|
|||||||
Reference in New Issue
Block a user