Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Smoke test for ADR-0030 Phase 4 — buildRunPrompt + runStreamHandler +
|
|
* dispatcher 'live' phase.
|
|
*
|
|
* Runs against the LOCAL godcrm_test DB (per ADR-0009 isolation rules).
|
|
* Does NOT hit PROD or DEV. Does NOT spawn the real `claude --print`
|
|
* binary — every case uses a stub script via RUN_CLAUDE_SCRIPT_OVERRIDE.
|
|
*
|
|
* Cases:
|
|
* 1. buildRunPrompt({ticketId, agentId}) returns prompt containing
|
|
* ticket title + agent name + role section.
|
|
* 2. runStreamHandler with stub emitting 3 events, exit 0 → success
|
|
* summary, eventCount===3.
|
|
* 3. runStreamHandler with stub emitting 1 event then exit 1 → failed
|
|
* summary with lastError populated.
|
|
* 4. runStreamHandler with stub sleeping forever, timeoutMs=2000 →
|
|
* timeout summary.
|
|
* 5. End-to-end: dispatcher tick with RUN_DISPATCHER_PHASE='live' and a
|
|
* stub script → ticket flips to run_state='succeeded',
|
|
* run_terminal_reason='completed'. Workspace destroyed in finally.
|
|
*
|
|
* Test ticket id range: 99980-99989 (P3 used 99990-99999).
|
|
*/
|
|
|
|
// ─── Force test DB + live phase BEFORE any module import ───────────
|
|
process.env.POSTGRES_HOST = 'localhost';
|
|
process.env.POSTGRES_DB = 'godcrm_test';
|
|
process.env.POSTGRES_USER = 'godcrm';
|
|
process.env.POSTGRES_PASSWORD = 'godcrm_dev_2026';
|
|
process.env.POSTGRES_PORT = '5432';
|
|
process.env.AGENT_RUN_DISPATCHER_ENABLED = 'false'; // we drive ticks manually
|
|
process.env.RUN_DISPATCHER_PHASE = 'live';
|
|
process.env.NODE_ENV = 'test';
|
|
delete process.env.BUSINESS_CRM_IS_PROD;
|
|
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
const { buildRunPrompt } = await import(
|
|
'../backend/services/agent-run-dispatcher/build-run-prompt.mjs'
|
|
);
|
|
const { runStreamHandler } = await import(
|
|
'../backend/services/agent-run-dispatcher/run-stream-handler.mjs'
|
|
);
|
|
const { runTick } = await import(
|
|
'../backend/services/agent-run-dispatcher/index.js'
|
|
);
|
|
const { destroyWorkspace } = await import(
|
|
'../backend/services/agent-run-dispatcher/workspace-manager.js'
|
|
);
|
|
const { dbGet, dbRun } = await import('../backend/database/connection.js');
|
|
|
|
const TICKETS_TABLE_ID = 1708;
|
|
const AGENTS_TABLE_ID = 1784;
|
|
const STATE_BACKLOG = 24275;
|
|
const SMOKE_TAG = 'smoke-adr0030-p4';
|
|
// Concrete row ids assigned at insertion time — we let Postgres pick from the
|
|
// id_seq because the test DB lacks the PK constraint required for ON CONFLICT.
|
|
// Brief reserves 99980-99989 for tagging only; we filter by smoke_tag in cleanup.
|
|
const RUNTIME_IDS = {
|
|
ticketPrompt: null,
|
|
agentPrompt: null,
|
|
ticketE2E: null,
|
|
agentE2E: null,
|
|
};
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const insertedRowIds = []; // track all rows we insert so cleanup nukes them
|
|
const stubFiles = []; // track stub scripts so we can rm them
|
|
|
|
function assert(label, cond, extra = '') {
|
|
if (cond) { console.log(` PASS ${label}`); pass++; }
|
|
else { console.log(` FAIL ${label} ${extra}`); fail++; }
|
|
}
|
|
|
|
function genBaseId() {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let s = '';
|
|
for (let i = 0; i < 8; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Insert a row into table_rows. The id_seq picks the id; we capture and
|
|
* return it. We can't force ids in 99980-99989 because the test DB lacks
|
|
* the PK index needed for ON CONFLICT — we rely on smoke_tag for cleanup
|
|
* instead.
|
|
*/
|
|
async function insertRow(tableId, data) {
|
|
const row = await dbGet(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES ($1, $2, $3::jsonb, NOW(), NOW())
|
|
RETURNING id`,
|
|
[tableId, genBaseId(), JSON.stringify(data)]
|
|
);
|
|
insertedRowIds.push({ table_id: tableId, id: row.id });
|
|
return row.id;
|
|
}
|
|
|
|
async function getTicketRunData(ticketId) {
|
|
const row = await dbGet(
|
|
`SELECT data FROM table_rows WHERE table_id = $1 AND id = $2`,
|
|
[TICKETS_TABLE_ID, ticketId]
|
|
);
|
|
if (!row?.data) return null;
|
|
return typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
}
|
|
|
|
/**
|
|
* Write a stub script to a temp file, chmod +x, return path.
|
|
*/
|
|
async function writeStubScript(name, body) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'p4-stub-'));
|
|
const file = path.join(dir, name);
|
|
await fs.writeFile(file, body, { mode: 0o755 });
|
|
// mkdtemp + writeFile may not honor mode on some hosts — chmod explicitly.
|
|
await fs.chmod(file, 0o755);
|
|
stubFiles.push(file);
|
|
return file;
|
|
}
|
|
|
|
async function cleanup() {
|
|
// Restore env so it doesn't leak.
|
|
delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
|
|
// Nuke any workspaces we may have created via the live tick.
|
|
if (RUNTIME_IDS.ticketE2E) {
|
|
try { await destroyWorkspace(RUNTIME_IDS.ticketE2E); } catch { /* best-effort */ }
|
|
}
|
|
|
|
// Delete inserted table_rows.
|
|
if (insertedRowIds.length > 0) {
|
|
const ids = insertedRowIds.map((r) => r.id);
|
|
await dbRun(
|
|
`DELETE FROM table_rows WHERE id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
}
|
|
// Catch orphans by smoke_tag.
|
|
await dbRun(
|
|
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
|
|
[SMOKE_TAG]
|
|
);
|
|
|
|
// Best-effort: rm any messages we may have inserted (none expected for
|
|
// case 5 because no conversation is bound — but cheap to clean by pattern).
|
|
try {
|
|
const e2e = RUNTIME_IDS.ticketE2E;
|
|
const prompt = RUNTIME_IDS.ticketPrompt;
|
|
const ids = [e2e, prompt].filter((x) => Number.isInteger(x));
|
|
if (ids.length > 0) {
|
|
await dbRun(
|
|
`DELETE FROM messages WHERE bound_table_id = $1 AND bound_row_id = ANY($2::int[])`,
|
|
[TICKETS_TABLE_ID, ids]
|
|
);
|
|
}
|
|
} catch { /* messages may not have been written */ }
|
|
|
|
// Remove stub scripts + their temp dirs.
|
|
for (const f of stubFiles) {
|
|
try {
|
|
await fs.rm(path.dirname(f), { recursive: true, force: true });
|
|
} catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('ADR-0030 Phase 4 smoke test (godcrm_test) — start');
|
|
console.log(` RUN_DISPATCHER_PHASE=${process.env.RUN_DISPATCHER_PHASE}`);
|
|
|
|
// Pre-flight: scrub anything left behind by prior interrupted runs.
|
|
await dbRun(
|
|
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
|
|
[SMOKE_TAG]
|
|
);
|
|
|
|
// ── Case 1: buildRunPrompt
|
|
console.log('\nCase 1: buildRunPrompt returns prompt with ticket title + agent name');
|
|
{
|
|
RUNTIME_IDS.agentPrompt = await insertRow(AGENTS_TABLE_ID, {
|
|
name: 'Smoke Agent Prompt',
|
|
system_prompt: 'You write concise replies. SMOKE_AGENT_MARKER',
|
|
smoke_tag: SMOKE_TAG,
|
|
});
|
|
RUNTIME_IDS.ticketPrompt = await insertRow(TICKETS_TABLE_ID, {
|
|
state: STATE_BACKLOG,
|
|
assigned_to: String(RUNTIME_IDS.agentPrompt),
|
|
title: 'Smoke prompt ticket title XYZ',
|
|
description: 'Some description body for smoke test',
|
|
story: 'As a smoke test, I want a story field rendered.',
|
|
smoke_tag: SMOKE_TAG,
|
|
run_state: 'idle',
|
|
});
|
|
const result = await buildRunPrompt({
|
|
ticketId: RUNTIME_IDS.ticketPrompt,
|
|
agentId: RUNTIME_IDS.agentPrompt,
|
|
});
|
|
assert('prompt is string', typeof result.prompt === 'string' && result.prompt.length > 0);
|
|
assert('contains ticket title', result.prompt.includes('Smoke prompt ticket title XYZ'),
|
|
`prompt head: ${result.prompt.slice(0, 200)}`);
|
|
assert('contains agent name', result.prompt.includes('Smoke Agent Prompt'));
|
|
assert('contains agent system_prompt marker', result.prompt.includes('SMOKE_AGENT_MARKER'));
|
|
assert('contains description section', result.prompt.includes('## Description'));
|
|
assert('contains story section', result.prompt.includes('## Story'));
|
|
assert('contains constraints section', result.prompt.includes('## Constraints'));
|
|
assert('contains role header with agent name',
|
|
result.prompt.includes('## Your role: Smoke Agent Prompt'));
|
|
assert('returns agentRow', !!result.agentRow);
|
|
assert('returns ticketRow', !!result.ticketRow);
|
|
assert('returns resolvedAt ISO', typeof result.resolvedAt === 'string' && result.resolvedAt.length > 10);
|
|
}
|
|
|
|
// ── Case 2: stream handler — 3 events + exit 0 → success
|
|
console.log('\nCase 2: runStreamHandler success path (3 events, exit 0)');
|
|
{
|
|
const stub = await writeStubScript('stub-success.sh', `#!/usr/bin/env bash
|
|
set -e
|
|
# Ignore args; consume stdin so caller's pipe doesn't block.
|
|
cat > /dev/null
|
|
echo '{"type":"info","message":"stub_started"}'
|
|
echo '{"type":"info","message":"stub_progress"}'
|
|
echo '{"type":"output","content":"hi from stub","status":"success","exit":0}'
|
|
exit 0
|
|
`);
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
|
|
const events = [];
|
|
const summary = await runStreamHandler({
|
|
ticketId: 99980,
|
|
workspacePath: '/tmp',
|
|
prompt: 'hello',
|
|
agentId: 0,
|
|
onEvent: (e) => events.push(e),
|
|
timeoutMs: 10_000,
|
|
heartbeatMs: 50_000,
|
|
});
|
|
assert('exitCode === 0', summary.exitCode === 0, JSON.stringify(summary));
|
|
assert('finalStatus === success', summary.finalStatus === 'success', JSON.stringify(summary));
|
|
assert('eventCount === 3', summary.eventCount === 3, `got ${summary.eventCount}`);
|
|
assert('events array length matches', events.length === 3, `got ${events.length}`);
|
|
assert('no lastError on success', !summary.lastError, JSON.stringify(summary));
|
|
}
|
|
|
|
// ── Case 3: stream handler — 1 event then exit 1 → failed
|
|
console.log('\nCase 3: runStreamHandler failed path (1 event, exit 1)');
|
|
{
|
|
const stub = await writeStubScript('stub-fail.sh', `#!/usr/bin/env bash
|
|
cat > /dev/null
|
|
echo '{"type":"info","message":"stub_about_to_fail"}'
|
|
echo "diagnostic line on stderr" >&2
|
|
echo "second stderr line" >&2
|
|
exit 1
|
|
`);
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
|
|
const summary = await runStreamHandler({
|
|
ticketId: 99980,
|
|
workspacePath: '/tmp',
|
|
prompt: 'hello',
|
|
agentId: 0,
|
|
timeoutMs: 10_000,
|
|
heartbeatMs: 50_000,
|
|
});
|
|
assert('exitCode === 1', summary.exitCode === 1, JSON.stringify(summary));
|
|
assert('finalStatus === failed', summary.finalStatus === 'failed', JSON.stringify(summary));
|
|
assert('eventCount === 1', summary.eventCount === 1, `got ${summary.eventCount}`);
|
|
assert('lastError populated', !!summary.lastError, JSON.stringify(summary));
|
|
assert('lastError mentions diagnostic',
|
|
summary.lastError && summary.lastError.includes('diagnostic'),
|
|
JSON.stringify(summary.lastError));
|
|
}
|
|
|
|
// ── Case 4: stream handler — sleep forever, timeoutMs=2000
|
|
console.log('\nCase 4: runStreamHandler timeout path');
|
|
{
|
|
const stub = await writeStubScript('stub-sleep.sh', `#!/usr/bin/env bash
|
|
cat > /dev/null
|
|
echo '{"type":"info","message":"stub_sleeping"}'
|
|
sleep 60
|
|
exit 0
|
|
`);
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
|
|
const t0 = Date.now();
|
|
const summary = await runStreamHandler({
|
|
ticketId: 99980,
|
|
workspacePath: '/tmp',
|
|
prompt: 'hello',
|
|
agentId: 0,
|
|
timeoutMs: 2_000,
|
|
heartbeatMs: 50_000,
|
|
});
|
|
const elapsed = Date.now() - t0;
|
|
assert('finalStatus === timeout', summary.finalStatus === 'timeout', JSON.stringify(summary));
|
|
assert('elapsed near timeoutMs (2-10s)', elapsed >= 2000 && elapsed < 10_000, `elapsed=${elapsed}ms`);
|
|
assert('eventCount >= 1', summary.eventCount >= 1, JSON.stringify(summary));
|
|
}
|
|
|
|
// ── Case 5: end-to-end dispatcher tick with stub
|
|
console.log('\nCase 5: end-to-end dispatcher tick → ticket flips to succeeded');
|
|
{
|
|
// Stub that just confirms the prompt arrived (cat to /dev/null) + emits
|
|
// a tiny success result. Faster than running real claude.
|
|
const stub = await writeStubScript('stub-e2e.sh', `#!/usr/bin/env bash
|
|
# Drain stdin so the parent's pipe doesn't block.
|
|
PROMPT=$(cat)
|
|
echo '{"type":"info","message":"e2e_stub_started"}'
|
|
# Emit an output event with a fixed string so we can verify chat-post path
|
|
# (will silently no-op since no conversation is bound).
|
|
echo '{"type":"output","content":"E2E stub output content.","status":"success","exit":0}'
|
|
echo '{"type":"result","status":"success","exit":0}'
|
|
exit 0
|
|
`);
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
|
|
|
|
RUNTIME_IDS.agentE2E = await insertRow(AGENTS_TABLE_ID, {
|
|
name: 'Smoke Agent E2E',
|
|
system_prompt: 'Be brief.',
|
|
smoke_tag: SMOKE_TAG,
|
|
});
|
|
RUNTIME_IDS.ticketE2E = await insertRow(TICKETS_TABLE_ID, {
|
|
state: STATE_BACKLOG,
|
|
assigned_to: String(RUNTIME_IDS.agentE2E),
|
|
title: 'E2E phase4 smoke ticket',
|
|
what: 'E2E phase4 smoke ticket',
|
|
smoke_tag: SMOKE_TAG,
|
|
run_state: 'idle',
|
|
});
|
|
|
|
const stats = await runTick({ source: 'smoke_p4_case_5' });
|
|
assert('tick picked >= 1', stats.picked >= 1, JSON.stringify(stats));
|
|
assert('tick reports live_succeeded >= 1',
|
|
stats.live_succeeded >= 1, JSON.stringify(stats));
|
|
assert('tick reports workspaces_created >= 1',
|
|
stats.workspaces_created >= 1, JSON.stringify(stats));
|
|
|
|
const data = await getTicketRunData(RUNTIME_IDS.ticketE2E);
|
|
assert('ticket has data', !!data, 'ticket vanished');
|
|
assert('run_state === succeeded',
|
|
data.run_state === 'succeeded', `got ${data?.run_state}`);
|
|
assert('run_terminal_reason === completed',
|
|
data.run_terminal_reason === 'completed', `got ${data?.run_terminal_reason}`);
|
|
assert('run_finished_at set', !!data.run_finished_at);
|
|
assert('run_exit_code === 0', data.run_exit_code === 0, `got ${data?.run_exit_code}`);
|
|
assert('run_duration_ms is number', typeof data.run_duration_ms === 'number');
|
|
assert('run_event_count >= 3', (data.run_event_count ?? 0) >= 3, `got ${data?.run_event_count}`);
|
|
|
|
// Audit log should record claim + start + terminal.
|
|
const audit = data.run_audit_log || [];
|
|
assert('audit has >= 3 entries', audit.length >= 3, `got ${audit.length}`);
|
|
const startEntry = audit.find((e) => e.reason === 'live_run_started');
|
|
assert('audit has live_run_started entry', !!startEntry);
|
|
const terminalEntry = audit.find((e) => e.reason === 'completed');
|
|
assert('audit has completed entry', !!terminalEntry);
|
|
|
|
// Workspace must be cleaned up (Phase 4 owns lifecycle).
|
|
let dirGone = false;
|
|
try {
|
|
await fs.access(`/root/workspaces/T-${RUNTIME_IDS.ticketE2E}`);
|
|
} catch {
|
|
dirGone = true;
|
|
}
|
|
assert('workspace dir removed after run', dirGone);
|
|
}
|
|
|
|
console.log(`\n${pass + fail} assertions: ${pass} pass / ${fail} fail`);
|
|
if (fail === 0) console.log('all assertions passed');
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error('SMOKE FAILED with exception:', err);
|
|
fail++;
|
|
})
|
|
.finally(async () => {
|
|
try { await cleanup(); } catch (err) { console.error('cleanup error:', err); }
|
|
process.exit(fail === 0 ? 0 : 1);
|
|
});
|