godcrm/scripts/smoke-adr0030-p3.mjs
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

334 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (9999099999).
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);
});