Compare commits

..

3 Commits

Author SHA1 Message Date
gavrielc a141f24f69 Merge main: compose 016 (channel instances) with 017 (idle timeout) in the migrations barrel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:11:42 +03:00
gavrielc bb1db4e35b fix(db): renumber idle-timeout migration to 017 — 016 is taken by #2733's instance migration
Both PRs branched from the same base and picked the next free number.
The runner dedupes by name so runtime behavior is unaffected either way;
the renumber avoids a barrel symbol conflict for whichever merges second.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:08:35 +03:00
gavrielc 5684cfd69b feat(container): per-group idle timeout — clean exit for ephemeral sessions
Adds container_configs.idle_timeout_ms (migration 016) and threads it
host-side (ContainerConfigRow → configFromDb → materializeContainerJson →
container.json) and container-side (loadConfig → idle tracker → poll loop).
When set, an idle container exits 0 once the window elapses instead of
riding until host-sweep's 30-min absolute ceiling kill. NULL/0 (the
default) keeps today's behavior byte-identical. Operator path:
ncl groups config update --idle-timeout-ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:03:57 +03:00
20 changed files with 453 additions and 425 deletions
+3
View File
@@ -15,6 +15,8 @@ export interface RunnerConfig {
groupName: string;
agentGroupId: string;
maxMessagesPerPrompt: number;
/** Idle window in ms after which the poll loop exits cleanly. 0 = disabled. */
idleTimeoutMs: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
@@ -44,6 +46,7 @@ export function loadConfig(): RunnerConfig {
groupName: (raw.groupName as string) || '',
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
idleTimeoutMs: (raw.idleTimeoutMs as number) || 0,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
@@ -0,0 +1,193 @@
/**
* Idle-timeout guard — the machinery that lets ephemeral sessions exit
* cleanly instead of riding until host-sweep's absolute ceiling.
*
* Behavior leg: the idle tracker with an injected clock (markActivity /
* shouldExit semantics, including the hasProcessedAtLeastOne gate and the
* idleTimeoutMs <= 0 disable).
*
* AST legs (runPollLoop is an infinite loop — not invocable in a test):
* - runPollLoop destructures idleTimeoutMs from loadConfig() (the
* destructure may carry other keys; this only pins idleTimeoutMs);
* - the empty-poll branch exits via process.exit(0) gated on
* idle.shouldExit();
* - idle.markActivity() runs after the batch-completion
* markCompleted(processingIds) so the idle window restarts per batch;
* - the processQuery call site threads idleTimeoutMs as the 5th argument;
* - the 'result' event arm calls query.end() gated on
* `idleTimeoutMs > 0 && !hasUnwrapped` — never unconditionally, or the
* unwrapped-output re-send nudge would be cut off mid-stream;
* - loadConfig()'s returned literal carries the idleTimeoutMs field
* (RunnerConfig's type is covered by the typecheck leg).
*/
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'bun:test';
import ts from 'typescript';
import { createIdleTracker } from './idle-tracker.js';
describe('idle tracker behavior', () => {
it('never exits before the first processed batch, regardless of elapsed time', () => {
let clock = 0;
const tracker = createIdleTracker(1000, () => clock);
clock = 1_000_000;
expect(tracker.shouldExit()).toBe(false);
});
it('exits only after the idle window elapses past the last activity', () => {
let clock = 0;
const tracker = createIdleTracker(1000, () => clock);
tracker.markActivity(); // first batch completes at t=0
clock = 900;
expect(tracker.shouldExit()).toBe(false);
clock = 1001;
expect(tracker.shouldExit()).toBe(true);
// New activity re-arms the window.
tracker.markActivity();
clock = 1900;
expect(tracker.shouldExit()).toBe(false);
clock = 2002;
expect(tracker.shouldExit()).toBe(true);
});
it('idleTimeoutMs <= 0 disables idle exit entirely', () => {
let clock = 0;
const tracker = createIdleTracker(0, () => clock);
tracker.markActivity();
clock = 10_000_000;
expect(tracker.shouldExit()).toBe(false);
});
});
// ── AST legs ──
function parse(file: string): ts.SourceFile {
const source = fs.readFileSync(path.join(import.meta.dir, file), 'utf8');
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
}
function findAll<T extends ts.Node>(root: ts.Node, pred: (n: ts.Node) => n is T): T[] {
const out: T[] = [];
const visit = (n: ts.Node): void => {
if (pred(n)) out.push(n);
n.forEachChild(visit);
};
visit(root);
return out;
}
function hasAncestor(node: ts.Node, pred: (n: ts.Node) => boolean): boolean {
let cur: ts.Node | undefined = node.parent;
while (cur) {
if (pred(cur)) return true;
cur = cur.parent;
}
return false;
}
describe('poll-loop.ts idle wiring', () => {
const sf = parse('poll-loop.ts');
const runPollLoop = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'runPollLoop');
it('destructures idleTimeoutMs from loadConfig()', () => {
const decls = findAll(runPollLoop!, ts.isVariableDeclaration).filter(
(d) =>
d.initializer !== undefined &&
ts.isCallExpression(d.initializer) &&
ts.isIdentifier(d.initializer.expression) &&
d.initializer.expression.text === 'loadConfig' &&
ts.isObjectBindingPattern(d.name),
);
expect(decls.length).toBeGreaterThanOrEqual(1);
const hasKey = decls.some((d) =>
(d.name as ts.ObjectBindingPattern).elements.some(
(e) => ts.isIdentifier(e.name) && e.name.text === 'idleTimeoutMs',
),
);
expect(hasKey).toBe(true);
});
it('the empty-poll branch exits 0 gated on idle.shouldExit()', () => {
const exits = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) =>
ts.isPropertyAccessExpression(c.expression) &&
c.expression.getText(sf) === 'process.exit' &&
c.arguments[0]?.getText(sf) === '0',
);
const gated = exits.filter((c) =>
hasAncestor(
c,
(n) => ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idle.shouldExit()',
),
);
expect(gated.length).toBe(1);
// And the gate itself sits inside the messages.length === 0 branch.
expect(
hasAncestor(gated[0], (n) => ts.isIfStatement(n) && n.expression.getText(sf).includes('messages.length === 0')),
).toBe(true);
});
it('marks activity after markCompleted so the idle window restarts per batch', () => {
const marks = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'idle.markActivity',
);
expect(marks.length).toBe(1);
// The batch-completion call is markCompleted(processingIds) — the others
// handle command/skip bookkeeping and must not arm the idle window.
const completed = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) =>
ts.isIdentifier(c.expression) &&
c.expression.text === 'markCompleted' &&
c.arguments[0]?.getText(sf) === 'processingIds',
);
expect(completed.length).toBe(1);
expect(marks[0].getStart(sf)).toBeGreaterThan(completed[0].getStart(sf));
});
it("threads idleTimeoutMs as processQuery's 5th argument", () => {
const calls = findAll(runPollLoop!, ts.isCallExpression).filter(
(c) => ts.isIdentifier(c.expression) && c.expression.text === 'processQuery',
);
expect(calls.length).toBe(1);
expect(calls[0].arguments.length).toBe(5);
expect(calls[0].arguments[4].getText(sf)).toBe('idleTimeoutMs');
});
it("the 'result' event arm ends the stream gated on idleTimeoutMs > 0 && !hasUnwrapped", () => {
const processQuery = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'processQuery');
expect(processQuery).toBeDefined();
const ends = findAll(processQuery!, ts.isCallExpression).filter(
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'query.end',
);
// The !hasUnwrapped half of the gate is load-bearing: an unconditional
// (or idleTimeoutMs-only) end would close the stream right after the
// unwrapped-output nudge was pushed, stranding the re-sent response.
const gated = ends.filter((c) =>
hasAncestor(
c,
(n) =>
ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idleTimeoutMs>0&&!hasUnwrapped',
),
);
expect(gated.length).toBe(1);
});
});
describe('config.ts idle wiring', () => {
const sf = parse('config.ts');
it('loadConfig returns an idleTimeoutMs field', () => {
const loadConfig = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'loadConfig');
expect(loadConfig).toBeDefined();
const props = findAll(loadConfig!, ts.isPropertyAssignment).filter(
(p) => ts.isIdentifier(p.name) && p.name.text === 'idleTimeoutMs',
);
expect(props.length).toBe(1);
// Reads the raw container.json key with a 0 default (0 = disabled).
expect(props[0].initializer.getText(sf).replace(/\s+/g, '')).toContain('raw.idleTimeoutMs');
});
});
@@ -0,0 +1,40 @@
/**
* Idle-exit tracker for ephemeral sessions.
*
* The poll loop creates one tracker per run and makes two one-line calls:
*
* - `markActivity()` after a batch completes — records the last time the
* agent did real work and arms the tracker (an agent that never processed
* anything must not idle-exit before its first trigger arrives).
* - `shouldExit()` in the empty-poll branch — true once idleTimeoutMs > 0,
* at least one batch has been processed, and the idle window has elapsed.
*
* `idleTimeoutMs` comes from the group's container.json (RunnerConfig),
* materialized from the `container_configs.idle_timeout_ms` column. A value
* of 0 (the default) disables idle exit entirely — the container then rides
* until host-sweep's absolute ceiling, exactly as before this tracker existed.
*/
export interface IdleTracker {
/** Record activity: arms the tracker and resets the idle window. */
markActivity(): void;
/** True when the session has been idle past the timeout and may exit 0. */
shouldExit(): boolean;
}
export function createIdleTracker(idleTimeoutMs: number, now: () => number = Date.now): IdleTracker {
let lastActivityAt = now();
let hasProcessedAtLeastOne = false;
return {
markActivity(): void {
lastActivityAt = now();
hasProcessedAtLeastOne = true;
},
shouldExit(): boolean {
if (idleTimeoutMs <= 0) return false;
if (!hasProcessedAtLeastOne) return false;
return now() - lastActivityAt > idleTimeoutMs;
},
};
}
+24 -2
View File
@@ -4,6 +4,8 @@ import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import { loadConfig } from './config.js';
import { createIdleTracker } from './idle-tracker.js';
import {
formatMessages,
extractRouting,
@@ -104,6 +106,12 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// This lets the new container re-process those messages.
clearStaleProcessingAcks();
// Idle exit: when the group's container config sets idle_timeout_ms, an
// idle container exits 0 after the window elapses instead of riding until
// host-sweep's absolute ceiling kills it. Unset/0 = disabled (default).
const { idleTimeoutMs } = loadConfig();
const idle = createIdleTracker(idleTimeoutMs);
let pollCount = 0;
let isFirstPoll = true;
while (true) {
@@ -118,6 +126,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
}
if (messages.length === 0) {
if (idle.shouldExit()) {
log(`Idle timeout (${idleTimeoutMs}ms) — exiting`);
process.exit(0);
}
await sleep(POLL_INTERVAL_MS);
continue;
}
@@ -232,7 +244,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// can stamp it on outbound rows — needed for a2a return-path routing.
setCurrentInReplyTo(routing.inReplyTo);
try {
const result = await processQuery(query, routing, processingIds, config.providerName);
const result = await processQuery(query, routing, processingIds, config.providerName, idleTimeoutMs);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
@@ -266,6 +278,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Ensure completed even if processQuery ended without a result event
// (e.g. stream closed unexpectedly).
markCompleted(processingIds);
idle.markActivity();
log(`Completed ${ids.length} message(s)`);
}
}
@@ -313,6 +326,7 @@ async function processQuery(
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
idleTimeoutMs: number = 0,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
@@ -454,8 +468,9 @@ async function processQuery(
// (send_message) mid-turn, or the message may not need a response
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
let hasUnwrapped = false;
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
({ hasUnwrapped } = dispatchResultText(event.text, routing));
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
@@ -468,6 +483,13 @@ async function processQuery(
);
}
}
// When idleTimeoutMs is set, end the stream once the turn completes
// so the outer loop can evaluate the idle window. Skipped while the
// turn's output was unwrapped — the re-send nudge pushed above needs
// the stream to stay open for the corrected response.
if (idleTimeoutMs > 0 && !hasUnwrapped) {
query.end();
}
}
}
} finally {
+2
View File
@@ -310,6 +310,7 @@ CREATE TABLE container_configs (
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
idle_timeout_ms INTEGER, -- idle-exit window (ms); NULL/0 = disabled
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
@@ -344,6 +345,7 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
| 016 | `016-container-idle-timeout.ts` | `ALTER TABLE container_configs ADD COLUMN idle_timeout_ms` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.11",
"version": "2.1.10",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+1
View File
@@ -59,6 +59,7 @@ export function backfillContainerConfigs(): void {
image_tag: legacy.imageTag ?? null,
assistant_name: legacy.assistantName ?? null,
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
idle_timeout_ms: null,
skills: JSON.stringify(legacy.skills ?? 'all'),
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
+12 -3
View File
@@ -22,6 +22,7 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
image_tag: row.image_tag,
assistant_name: row.assistant_name,
max_messages_per_prompt: row.max_messages_per_prompt,
idle_timeout_ms: row.idle_timeout_ms,
skills: JSON.parse(row.skills),
mcp_servers: JSON.parse(row.mcp_servers),
packages_apt: JSON.parse(row.packages_apt),
@@ -213,7 +214,7 @@ registerResource({
access: 'approval',
description:
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
@@ -223,7 +224,14 @@ registerResource({
const updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
>
> = {};
if (args.provider !== undefined) updates.provider = args.provider as string;
@@ -233,6 +241,7 @@ registerResource({
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 (args.idle_timeout_ms !== undefined) updates.idle_timeout_ms = Number(args.idle_timeout_ms);
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
if (!['disabled', 'group', 'global'].includes(scope)) {
@@ -243,7 +252,7 @@ registerResource({
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, --cli-scope',
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope',
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* idle_timeout_ms threading migration 016 column `ContainerConfigRow`
* `configFromDb()` `materializeContainerJson()` `container.json`.
*
* The default leg is load-bearing: a NULL column must keep `idleTimeoutMs`
* out of container.json entirely, so groups that never set the value get
* today's behavior byte-identical (the container-side loadConfig then
* defaults to 0 = idle exit disabled).
*/
import fs from 'fs';
import path from 'path';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('./config.js', async () => {
const actual = await vi.importActual('./config.js');
return { ...actual, GROUPS_DIR: '/tmp/nanoclaw-test-container-config/groups' };
});
const TEST_DIR = '/tmp/nanoclaw-test-container-config';
const GROUPS_DIR = path.join(TEST_DIR, 'groups');
import { initTestDb, closeDb, runMigrations } from './db/index.js';
import { createAgentGroup, getAgentGroup } from './db/agent-groups.js';
import { ensureContainerConfig, getContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
import { configFromDb, materializeContainerJson } from './container-config.js';
const GID = 'ag-idle';
function now(): string {
return new Date().toISOString();
}
describe('container config idle_timeout_ms threading', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(GROUPS_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: GID, name: 'idle-group', folder: 'idle-group', agent_provider: null, created_at: now() });
ensureContainerConfig(GID);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('updateContainerConfigScalars persists idle_timeout_ms and configFromDb threads it', () => {
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
const row = getContainerConfig(GID)!;
expect(row.idle_timeout_ms).toBe(300000);
const config = configFromDb(row, getAgentGroup(GID)!);
expect(config.idleTimeoutMs).toBe(300000);
});
it('materializeContainerJson writes idleTimeoutMs into container.json', () => {
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
const config = materializeContainerJson(GID);
expect(config.idleTimeoutMs).toBe(300000);
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
expect(written.idleTimeoutMs).toBe(300000);
});
it('NULL column (the default) keeps idleTimeoutMs out of container.json — feature off', () => {
const row = getContainerConfig(GID)!;
expect(row.idle_timeout_ms).toBeNull();
const config = configFromDb(row, getAgentGroup(GID)!);
expect(config.idleTimeoutMs).toBeUndefined();
materializeContainerJson(GID);
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
// JSON.stringify drops undefined — the key must be absent, not null/0.
expect('idleTimeoutMs' in written).toBe(false);
});
});
+3
View File
@@ -41,6 +41,8 @@ export interface ContainerConfig {
assistantName?: string;
agentGroupId?: string;
maxMessagesPerPrompt?: number;
/** Idle window in ms after which an idle container exits cleanly. Unset/0 = disabled. */
idleTimeoutMs?: number;
model?: string;
effort?: string;
}
@@ -61,6 +63,7 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
assistantName: row.assistant_name ?? group.name,
agentGroupId: group.id,
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
idleTimeoutMs: row.idle_timeout_ms ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
};
+34 -49
View File
@@ -36,7 +36,6 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
import './providers/index.js';
import {
getProviderContainerConfig,
providerProvidesAgentSurfaces,
type ProviderContainerContribution,
type VolumeMount,
} from './providers/provider-container-registry.js';
@@ -128,19 +127,12 @@ async function spawnContainer(session: Session): Promise<void> {
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, { provider: providerName });
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
// OneCLI agent identifier is always the agent group id — stable across
// sessions and reversible via getAgentGroup() for approval routing.
@@ -242,37 +234,32 @@ function resolveProviderContribution(
? fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
selectedSkills: selectedSkillNames(containerConfig),
hostEnv: process.env,
})
: {};
return { provider, contribution };
}
export function buildMounts(
function buildMounts(
agentGroup: AgentGroup,
session: Session,
containerConfig: import('./container-config.js').ContainerConfig,
provider: string,
providerContribution: ProviderContainerContribution,
): VolumeMount[] {
const projectRoot = process.cwd();
// Default agent surfaces (composed project doc, skill links, provider state
// dir) apply unless the provider's registration declares it provides its
// own — a capability, never a provider name. See provider-container-registry.
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before.
initGroupFilesystem(agentGroup);
// Sync skill symlinks based on container.json selection before mounting.
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
if (defaultSurfaces) {
// Sync skill symlinks based on container.json selection before mounting.
syncSkillSymlinks(claudeDir, containerConfig);
syncSkillSymlinks(claudeDir, containerConfig);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
}
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
const mounts: VolumeMount[] = [];
const sessDir = sessionDir(agentGroup.id, session.id);
@@ -299,11 +286,11 @@ export function buildMounts(
// already RO-mounted, so writes through it fail regardless — no need for
// a nested mount there.
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
if (fs.existsSync(composedClaudeMd)) {
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
}
const fragmentsDir = path.join(groupDir, '.claude-fragments');
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
if (fs.existsSync(fragmentsDir)) {
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
@@ -316,15 +303,13 @@ export function buildMounts(
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
if (fs.existsSync(sharedClaudeMd)) {
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
// skill symlinks)
if (defaultSurfaces) {
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
}
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
// Shared agent-runner source — read-only, same code for all groups.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
@@ -361,7 +346,25 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
fs.mkdirSync(skillsDir, { recursive: true });
}
const desired = selectedSkillNames(containerConfig);
// Determine desired skill set
const projectRoot = process.cwd();
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
let desired: string[];
if (containerConfig.skills === 'all') {
// Recompute from shared dir — newly-added upstream skills appear automatically
desired = fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
} else {
desired = containerConfig.skills;
}
const desiredSet = new Set(desired);
// Remove symlinks not in the desired set
@@ -394,24 +397,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Resolve the group's skill selection to concrete names — `'all'` recomputes
* from `container/skills/` so newly-added upstream skills appear automatically.
*/
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
if (containerConfig.skills !== 'all') return containerConfig.skills;
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
return fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
+9 -1
View File
@@ -8,6 +8,7 @@ const SCALAR_COLUMNS = new Set([
'image_tag',
'assistant_name',
'max_messages_per_prompt',
'idle_timeout_ms',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
@@ -55,7 +56,14 @@ export function updateContainerConfigScalars(
updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
>
>,
): void {
@@ -0,0 +1,13 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration017: Migration = {
version: 17,
name: 'container-idle-timeout',
up(db: Database.Database) {
// Idle-exit window in ms for the agent container. NULL (the default) or 0
// disables idle exit — existing groups keep today's behavior, where an
// idle container rides until host-sweep's absolute ceiling kills it.
db.prepare('ALTER TABLE container_configs ADD COLUMN idle_timeout_ms INTEGER').run();
},
};
+2
View File
@@ -13,6 +13,7 @@ import { migration013 } from './013-approval-render-metadata.js';
import { migration014 } from './014-container-configs.js';
import { migration015 } from './015-cli-scope.js';
import { migration016 } from './016-messaging-group-instance.js';
import { migration017 } from './017-container-idle-timeout.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
@@ -46,6 +47,7 @@ export const migrations: Migration[] = [
migration014,
migration015,
migration016,
migration017,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
+20 -32
View File
@@ -4,7 +4,6 @@ import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { AgentGroup } from './types.js';
const DEFAULT_SETTINGS_JSON =
@@ -47,18 +46,9 @@ const DEFAULT_SETTINGS_JSON =
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
* per-group instructions (if provided) seed `CLAUDE.local.md`.
*/
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string; provider?: string | null },
): void {
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
const initialized: string[] = [];
// Default agent surfaces apply unless the group's provider declares (at
// registration) that it provides its own. Callers that don't know the
// provider omit it — unregistered/unknown names report no capabilities,
// so the default surfaces are written, exactly as before this seam.
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
@@ -69,7 +59,7 @@ export function initGroupFilesystem(
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
// Claude Code. Seeded with caller-provided instructions on first creation.
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
if (!fs.existsSync(claudeLocalFile)) {
const body = opts?.instructions ? opts.instructions + '\n' : '';
fs.writeFileSync(claudeLocalFile, body);
initialized.push('CLAUDE.local.md');
@@ -81,28 +71,26 @@ export function initGroupFilesystem(
initialized.push('container_configs');
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
if (defaultSurfaces) {
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
if (initialized.length > 0) {
-143
View File
@@ -1,143 +0,0 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
}));
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { buildMounts } from './container-runner.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { initGroupFilesystem } from './group-init.js';
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
import type { ContainerConfig } from './container-config.js';
import type { AgentGroup, Session } from './types.js';
// A provider that declares (at registration) that it owns its agent surfaces.
// Registered once — the registry is module-global and rejects duplicates.
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
function session(id: string, agentGroupId: string): Session {
return { id, agent_group_id: agentGroupId } as Session;
}
function containerConfig(): ContainerConfig {
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('initGroupFilesystem agent surfaces', () => {
it('writes the default surfaces when no provider is given (todays behavior)', () => {
const ag = group('ag-default', 'default-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
});
it('skips the default surfaces for a provider that provides its own', () => {
const ag = group('ag-surfy', 'surfy-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
expect(fs.existsSync(groupDir)).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
});
it('treats an unregistered provider name as default surfaces', () => {
const ag = group('ag-unknown', 'unknown-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { provider: 'not-registered' });
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
});
});
describe('buildMounts agent surfaces', () => {
it('mounts the default surfaces for an unregistered provider (todays behavior)', () => {
const ag = group('ag-mounts-default', 'mounts-default');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, {});
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
// Composer ran: the generated project doc exists on disk.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
});
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
const ag = group('ag-mounts-surfy', 'mounts-surfy');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
const contributed = {
mounts: [
{
hostPath: path.join(GROUPS_DIR, ag.folder),
containerPath: '/workspace/agent/OWN-DOC.md',
readonly: true,
},
],
};
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).not.toContain('/home/node/.claude');
expect(containerPaths).not.toContain('/app/CLAUDE.md');
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
// Composer did NOT run for this group.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
// Core mounts and the provider's own contribution are intact.
expect(containerPaths).toContain('/workspace');
expect(containerPaths).toContain('/workspace/agent');
expect(containerPaths).toContain('/app/src');
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
});
});
+4 -54
View File
@@ -27,19 +27,6 @@ export interface ProviderContainerContext {
sessionDir: string;
/** Agent group ID, for any per-group logic. */
agentGroupId: string;
/**
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
* `/workspace/agent`). Exists by the time the config fn runs group
* filesystem init happens first. Surfaces-providing providers compose
* their project doc and skill links here.
*/
groupDir: string;
/**
* Skill names selected by the group's container config, with `'all'`
* already resolved against `container/skills/`. Surfaces-providing
* providers use this to sync their own skill-discovery links.
*/
selectedSkills: string[];
/** `process.env` at spawn time — pull passthrough values from here. */
hostEnv: NodeJS.ProcessEnv;
}
@@ -51,56 +38,19 @@ export interface ProviderContainerContribution {
env?: Record<string, string>;
}
/**
* Static capabilities a provider declares at registration time knowable
* without a spawn context, so any host path (group init, spawn, creation
* flows) can consult them by name.
*/
export interface ProviderHostCapabilities {
/**
* Optional. When true, this provider owns its agent-facing surfaces the
* composed project doc, skill-discovery links, and provider state dir
* and the host must NOT compose or mount the default ones (composed
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
* `CLAUDE.local.md` seeding). The provider's config fn does its own
* composing and returns its own mounts. Default off providers that omit
* this get the default surfaces, which is today's behavior.
*/
readonly providesAgentSurfaces?: boolean;
}
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
interface RegistryEntry {
fn: ProviderContainerConfigFn;
capabilities: ProviderHostCapabilities;
}
const registry = new Map<string, ProviderContainerConfigFn>();
const registry = new Map<string, RegistryEntry>();
export function registerProviderContainerConfig(
name: string,
fn: ProviderContainerConfigFn,
capabilities: ProviderHostCapabilities = {},
): void {
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
if (registry.has(name)) {
throw new Error(`Provider container config already registered: ${name}`);
}
registry.set(name, { fn, capabilities });
registry.set(name, fn);
}
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
return registry.get(name)?.fn;
}
/**
* Capability lookup by provider name. Unregistered providers (including the
* baked-in default) report no capabilities the host applies its default
* surfaces, exactly as before this seam existed.
*/
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
if (!name) return false;
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
return registry.get(name);
}
export function listProviderContainerConfigNames(): string[] {
+1
View File
@@ -19,6 +19,7 @@ export interface ContainerConfigRow {
image_tag: string | null;
assistant_name: string | null;
max_messages_per_prompt: number | null;
idle_timeout_ms: number | null; // idle-exit window (ms); NULL/0 = disabled
skills: string; // JSON: '"all"' | '["skill1","skill2"]'
mcp_servers: string; // JSON: Record<string, McpServerConfig>
packages_apt: string; // JSON: string[]
-97
View File
@@ -1,97 +0,0 @@
/**
* Guard for the raw-route half of src/webhook-server.ts
* registerWebhookHandler + the rawRoutes dispatch branch.
*
* Drives the REAL shared HTTP server on an ephemeral WEBHOOK_PORT (no
* mocking of the routing layer): a registered raw route must dispatch,
* unknown paths must 404, a throwing handler must surface as 500,
* raw routes must coexist with Chat SDK adapter routes on the same
* server, and stopWebhookServer must clear them.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import type { Chat } from 'chat';
import { registerWebhookAdapter, registerWebhookHandler, stopWebhookServer } from './webhook-server.js';
const PORT = 21000 + Math.floor(Math.random() * 20000);
async function post(path: string, body = '{}'): Promise<globalThis.Response> {
for (let attempt = 0; ; attempt++) {
try {
return await fetch(`http://127.0.0.1:${PORT}/webhook/${path}`, { method: 'POST', body });
} catch (err) {
if (attempt >= 40) throw err;
await new Promise((r) => setTimeout(r, 50));
}
}
}
afterAll(async () => {
await stopWebhookServer();
delete process.env.WEBHOOK_PORT;
});
describe('webhook server raw routes', () => {
it('dispatches a registered raw route to its handler', async () => {
process.env.WEBHOOK_PORT = String(PORT);
const methods: string[] = [];
registerWebhookHandler('ping', (req, res) => {
methods.push(req.method || '');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('pong');
});
const res = await post('ping');
expect(res.status).toBe(200);
expect(await res.text()).toBe('pong');
expect(methods).toEqual(['POST']);
});
it('returns 404 for paths with no registered route', async () => {
const res = await post('nope');
expect(res.status).toBe(404);
});
it('turns a throwing handler into a 500 response', async () => {
registerWebhookHandler('boom', () => {
throw new Error('handler exploded');
});
const res = await post('boom');
expect(res.status).toBe(500);
expect(await res.text()).toBe('Internal Server Error');
});
it('coexists with Chat SDK adapter routes on the same server', async () => {
const handler = vi.fn(async () => new Response('ok-chat', { status: 200 }));
const chat = { webhooks: { fake: handler } } as unknown as Chat;
registerWebhookAdapter(chat, 'fake');
const chatRes = await post('fake');
expect(chatRes.status).toBe(200);
expect(await chatRes.text()).toBe('ok-chat');
expect(handler).toHaveBeenCalledTimes(1);
// The raw route registered earlier is still live alongside it.
const rawRes = await post('ping');
expect(rawRes.status).toBe(200);
});
it('clears raw routes on stopWebhookServer', async () => {
await stopWebhookServer();
// Restart the server with a fresh route; the old raw routes must be gone.
registerWebhookHandler('fresh', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('fresh');
});
const stale = await post('ping');
expect(stale.status).toBe(404);
const fresh = await post('fresh');
expect(fresh.status).toBe(200);
expect(await fresh.text()).toBe('fresh');
});
});
+9 -43
View File
@@ -3,12 +3,9 @@
*
* Starts lazily on first adapter registration. Routes requests by path:
* /webhook/{adapterName} chat.webhooks[adapterName](request)
* /webhook/{path} raw handler from registerWebhookHandler(path, ...)
*
* Multiple Chat instances can register adapters each adapter name maps
* to its owning Chat instance. Raw routes let modules receive non-Chat-SDK
* webhooks (GitHub, payment providers, health checks) on the same server
* without editing this file or opening a second port.
* to its owning Chat instance.
*/
import http from 'http';
@@ -23,11 +20,7 @@ interface WebhookEntry {
adapterName: string;
}
/** Node-style handler for raw (non-Chat-SDK) webhook routes. */
export type RawWebhookHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
const routes = new Map<string, WebhookEntry>();
const rawRoutes = new Map<string, RawWebhookHandler>();
let server: http.Server | null = null;
/** Convert Node.js IncomingMessage to a Web API Request. */
@@ -91,22 +84,6 @@ export function registerWebhookAdapter(chat: Chat, adapterName: string, routingP
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${routingPath}` });
}
/**
* Register a raw Node-style handler at /webhook/{path} on the shared server.
*
* For webhooks that don't flow through a Chat SDK adapter (GitHub, payment
* providers, health checks): modules register their endpoint here instead of
* editing this file or standing up a second HTTP server on another port.
* The handler owns the request/response directly.
*
* Starts the server lazily on first call.
*/
export function registerWebhookHandler(path: string, handler: RawWebhookHandler): void {
rawRoutes.set(path, handler);
ensureServer();
log.info('Webhook handler registered', { path: `/webhook/${path}` });
}
function ensureServer(): void {
if (server) return;
@@ -124,22 +101,14 @@ function ensureServer(): void {
}
const adapterName = match[1];
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
try {
// Raw routes take priority — the handler writes the response itself.
const rawHandler = rawRoutes.get(adapterName);
if (rawHandler) {
await rawHandler(req, res);
return;
}
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
const webReq = await toWebRequest(req);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webhooks = entry.chat.webhooks as Record<string, (r: Request, opts?: any) => Promise<Response>>;
@@ -152,10 +121,8 @@ function ensureServer(): void {
await fromWebResponse(webRes, res);
} catch (err) {
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
});
@@ -170,7 +137,6 @@ export async function stopWebhookServer(): Promise<void> {
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
routes.clear();
rawRoutes.clear();
log.info('Webhook server stopped');
}
}