Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
177 lines
7.1 KiB
JavaScript
177 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// ADR-0003 §C-3 smoke — regression watcher.
|
|
//
|
|
// S1: bootstrap a throwaway verified criterion + bdd_link → ticket.
|
|
// S2: fire onTicketStateTransition with oldState=done, newState=in-progress.
|
|
// S3: assert criterion.status=='regressed', regressed_by_ticket_id is set,
|
|
// and a bdd_audit_log row exists with action='regress' + caused_by.
|
|
// S4: gate check — fire with newState=done (idempotent no-op) and with
|
|
// wrong table id (no-op).
|
|
// S5: cleanup everything inserted by this smoke.
|
|
|
|
// ADR-0009 Phase 5: boot guard — aborts (exit 2) if POSTGRES_HOST is PROD.
|
|
import '../backend/test/setup.js';
|
|
import pg from 'pg';
|
|
import { onTicketStateTransition } from '../backend/services/bdd/regressionWatcher.js';
|
|
|
|
const { Pool } = pg;
|
|
const pool = new Pool({
|
|
host: process.env.POSTGRES_HOST || 'localhost',
|
|
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
|
|
database: process.env.POSTGRES_DB || 'godcrm_prod',
|
|
user: process.env.POSTGRES_USER || 'godcrm',
|
|
password: process.env.POSTGRES_PASSWORD || 'godcrm_dev_2026',
|
|
});
|
|
|
|
const TICKETS_TABLE_ID = 1708;
|
|
const DONE_STATE_ID = 24278;
|
|
const INPROGRESS_STATE_ID = 24276;
|
|
const FAKE_TICKET_ID = 999999990; // never collides with a real ticket row
|
|
|
|
async function tableId(client, name) {
|
|
const { rows } = await client.query(`
|
|
SELECT ut.id FROM universal_tables ut
|
|
JOIN projects p ON p.id = ut.project_id
|
|
WHERE p.space_id = 11 AND ut.name = $1 LIMIT 1
|
|
`, [name]);
|
|
return rows[0]?.id;
|
|
}
|
|
|
|
async function insertRow(client, tid, data) {
|
|
const baseId = `SMK-${Date.now().toString(36).toUpperCase()}-${Math.floor(Math.random() * 1000)}`;
|
|
const { rows } = await client.query(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES ($1, $2, $3::jsonb, NOW(), NOW()) RETURNING id`,
|
|
[tid, baseId, JSON.stringify(data)]
|
|
);
|
|
return rows[0].id;
|
|
}
|
|
|
|
async function waitFor(predicate, { tries = 20, gapMs = 100 } = {}) {
|
|
for (let i = 0; i < tries; i++) {
|
|
if (await predicate()) return true;
|
|
await new Promise(r => setTimeout(r, gapMs));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function main() {
|
|
const client = await pool.connect();
|
|
const created = { critRowId: null, linkRowId: null, auditRowIds: [] };
|
|
try {
|
|
const critTid = await tableId(client, 'bdd_criteria');
|
|
const linksTid = await tableId(client, 'bdd_links');
|
|
const auditTid = await tableId(client, 'bdd_audit_log');
|
|
if (!critTid || !linksTid || !auditTid) {
|
|
throw new Error(`missing BDD tables (crit=${critTid}, links=${linksTid}, audit=${auditTid})`);
|
|
}
|
|
console.log(`bootstrap ✓ tables: crit=${critTid} links=${linksTid} audit=${auditTid}`);
|
|
|
|
// S1: seed a verified criterion + link to FAKE_TICKET_ID
|
|
created.critRowId = await insertRow(client, critTid, {
|
|
code: 'SMK-C3-AC1',
|
|
title: 'Smoke C-3 regression criterion',
|
|
status: 'verified',
|
|
priority: 'must',
|
|
spec_id: null,
|
|
source_doc_id: null,
|
|
verified_at: new Date().toISOString(),
|
|
totp: { last_verified_at: new Date().toISOString() },
|
|
});
|
|
created.linkRowId = await insertRow(client, linksTid, {
|
|
from_kind: 'criterion',
|
|
from_id: created.critRowId,
|
|
to_kind: 'ticket',
|
|
to_id: FAKE_TICKET_ID,
|
|
relation: 'verifies',
|
|
});
|
|
console.log(`S1 ✓ criterion id=${created.critRowId} link id=${created.linkRowId} → ticket ${FAKE_TICKET_ID}`);
|
|
|
|
// S2: fire the watcher for done → in-progress
|
|
onTicketStateTransition(
|
|
TICKETS_TABLE_ID,
|
|
FAKE_TICKET_ID,
|
|
{ state: DONE_STATE_ID },
|
|
{ state: INPROGRESS_STATE_ID }
|
|
);
|
|
|
|
// S3: wait until criterion flips to regressed
|
|
const flipped = await waitFor(async () => {
|
|
const { rows } = await client.query(
|
|
`SELECT data FROM table_rows WHERE id = $1`, [created.critRowId]
|
|
);
|
|
const d = rows[0]?.data;
|
|
return d && d.status === 'regressed';
|
|
});
|
|
if (!flipped) throw new Error('criterion did not flip to regressed within 2s');
|
|
const { rows: critRows } = await client.query(
|
|
`SELECT data FROM table_rows WHERE id = $1`, [created.critRowId]
|
|
);
|
|
const critData = critRows[0].data;
|
|
if (critData.regressed_by_ticket_id !== FAKE_TICKET_ID) {
|
|
throw new Error(`regressed_by_ticket_id=${critData.regressed_by_ticket_id}, want ${FAKE_TICKET_ID}`);
|
|
}
|
|
console.log(`S3 ✓ status=regressed, regressed_by_ticket_id=${FAKE_TICKET_ID}, regressed_at=${critData.regressed_at}`);
|
|
|
|
// S4: audit-log row for this regression
|
|
const { rows: auditRows } = await client.query(
|
|
`SELECT id, data FROM table_rows
|
|
WHERE table_id = $1 AND data->>'caused_by' = $2
|
|
ORDER BY id DESC LIMIT 1`,
|
|
[auditTid, `ticket:${FAKE_TICKET_ID}:reopen`]
|
|
);
|
|
if (!auditRows.length) throw new Error('no bdd_audit_log row for regression');
|
|
const ad = auditRows[0].data;
|
|
created.auditRowIds.push(auditRows[0].id);
|
|
const auditChecks = [
|
|
['action=regress', ad.action === 'regress'],
|
|
['from_status=verified', ad.from_status === 'verified'],
|
|
['to_status=regressed', ad.to_status === 'regressed'],
|
|
[`criterion_id=${created.critRowId}`, ad.criterion_id === created.critRowId],
|
|
['actor_kind=system', ad.actor_kind === 'system'],
|
|
];
|
|
for (const [lbl, ok] of auditChecks) {
|
|
if (!ok) throw new Error(`S4 ✗ ${lbl}`);
|
|
console.log(`S4 ✓ ${lbl}`);
|
|
}
|
|
|
|
// S5: idempotent / guard semantics
|
|
// S5a: another done → done transition should be no-op
|
|
onTicketStateTransition(TICKETS_TABLE_ID, FAKE_TICKET_ID,
|
|
{ state: DONE_STATE_ID }, { state: DONE_STATE_ID });
|
|
// S5b: non-tickets table should be no-op
|
|
onTicketStateTransition(9999, FAKE_TICKET_ID,
|
|
{ state: DONE_STATE_ID }, { state: INPROGRESS_STATE_ID });
|
|
// S5c: old state wasn't done — no-op
|
|
onTicketStateTransition(TICKETS_TABLE_ID, FAKE_TICKET_ID,
|
|
{ state: INPROGRESS_STATE_ID }, { state: 43438 /* rejected */ });
|
|
// Give async handlers a window
|
|
await new Promise(r => setTimeout(r, 300));
|
|
const { rows: auditAfter } = await client.query(
|
|
`SELECT COUNT(*)::int AS n FROM table_rows
|
|
WHERE table_id = $1 AND data->>'caused_by' = $2`,
|
|
[auditTid, `ticket:${FAKE_TICKET_ID}:reopen`]
|
|
);
|
|
if (auditAfter[0].n !== 1) {
|
|
throw new Error(`expected 1 audit row after guard tests, got ${auditAfter[0].n}`);
|
|
}
|
|
console.log('S5 ✓ guards: same-state / non-tickets / old-state-not-done all no-op');
|
|
|
|
console.log('\nC-3 regression-watcher smoke: ALL GREEN');
|
|
} finally {
|
|
// S6: cleanup
|
|
for (const auditId of created.auditRowIds) {
|
|
await client.query(`DELETE FROM table_rows WHERE id = $1`, [auditId]);
|
|
}
|
|
if (created.linkRowId) await client.query(`DELETE FROM table_rows WHERE id = $1`, [created.linkRowId]);
|
|
if (created.critRowId) await client.query(`DELETE FROM table_rows WHERE id = $1`, [created.critRowId]);
|
|
console.log(`cleanup ✓ removed crit=${created.critRowId} link=${created.linkRowId} audit=${created.auditRowIds.join(',')}`);
|
|
client.release();
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('C-3 smoke FAILED:', e.message);
|
|
process.exit(1);
|
|
});
|