Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Smoke test for ADR-0030 Phase 3 — workspace_manager + dispatcher
|
||
* `RUN_DISPATCHER_PHASE='workspace_only'` integration.
|
||
*
|
||
* Runs against the LOCAL godcrm_test DB (per ADR-0009 isolation rules).
|
||
* Does NOT hit PROD or DEV remote.
|
||
*
|
||
* Cases:
|
||
* 1. createWorkspace(99991) returns valid path; dir + branch exist.
|
||
* 2. Calling createWorkspace(99991) twice — same result, no error.
|
||
* 3. listWorkspaces() includes ticket 99991.
|
||
* 4. destroyWorkspace(99991) removes worktree + branch.
|
||
* 5. Repeat destroyWorkspace(99991) → { removed: false, reason: 'not_found' }.
|
||
* 6. Parallel createWorkspace(99992 + 99993) — both succeed.
|
||
* 7. Cleanup: destroy 99992, 99993.
|
||
* 8. End-to-end: dispatcher tick with RUN_DISPATCHER_PHASE='workspace_only'
|
||
* flips test ticket → canceled with phase3_workspace_only +
|
||
* run_workspace_path set + workspace dir on disk.
|
||
*
|
||
* Cleanup: workspaces destroyed in finally; test ticket(s) deleted.
|
||
*/
|
||
|
||
// ─── Force test DB + workspace_only 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 = 'workspace_only';
|
||
process.env.NODE_ENV = 'test';
|
||
// `BUSINESS_CRM_IS_PROD` may be set on the host shell — explicitly clear it
|
||
// so the boot guard (if anything pulls in backend/test/setup.js indirectly)
|
||
// does not abort us. We otherwise mimic p2 smoke and don't import setup.js.
|
||
delete process.env.BUSINESS_CRM_IS_PROD;
|
||
|
||
import { promises as fs } from 'node:fs';
|
||
import { execFile } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
const {
|
||
createWorkspace,
|
||
destroyWorkspace,
|
||
listWorkspaces,
|
||
workspaceHealth,
|
||
WORKSPACE_ROOT,
|
||
SOURCE_REPO,
|
||
} = await import('../backend/services/agent-run-dispatcher/workspace-manager.js');
|
||
|
||
const {
|
||
runTick,
|
||
} = await import('../backend/services/agent-run-dispatcher/index.js');
|
||
|
||
const { dbGet, dbRun } = await import('../backend/database/connection.js');
|
||
|
||
const TICKETS_TABLE_ID = 1708;
|
||
const STATE_BACKLOG = 24275;
|
||
const SMOKE_TAG = 'smoke-adr0030-p3';
|
||
const ASSIGNED_TO = 'developer-ralph';
|
||
|
||
// Reserved test workspace IDs (99990–99999).
|
||
const TEST_IDS = {
|
||
primary: 99991,
|
||
parallelA: 99992,
|
||
parallelB: 99993,
|
||
};
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
const createdTicketIds = [];
|
||
const createdWorkspaceIds = new Set();
|
||
|
||
function assert(label, cond, extra = '') {
|
||
if (cond) {
|
||
console.log(` ✅ ${label}`);
|
||
pass++;
|
||
} else {
|
||
console.log(` ❌ ${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;
|
||
}
|
||
|
||
async function createTestTicket() {
|
||
const data = {
|
||
state: STATE_BACKLOG,
|
||
assigned_to: ASSIGNED_TO,
|
||
what: SMOKE_TAG,
|
||
created_by: 'manual-test',
|
||
smoke_tag: SMOKE_TAG,
|
||
run_state: 'idle',
|
||
};
|
||
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`,
|
||
[TICKETS_TABLE_ID, genBaseId(), JSON.stringify(data)]
|
||
);
|
||
createdTicketIds.push(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;
|
||
}
|
||
|
||
async function gitBranchExists(branch) {
|
||
try {
|
||
await execFileAsync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
||
cwd: SOURCE_REPO,
|
||
});
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function dirExists(p) {
|
||
try {
|
||
const s = await fs.stat(p);
|
||
return s.isDirectory();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function cleanup() {
|
||
// Destroy any workspaces we created (best-effort).
|
||
for (const id of createdWorkspaceIds) {
|
||
try {
|
||
await destroyWorkspace(id);
|
||
} catch (err) {
|
||
console.error(` cleanup: failed destroying workspace T-${id}:`, err.message);
|
||
}
|
||
}
|
||
// Delete any tickets we created + any with our smoke tag (catches orphans).
|
||
if (createdTicketIds.length > 0) {
|
||
await dbRun(
|
||
`DELETE FROM table_rows WHERE table_id = $1 AND id = ANY($2::int[])`,
|
||
[TICKETS_TABLE_ID, createdTicketIds]
|
||
);
|
||
}
|
||
await dbRun(
|
||
`DELETE FROM table_rows WHERE table_id = $1 AND data->>'smoke_tag' = $2`,
|
||
[TICKETS_TABLE_ID, SMOKE_TAG]
|
||
);
|
||
}
|
||
|
||
async function main() {
|
||
console.log('ADR-0030 Phase 3 smoke test (godcrm_test) — start');
|
||
console.log(` WORKSPACE_ROOT=${WORKSPACE_ROOT} RUN_DISPATCHER_PHASE=${process.env.RUN_DISPATCHER_PHASE}\n`);
|
||
|
||
// Pre-flight: scrub anything left behind by prior interrupted runs.
|
||
for (const id of Object.values(TEST_IDS)) {
|
||
try { await destroyWorkspace(id); } catch { /* best-effort */ }
|
||
}
|
||
await dbRun(
|
||
`DELETE FROM table_rows WHERE table_id = $1 AND data->>'smoke_tag' = $2`,
|
||
[TICKETS_TABLE_ID, SMOKE_TAG]
|
||
);
|
||
|
||
// ── Case 1: createWorkspace returns valid path; dir + branch exist
|
||
console.log('Case 1: createWorkspace(99991) creates worktree + branch');
|
||
{
|
||
const id = TEST_IDS.primary;
|
||
const ws = await createWorkspace(id);
|
||
createdWorkspaceIds.add(id);
|
||
assert('returns path', typeof ws.path === 'string' && ws.path.length > 0, JSON.stringify(ws));
|
||
assert('returns branch', ws.branch === `run/T-${id}`, `got ${ws.branch}`);
|
||
assert('returns createdAt', !!ws.createdAt);
|
||
assert('path is /root/workspaces/T-99991', ws.path === `/root/workspaces/T-${id}`, ws.path);
|
||
assert('dir exists on disk', await dirExists(ws.path));
|
||
assert('branch exists in git', await gitBranchExists(`run/T-${id}`));
|
||
}
|
||
|
||
// ── Case 2: Idempotent — second call returns same result
|
||
console.log('\nCase 2: second createWorkspace(99991) returns same workspace (idempotent)');
|
||
{
|
||
const id = TEST_IDS.primary;
|
||
const ws = await createWorkspace(id);
|
||
assert('same path on second call', ws.path === `/root/workspaces/T-${id}`, ws.path);
|
||
assert('same branch on second call', ws.branch === `run/T-${id}`);
|
||
assert('reused flag set', ws.reused === true, `reused=${ws.reused}`);
|
||
}
|
||
|
||
// ── Case 3: listWorkspaces includes our workspace
|
||
console.log('\nCase 3: listWorkspaces() includes ticket 99991');
|
||
{
|
||
const list = await listWorkspaces();
|
||
assert('list is array', Array.isArray(list));
|
||
const found = list.find((w) => w.ticketId === TEST_IDS.primary);
|
||
assert('found T-99991 in list', !!found, `list ids=${list.map(w => w.ticketId).join(',')}`);
|
||
if (found) {
|
||
assert('list entry has correct branch', found.branch === `run/T-${TEST_IDS.primary}`, found.branch);
|
||
assert('list entry has correct path', found.path === `/root/workspaces/T-${TEST_IDS.primary}`);
|
||
}
|
||
}
|
||
|
||
// ── Case 4: destroyWorkspace removes worktree + branch
|
||
console.log('\nCase 4: destroyWorkspace(99991) removes worktree + branch');
|
||
{
|
||
const id = TEST_IDS.primary;
|
||
const res = await destroyWorkspace(id);
|
||
createdWorkspaceIds.delete(id);
|
||
assert('returns removed=true', res.removed === true, JSON.stringify(res));
|
||
assert('dir gone from disk', !(await dirExists(`/root/workspaces/T-${id}`)));
|
||
assert('branch gone from git', !(await gitBranchExists(`run/T-${id}`)));
|
||
const list = await listWorkspaces();
|
||
assert('list no longer contains 99991', !list.find((w) => w.ticketId === id));
|
||
}
|
||
|
||
// ── Case 5: Idempotent destroy
|
||
console.log('\nCase 5: second destroyWorkspace(99991) returns not_found');
|
||
{
|
||
const id = TEST_IDS.primary;
|
||
const res = await destroyWorkspace(id);
|
||
assert('removed=false', res.removed === false, JSON.stringify(res));
|
||
assert('reason=not_found', res.reason === 'not_found', JSON.stringify(res));
|
||
}
|
||
|
||
// ── Case 6: parallel creates
|
||
console.log('\nCase 6: parallel createWorkspace(99992 + 99993)');
|
||
{
|
||
const [a, b] = await Promise.all([
|
||
createWorkspace(TEST_IDS.parallelA),
|
||
createWorkspace(TEST_IDS.parallelB),
|
||
]);
|
||
createdWorkspaceIds.add(TEST_IDS.parallelA);
|
||
createdWorkspaceIds.add(TEST_IDS.parallelB);
|
||
assert('A path correct', a.path === `/root/workspaces/T-${TEST_IDS.parallelA}`, a.path);
|
||
assert('B path correct', b.path === `/root/workspaces/T-${TEST_IDS.parallelB}`, b.path);
|
||
assert('A branch correct', a.branch === `run/T-${TEST_IDS.parallelA}`);
|
||
assert('B branch correct', b.branch === `run/T-${TEST_IDS.parallelB}`);
|
||
assert('A dir exists', await dirExists(a.path));
|
||
assert('B dir exists', await dirExists(b.path));
|
||
assert('paths distinct', a.path !== b.path);
|
||
}
|
||
|
||
// ── Case 7: cleanup of parallel pair
|
||
console.log('\nCase 7: destroy parallel pair');
|
||
{
|
||
const ra = await destroyWorkspace(TEST_IDS.parallelA);
|
||
const rb = await destroyWorkspace(TEST_IDS.parallelB);
|
||
createdWorkspaceIds.delete(TEST_IDS.parallelA);
|
||
createdWorkspaceIds.delete(TEST_IDS.parallelB);
|
||
assert('A removed=true', ra.removed === true, JSON.stringify(ra));
|
||
assert('B removed=true', rb.removed === true, JSON.stringify(rb));
|
||
assert('A dir gone', !(await dirExists(`/root/workspaces/T-${TEST_IDS.parallelA}`)));
|
||
assert('B dir gone', !(await dirExists(`/root/workspaces/T-${TEST_IDS.parallelB}`)));
|
||
}
|
||
|
||
// ── Case 8: dispatcher end-to-end with workspace_only phase
|
||
console.log('\nCase 8: dispatcher tick → workspace materialized + ticket canceled');
|
||
{
|
||
const ticketId = await createTestTicket();
|
||
const stats = await runTick({ source: 'smoke_p3_case_8' });
|
||
assert('tick picked >= 1', stats.picked >= 1, JSON.stringify(stats));
|
||
assert('tick reports workspaces_created >= 1', stats.workspaces_created >= 1, JSON.stringify(stats));
|
||
assert('tick reports canceled_workspace_only >= 1', stats.canceled_workspace_only >= 1, JSON.stringify(stats));
|
||
|
||
const data = await getTicketRunData(ticketId);
|
||
assert('ticket run_state == canceled', data.run_state === 'canceled', `got ${data.run_state}`);
|
||
assert(
|
||
'ticket terminal_reason == phase3_workspace_only',
|
||
data.run_terminal_reason === 'phase3_workspace_only',
|
||
`got ${data.run_terminal_reason}`
|
||
);
|
||
assert('ticket has run_workspace_path', !!data.run_workspace_path, `got ${data.run_workspace_path}`);
|
||
assert(
|
||
'workspace path matches expected pattern',
|
||
data.run_workspace_path && data.run_workspace_path.startsWith(`/root/workspaces/T-`),
|
||
data.run_workspace_path
|
||
);
|
||
assert('workspace dir exists on disk', await dirExists(data.run_workspace_path));
|
||
|
||
// Audit log should record both transitions w/ workspace info on the cancel entry.
|
||
const audit = data.run_audit_log || [];
|
||
assert('audit log has >= 2 entries', audit.length >= 2, `got ${audit.length}`);
|
||
const cancelEntry = audit.find((e) => e.reason === 'phase3_workspace_only');
|
||
assert('audit log includes phase3_workspace_only reason', !!cancelEntry);
|
||
if (cancelEntry) {
|
||
assert('cancel audit entry carries workspace_path', !!cancelEntry.workspace_path);
|
||
assert('cancel audit entry carries branch', !!cancelEntry.branch);
|
||
}
|
||
|
||
// Cleanup: the dispatcher does NOT remove the workspace in P3 (Phase 4
|
||
// owns lifecycle). We track it here for the finally block to nuke.
|
||
if (data.run_workspace_path) {
|
||
const m = data.run_workspace_path.match(/T-(\d+)$/);
|
||
if (m) createdWorkspaceIds.add(Number(m[1]));
|
||
}
|
||
}
|
||
|
||
// ── workspaceHealth sanity
|
||
console.log('\nBonus: workspaceHealth() reports root + counts');
|
||
{
|
||
const h = await workspaceHealth();
|
||
assert('health.ok == true', h.ok === true, JSON.stringify(h));
|
||
assert('health.workspaceRoot set', h.workspaceRoot === '/root/workspaces', h.workspaceRoot);
|
||
assert('health.count is numeric', typeof h.count === 'number');
|
||
assert('health.orphaned is array', Array.isArray(h.orphaned));
|
||
}
|
||
|
||
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);
|
||
});
|