Compare commits

..

5 Commits

Author SHA1 Message Date
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
23 changed files with 288 additions and 395 deletions
-3
View File
@@ -15,8 +15,6 @@ 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;
@@ -46,7 +44,6 @@ 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,
@@ -1,193 +0,0 @@
/**
* 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');
});
});
@@ -1,40 +0,0 @@
/**
* 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;
},
};
}
+7
View File
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { buildSystemPromptAddendum } from './destinations.js';
import { ensureMemoryScaffold } from './memory-scaffold.js';
// Providers barrel — each enabled provider self-registers on import.
// Provider skills append imports to providers/index.ts.
import './providers/index.js';
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
effort: config.effort,
});
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
// the runner creates a persistent memory/ tree in its host-backed workspace at
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
// and keeps its native memory untouched.
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
await runPollLoop({
provider,
providerName,
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { ensureMemoryScaffold } from './memory-scaffold.js';
describe('ensureMemoryScaffold', () => {
it('deterministically creates the memory tree', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it('is idempotent and never clobbers the agent edits', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
const indexFile = path.join(base, 'memory', 'index.md');
fs.writeFileSync(indexFile, '# my own index\n');
ensureMemoryScaffold(base);
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
/**
* Create the agent's persistent memory scaffold, container-side, at boot.
*
* The runner owns its own workspace: it writes the memory tree straight into
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
* ephemeral container). No host-side step, nothing mounted in.
*
* The default `definition.md` / `index.md` live as real markdown templates next
* to this module (under `memory-templates/`) — not as strings in code — so the
* doctrine is editable as markdown and the agent receives an unescaped copy.
* They ship in the mounted `/app/src` tree, so no image change is needed.
*
* Idempotent — only writes what's missing, so the agent's own edits and
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
* the runner makes no assumption about which harness is running — a provider
* opts in via `usesMemoryScaffold`.
*/
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
const memoryDir = path.join(baseDir, 'memory');
const systemDir = path.join(memoryDir, 'system');
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
fs.mkdirSync(dir, { recursive: true });
}
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
}
function copyTemplateIfMissing(template: string, dest: string): void {
if (fs.existsSync(dest)) return;
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import path from 'path';
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
// test goes red. main() can't be driven in-process (it reads
// /workspace/agent/container.json and enters the poll loop), so the guard is
// structural: gate + import must both be present in the real entry point.
describe('memory scaffold boot wiring', () => {
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
it('gates the scaffold on the provider capability in main()', () => {
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
});
it('imports ensureMemoryScaffold from the seam module', () => {
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
});
});
@@ -0,0 +1,23 @@
# Agent Memory System
This editable file defines how your persistent memory works. It is a starting
point, not a contract — reorganize it as the work demands. If the user or another
memory system replaces this definition, follow the replacement.
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
Treat indexes as core data: keep them accurate and concise.
Every folder of durable memory has its own `index.md` describing its contents.
When an index grows past roughly 20 entries, group related items into subfolders,
and give each new subfolder its own `index.md` linked from the parent.
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
Use entity folders for things that matter: projects, people, places, organizations, decisions.
When the user shares something that should survive future turns, store it in the
smallest useful file; prefer updating an existing file over creating duplicates.
Write concise, source-aware notes; include dates when timing matters.
If a fact is corrected, update the memory and keep only useful history.
When you add, move, or remove memory, update the nearest index.
Before answering from memory, read the relevant index or file instead of guessing;
if memory is missing or uncertain, say so and verify when it matters.
@@ -0,0 +1,5 @@
# Memory Index
- [Memory system definition](system/definition.md)
- [Memories](memories/) - durable facts, people, projects, decisions
- [Data](data/) - structured reference data
+2 -24
View File
@@ -4,8 +4,6 @@ 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,
@@ -106,12 +104,6 @@ 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) {
@@ -126,10 +118,6 @@ 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;
}
@@ -244,7 +232,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, idleTimeoutMs);
const result = await processQuery(query, routing, processingIds, config.providerName);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
@@ -278,7 +266,6 @@ 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)`);
}
}
@@ -326,7 +313,6 @@ async function processQuery(
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
idleTimeoutMs: number = 0,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
@@ -468,9 +454,8 @@ 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) {
({ hasUnwrapped } = dispatchResultText(event.text, routing));
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
@@ -483,13 +468,6 @@ 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 {
@@ -6,6 +6,14 @@ export interface AgentProvider {
*/
readonly supportsNativeSlashCommands: boolean;
/**
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
* agent's workspace at boot. Providers with their own native memory (e.g.
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
* provider, never gated on a provider name.
*/
readonly usesMemoryScaffold?: boolean;
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
-2
View File
@@ -310,7 +310,6 @@ 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 '[]',
@@ -345,7 +344,6 @@ 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.10",
"version": "2.1.11",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
-1
View File
@@ -59,7 +59,6 @@ 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 ?? []),
+3 -12
View File
@@ -22,7 +22,6 @@ 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),
@@ -214,7 +213,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, --idle-timeout-ms, --cli-scope.',
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
@@ -224,14 +223,7 @@ registerResource({
const updates: Partial<
Pick<
ContainerConfigRow,
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
> = {};
if (args.provider !== undefined) updates.provider = args.provider as string;
@@ -241,7 +233,6 @@ 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)) {
@@ -252,7 +243,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, --idle-timeout-ms, --cli-scope',
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
);
}
-82
View File
@@ -1,82 +0,0 @@
/**
* 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,8 +41,6 @@ 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;
}
@@ -63,7 +61,6 @@ 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,
};
+1 -9
View File
@@ -8,7 +8,6 @@ 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']);
@@ -56,14 +55,7 @@ export function updateContainerConfigScalars(
updates: Partial<
Pick<
ContainerConfigRow,
| 'provider'
| 'model'
| 'effort'
| 'image_tag'
| 'assistant_name'
| 'max_messages_per_prompt'
| 'idle_timeout_ms'
| 'cli_scope'
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
>,
): void {
@@ -1,13 +0,0 @@
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,7 +13,6 @@ 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';
@@ -47,7 +46,6 @@ export const migrations: Migration[] = [
migration014,
migration015,
migration016,
migration017,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
-1
View File
@@ -19,7 +19,6 @@ 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
@@ -0,0 +1,97 @@
/**
* 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');
});
});
+43 -9
View File
@@ -3,9 +3,12 @@
*
* 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.
* 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.
*/
import http from 'http';
@@ -20,7 +23,11 @@ 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. */
@@ -84,6 +91,22 @@ 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;
@@ -101,14 +124,22 @@ 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>>;
@@ -121,8 +152,10 @@ function ensureServer(): void {
await fromWebResponse(webRes, res);
} catch (err) {
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
}
});
@@ -137,6 +170,7 @@ 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');
}
}