godcrm/backend/routes/v3/bdd/tests.js
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

342 lines
12 KiB
JavaScript

/**
* ADR-156 Phase 5A: BDD Test Runs API
*
* POST /api/v3/bdd/tests/:id/runs
* - Records a test-run row into CRM logical table `bdd_test_runs`
* - On passed: checks whether *all* blocking tests for the parent criterion
* have a passing run within the last 24h. If so, flips the criterion's
* data.status to 'agent_claimed' and emits pg_notify('bdd.criterion.claimed').
* - On failed: flips criterion's data.status to 'failed' and emits
* pg_notify('bdd.criterion.failed').
* - On 3 consecutive timeout/error runs: disables the test (data.disabled=true).
*/
import { dbGet, dbAll, dbRun, sqlNow } from '../../../database/connection.js';
import { generateBaseId } from '../../../utils/baseId.js';
import { apiLogger } from '../../../utils/logger.js';
import { created, error, notFound, badRequest } from '../../../utils/response.js';
import {
getBddTableId,
patchLogicalRow,
pgNotify,
} from './shared.js';
/**
* Fetch the logical row for `bdd_tests.id` by the CRM row id.
* Returns { id, base_id, data } or null.
*/
async function getTestRow(testRowId) {
const bddTestsTableId = await getBddTableId('bdd_tests');
if (!bddTestsTableId) return null;
const row = await dbGet(`
SELECT id, base_id, data
FROM table_rows
WHERE table_id = ? AND id = ?
`, [bddTestsTableId, testRowId]);
if (!row) return null;
// PG returns jsonb as object already
row.data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
return row;
}
/**
* ADR-156 Appendix C §1.6 — resolve a `bdd_tests` row by its `code` field
* (e.g. ADR-156-AC1-T1), falling back to `previous_codes` history so renamed
* tests remain addressable by their old code.
*
* Returns { id, base_id, data } or null.
*/
async function getTestRowByCode(code) {
const bddTestsTableId = await getBddTableId('bdd_tests');
if (!bddTestsTableId) return null;
// Match current code, or code present in previous_codes JSON array.
const row = await dbGet(`
SELECT id, base_id, data
FROM table_rows
WHERE table_id = ?
AND (
data->>'code' = ?
OR (
jsonb_typeof(data->'previous_codes') = 'array'
AND data->'previous_codes' @> to_jsonb(?::text)
)
)
ORDER BY (data->>'code' = ?) DESC, id ASC
LIMIT 1
`, [bddTestsTableId, code, code, code]);
if (!row) return null;
row.data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
return row;
}
/**
* Heuristic: does `s` look like a BDD code (e.g. `ADR-156-AC1-T1`) rather
* than a numeric row id?
*/
function looksLikeCode(s) {
if (typeof s !== 'string') return false;
return /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-AC\d+(?:-T\d+)?$/.test(s);
}
/**
* Load all sibling tests that share the same criterion_id AND are is_blocking.
*/
async function getBlockingSiblingTests(criterionId) {
const bddTestsTableId = await getBddTableId('bdd_tests');
if (!bddTestsTableId) return [];
const rows = await dbAll(`
SELECT id, base_id, data
FROM table_rows
WHERE table_id = ?
AND data->>'criterion_id' = ?
AND COALESCE(data->>'is_blocking','false') IN ('true','1','t')
`, [bddTestsTableId, String(criterionId)]);
return rows.map(r => ({
...r,
data: typeof r.data === 'string' ? JSON.parse(r.data) : (r.data || {}),
}));
}
/**
* Has this test had a passing run newer than the cutoff?
*/
async function hasFreshPass(testRowId, cutoffIso) {
const bddRunsTableId = await getBddTableId('bdd_test_runs');
if (!bddRunsTableId) return false;
const row = await dbGet(`
SELECT id
FROM table_rows
WHERE table_id = ?
AND data->>'test_id' = ?
AND data->>'status' = 'passed'
AND COALESCE(data->>'finished_at', data->>'created_at', to_char(created_at,'YYYY-MM-DD"T"HH24:MI:SS"Z"')) > ?
LIMIT 1
`, [bddRunsTableId, String(testRowId), cutoffIso]);
return !!row;
}
/**
* Fetch the last N runs (most recent first) for a given test.
*/
async function getLastRuns(testRowId, n = 3) {
const bddRunsTableId = await getBddTableId('bdd_test_runs');
if (!bddRunsTableId) return [];
const rows = await dbAll(`
SELECT id, data, created_at
FROM table_rows
WHERE table_id = ? AND data->>'test_id' = ?
ORDER BY id DESC
LIMIT ?
`, [bddRunsTableId, String(testRowId), n]);
return rows.map(r => ({
...r,
data: typeof r.data === 'string' ? JSON.parse(r.data) : (r.data || {}),
}));
}
/**
* Core logic triggered after inserting a run. Returns a summary object used
* for logging; any errors are swallowed so the HTTP response isn't blocked.
*/
async function handlePostInsertSideEffects({ testRow, runStatus, triggeredById }) {
const summary = { criterion_claimed: false, criterion_failed: false, test_disabled: false };
const criterionId = testRow.data?.criterion_id;
const bddCriteriaTableId = await getBddTableId('bdd_criteria');
const bddTestsTableId = await getBddTableId('bdd_tests');
// --- failed → mark criterion as failed -------------------------------
if (runStatus === 'failed' && criterionId && bddCriteriaTableId) {
const crit = await dbGet(`
SELECT id, data FROM table_rows WHERE table_id = ? AND id = ?
`, [bddCriteriaTableId, criterionId]);
if (crit) {
await patchLogicalRow(bddCriteriaTableId, crit.id, {
status: 'failed',
failed_at: new Date().toISOString(),
failed_test_id: testRow.id,
});
const critData = typeof crit.data === 'string' ? JSON.parse(crit.data) : (crit.data || {});
await pgNotify('bdd.criterion.failed', {
criterion_id: crit.id,
spec_id: critData.spec_id ?? null,
doc_id: critData.source_doc_id ?? null,
failing_test_id: testRow.id,
});
summary.criterion_failed = true;
}
}
// --- passed → check all blocking siblings are green, claim if so -----
if (runStatus === 'passed' && criterionId && bddCriteriaTableId) {
const siblings = await getBlockingSiblingTests(criterionId);
const cutoffIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
let allGreen = siblings.length > 0;
for (const sib of siblings) {
// the current test just passed — short-circuit to avoid race
if (Number(sib.id) === Number(testRow.id)) continue;
const fresh = await hasFreshPass(sib.id, cutoffIso);
if (!fresh) { allGreen = false; break; }
}
if (allGreen) {
const crit = await dbGet(`
SELECT id, data FROM table_rows WHERE table_id = ? AND id = ?
`, [bddCriteriaTableId, criterionId]);
if (crit) {
await patchLogicalRow(bddCriteriaTableId, crit.id, {
status: 'agent_claimed',
claimed_at: new Date().toISOString(),
claimed_by_agent: triggeredById ?? null,
});
const critData = typeof crit.data === 'string' ? JSON.parse(crit.data) : (crit.data || {});
await pgNotify('bdd.criterion.claimed', {
criterion_id: crit.id,
spec_id: critData.spec_id ?? null,
doc_id: critData.source_doc_id ?? null,
claimed_by: triggeredById ?? null,
});
summary.criterion_claimed = true;
}
}
}
// --- 3 consecutive timeout/error → disable test ----------------------
if ((runStatus === 'timeout' || runStatus === 'error') && bddTestsTableId) {
const last = await getLastRuns(testRow.id, 3);
if (last.length >= 3 && last.every(r => r.data?.status === 'timeout' || r.data?.status === 'error')) {
await patchLogicalRow(bddTestsTableId, testRow.id, {
disabled: true,
disabled_reason: `3 consecutive ${runStatus} runs`,
disabled_at: new Date().toISOString(),
});
summary.test_disabled = true;
}
}
return summary;
}
/**
* POST /api/v3/bdd/tests/:id/runs
* Body: {
* status, // required: queued|running|passed|failed|timeout|error
* exit_code?,
* duration_ms?,
* stdout_tail?,
* stderr_tail?,
* assertion_result?,
* score?,
* triggered_by, // required: 'human' | 'agent' | 'worker' | 'cron'
* triggered_by_id, // required: stringly-typed id (agent id, user id, etc.)
* run_hash?,
* }
*/
export default function registerTestRoutes(router) {
router.post('/tests/:id/runs', async (req, res) => {
const startedAt = new Date().toISOString();
try {
// ADR-156 Appendix C §1.6: :id may be a numeric row id OR a stable code
// string (including a historical code from `previous_codes`).
const rawId = req.params.id;
let testRowId;
let preloadedRow = null;
if (looksLikeCode(rawId)) {
preloadedRow = await getTestRowByCode(rawId);
if (!preloadedRow) return notFound(res, 'bdd_tests row (by code)');
testRowId = preloadedRow.id;
} else {
testRowId = parseInt(rawId, 10);
if (!Number.isFinite(testRowId)) return badRequest(res, 'Invalid test id');
}
const {
status,
exit_code = null,
duration_ms = null,
stdout_tail = null,
stderr_tail = null,
assertion_result = null,
score = null,
triggered_by,
triggered_by_id,
run_hash = null,
} = req.body || {};
const ALLOWED_STATUS = new Set(['queued', 'running', 'passed', 'failed', 'timeout', 'error']);
if (!status || !ALLOWED_STATUS.has(status)) {
return badRequest(res, `status must be one of: ${[...ALLOWED_STATUS].join(', ')}`);
}
if (!triggered_by) return badRequest(res, 'triggered_by is required');
if (triggered_by_id === undefined || triggered_by_id === null) {
return badRequest(res, 'triggered_by_id is required');
}
const testRow = preloadedRow || await getTestRow(testRowId);
if (!testRow) return notFound(res, 'bdd_tests row');
const bddRunsTableId = await getBddTableId('bdd_test_runs');
if (!bddRunsTableId) {
return error(res, 'BDD_TABLES_NOT_BOOTSTRAPPED',
'bdd_test_runs logical table is missing in space 11. Run scripts/bootstrap-bdd-tables.js.', 500);
}
const runData = {
test_id: String(testRowId),
status,
exit_code,
duration_ms,
stdout_tail,
stderr_tail,
assertion_result,
score,
triggered_by,
triggered_by_id: String(triggered_by_id),
run_hash,
started_at: startedAt,
finished_at: new Date().toISOString(),
};
const baseId = generateBaseId();
const insert = await dbRun(`
INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
`, [bddRunsTableId, baseId, JSON.stringify(runData), req.user?.id || 1]);
const runId = insert.lastID || insert.lastInsertRowid;
if (status === 'queued') {
try {
await pgNotify('bdd.test_run.queued', { test_id: testRowId, run_id: runId });
} catch (notifyErr) {
apiLogger.warn({ err: notifyErr.message, testRowId, runId },
'BDD: pg_notify bdd.test_run.queued failed (row was still inserted)');
}
}
// Side-effects (claim/fail/disable). Never bubble these up to the caller.
let sideEffects = {};
try {
sideEffects = await handlePostInsertSideEffects({
testRow,
runStatus: status,
triggeredById: triggered_by_id,
});
} catch (sideErr) {
apiLogger.warn({ err: sideErr.message, testRowId, runId },
'BDD: side-effect processing failed (run row was still inserted)');
}
return created(res, {
id: runId,
base_id: baseId,
test_id: testRowId,
status,
side_effects: sideEffects,
});
} catch (err) {
apiLogger.error({ err, testId: req.params.id }, 'POST /bdd/tests/:id/runs failed');
return error(res, 'BDD_RUN_INSERT_FAILED', err.message, 500);
}
});
}