Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
675 lines
24 KiB
JavaScript
675 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ADR-156 Phase 5A: BDD test runner worker
|
|
*
|
|
* Responsibilities:
|
|
* 1. LISTEN on Postgres channels `bdd.test_run.queued` and
|
|
* `bdd.criterion.claim_requested`. Any notification triggers a poll pass.
|
|
* 2. Poll `bdd_test_runs` logical rows where data.status='queued', claim
|
|
* one, execute its parent `bdd_tests.command` in a strict sandbox with a
|
|
* 30-second timeout.
|
|
* 3. POST the result back to /api/v3/bdd/tests/:id/runs with an internal
|
|
* JWT so the normal server-side state machine (claim/fail/disable)
|
|
* takes over.
|
|
*
|
|
* This is a SCAFFOLD (ADR-156 Phase 5A). It is intentionally conservative:
|
|
* - command must match an allowlist regex (npm|npx|curl|pytest|mcp|claude)
|
|
* - child runs with shell:false, scrubbed env, cwd=/tmp
|
|
* - if the process starts as root, drop to uid/gid 'nobody'
|
|
* - stdout/stderr are tailed to the last 4KB
|
|
* - timeout kills the process tree
|
|
*/
|
|
|
|
import { config } from '../config.js';
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { execFile } from 'node:child_process';
|
|
import jwt from 'jsonwebtoken';
|
|
import pg from 'pg';
|
|
|
|
import { dbGet, dbAll, dbRun, sqlNow, getAdapter } from '../database/connection.js';
|
|
import { generateBaseId } from '../utils/baseId.js';
|
|
import { logger } from '../utils/logger.js';
|
|
import { gateBash, gateSql, gateMcp } from './bdd-gates.js';
|
|
|
|
const log = logger.child({ worker: 'bdd-runner' });
|
|
|
|
const PORT = config.PORT || 5000;
|
|
const SELF_BASE_URL = process.env.BDD_WORKER_BASE_URL || `http://127.0.0.1:${PORT}`;
|
|
const JWT_SECRET = process.env.JWT_SECRET || config.JWT_SECRET;
|
|
const INTERNAL_USER_ID = parseInt(process.env.BDD_WORKER_USER_ID || '1', 10);
|
|
const POLL_INTERVAL_MS = parseInt(process.env.BDD_WORKER_POLL_MS || '15000', 10);
|
|
const EXEC_TIMEOUT_MS = parseInt(process.env.BDD_WORKER_EXEC_MS || '30000', 10);
|
|
const TAIL_BYTES = 4096;
|
|
const BDD_SPACE_ID = 11;
|
|
|
|
// ADR-156 Appendix C §2.1 — per-runner gates live in ./bdd-gates.js. The old
|
|
// single-binary prefix allowlist is superseded but preserved here as a last-
|
|
// ditch fallback when a test has no runner kind recorded (legacy rows from
|
|
// iteration 1). New rows MUST set data.runner to bash|sql|mcp|http.
|
|
const LEGACY_COMMAND_ALLOWLIST = /^(npm|npx|curl|pytest|mcp|claude)( |$)/;
|
|
|
|
const execFileP = promisify(execFile);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table-id resolution (same approach as backend/routes/v3/bdd.js)
|
|
// ---------------------------------------------------------------------------
|
|
const tableIdCache = new Map();
|
|
async function getBddTableId(name) {
|
|
if (tableIdCache.has(name)) return tableIdCache.get(name);
|
|
const row = await dbGet(`
|
|
SELECT ut.id
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE p.space_id = ? AND ut.name = ?
|
|
ORDER BY ut.id ASC
|
|
LIMIT 1
|
|
`, [BDD_SPACE_ID, name]);
|
|
if (row?.id) {
|
|
tableIdCache.set(name, row.id);
|
|
return row.id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JWT for self-POST
|
|
// ---------------------------------------------------------------------------
|
|
function issueInternalJwt() {
|
|
return jwt.sign(
|
|
{ id: INTERNAL_USER_ID, email: 'bdd-runner@internal', role: 'system' },
|
|
JWT_SECRET,
|
|
{ expiresIn: '5m' }
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox exec
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse a command string into [bin, ...args] without a shell.
|
|
* NOTE: This is a naive whitespace split; commands requiring quotes/pipes are
|
|
* rejected by the allowlist + the presence of shell metacharacters.
|
|
*/
|
|
function splitCommand(cmd) {
|
|
const SHELL_METACHARS = /[|&;`$(){}<>\\]/;
|
|
if (SHELL_METACHARS.test(cmd)) {
|
|
throw new Error('command contains shell metacharacters');
|
|
}
|
|
const parts = cmd.trim().split(/\s+/);
|
|
if (parts.length === 0) throw new Error('empty command');
|
|
return parts;
|
|
}
|
|
|
|
function tailBuffer(buf, n = TAIL_BYTES) {
|
|
if (!Buffer.isBuffer(buf)) buf = Buffer.from(String(buf || ''), 'utf8');
|
|
if (buf.length <= n) return buf.toString('utf8');
|
|
return buf.subarray(buf.length - n).toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Apply the per-runner security gate (ADR-156 Appendix C §2.1). Returns null
|
|
* if the command is allowed, or an error result if it is not.
|
|
*
|
|
* `test` here is the shape returned by getTestCommand().
|
|
*/
|
|
function applyRunnerGate(test, command) {
|
|
const runner = (test && test.runner) || 'bash';
|
|
const cfg = (test && test.runner_config) || {};
|
|
|
|
let gate;
|
|
switch (runner) {
|
|
case 'bash':
|
|
gate = gateBash(command, cfg.allow || []);
|
|
break;
|
|
case 'sql':
|
|
gate = gateSql(command);
|
|
break;
|
|
case 'mcp': {
|
|
// MCP command format: `tool_name {json_args}`. Extract leading token.
|
|
const toolName = String(command).trim().split(/\s+/, 1)[0] || '';
|
|
gate = gateMcp(toolName, cfg.tools || []);
|
|
break;
|
|
}
|
|
case 'http':
|
|
// No gate yet for http runner — built-in probe validates URL itself.
|
|
return null;
|
|
default:
|
|
// Legacy rows with no runner kind → fall back to the old prefix allowlist
|
|
// so we don't strand iteration-1 fixtures.
|
|
if (!LEGACY_COMMAND_ALLOWLIST.test(String(command).trim())) {
|
|
return {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '',
|
|
stderr_tail: `unknown runner "${runner}" and legacy allowlist miss: ${String(command).slice(0, 120)}`,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
if (gate && gate.ok === false) {
|
|
return {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '',
|
|
stderr_tail: `gate ${runner}: ${gate.reason}`,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Execute a SELECT-only SQL statement against the dedicated read-only role
|
|
* (ADR-156 Appendix C §2.1 "sql runner"). Uses DATABASE_URL_RO if set, else
|
|
* falls back to the worker's POSTGRES_* env with user=bdd_readonly.
|
|
*
|
|
* The gate has already validated the statement is SELECT/WITH-SELECT shaped.
|
|
*/
|
|
async function executeSql(sql) {
|
|
const started = Date.now();
|
|
const { Client } = pg;
|
|
const connConfig = process.env.DATABASE_URL_RO
|
|
? { connectionString: process.env.DATABASE_URL_RO }
|
|
: {
|
|
host: process.env.POSTGRES_HOST || 'localhost',
|
|
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
|
|
database: process.env.POSTGRES_DB || 'godcrm_prod',
|
|
user: process.env.BDD_RO_USER || 'bdd_readonly',
|
|
password: process.env.BDD_RO_PASSWORD || process.env.POSTGRES_PASSWORD,
|
|
};
|
|
const client = new Client(connConfig);
|
|
try {
|
|
await client.connect();
|
|
// Enforce statement timeout even if the gate missed something long-running.
|
|
await client.query("SET statement_timeout = '5000ms'");
|
|
const res = await client.query(sql);
|
|
const rowsJson = JSON.stringify(res.rows || []);
|
|
return {
|
|
status: 'passed', exit_code: 0,
|
|
duration_ms: Date.now() - started,
|
|
stdout_tail: rowsJson.length > TAIL_BYTES ? rowsJson.slice(-TAIL_BYTES) : rowsJson,
|
|
stderr_tail: '',
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
status: 'failed', exit_code: null,
|
|
duration_ms: Date.now() - started,
|
|
stdout_tail: '',
|
|
stderr_tail: `sql: ${e.message}`.slice(-TAIL_BYTES),
|
|
};
|
|
} finally {
|
|
try { await client.end(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute an HTTP probe (ADR-156 Phase 5A §2.1 "http runner").
|
|
*
|
|
* Contract:
|
|
* - Fetch test.target with test.method/headers/body and a timeout.
|
|
* - Read the response body as text (we trim both sides for equals checks;
|
|
* this is deliberate so upstream whitespace/trailing-newline drift between
|
|
* servers doesn't flake an otherwise-correct body match).
|
|
* - If test.expected_signal.equals is set → strict string equality check
|
|
* (after .trim() on BOTH sides). Records {expected, actual, matched} in
|
|
* assertion_result.
|
|
* - If test.expected_signal.status is set (number) → exact HTTP status match;
|
|
* otherwise the default "OK range" is 200-299.
|
|
* - status='passed' iff (equals check matched OR no equals provided) AND
|
|
* HTTP status is in the expected range.
|
|
* - On failure, assertion_result is still populated so consumers can debug.
|
|
*/
|
|
async function executeHttp(test) {
|
|
const started = Date.now();
|
|
const target = typeof test.target === 'string' ? test.target.trim() : '';
|
|
if (!target) {
|
|
return {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '', stderr_tail: 'http runner: missing target URL',
|
|
assertion_result: { expected: null, actual: null, matched: false },
|
|
};
|
|
}
|
|
|
|
const method = (test.method || 'GET').toString().toUpperCase();
|
|
const headers = (test.headers && typeof test.headers === 'object') ? test.headers : {};
|
|
const body = test.body ?? undefined;
|
|
const expectedSignal = test.expected_signal && typeof test.expected_signal === 'object'
|
|
? test.expected_signal : null;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), EXEC_TIMEOUT_MS);
|
|
|
|
let resp;
|
|
try {
|
|
resp = await fetch(target, {
|
|
method,
|
|
headers,
|
|
body: (method === 'GET' || method === 'HEAD') ? undefined : body,
|
|
signal: controller.signal,
|
|
});
|
|
} catch (e) {
|
|
clearTimeout(timer);
|
|
const duration_ms = Date.now() - started;
|
|
const aborted = e.name === 'AbortError' || /abort/i.test(e.message || '');
|
|
return {
|
|
status: aborted ? 'timeout' : 'error',
|
|
exit_code: null,
|
|
duration_ms,
|
|
stdout_tail: '',
|
|
stderr_tail: `http: ${e.message || String(e)}`.slice(-TAIL_BYTES),
|
|
assertion_result: { expected: expectedSignal, actual: null, matched: false },
|
|
};
|
|
}
|
|
clearTimeout(timer);
|
|
|
|
let text = '';
|
|
try { text = await resp.text(); } catch { /* ignore */ }
|
|
|
|
const duration_ms = Date.now() - started;
|
|
const httpStatus = resp.status;
|
|
|
|
// Status range check (default 2xx; override with expected_signal.status).
|
|
let statusOk;
|
|
if (expectedSignal && typeof expectedSignal.status === 'number') {
|
|
statusOk = httpStatus === expectedSignal.status;
|
|
} else {
|
|
statusOk = httpStatus >= 200 && httpStatus <= 299;
|
|
}
|
|
|
|
// Equals check (trim both sides — see contract above).
|
|
let equalsMatched = true; // no equals provided ⇒ vacuously true
|
|
let expected = null;
|
|
let actual = text;
|
|
if (expectedSignal && typeof expectedSignal.equals === 'string') {
|
|
expected = expectedSignal.equals;
|
|
const a = (text || '').trim();
|
|
const e = expected.trim();
|
|
equalsMatched = a === e;
|
|
actual = text;
|
|
}
|
|
|
|
const matched = equalsMatched && statusOk;
|
|
|
|
const assertion_result = {
|
|
expected: expectedSignal ?? null,
|
|
actual: {
|
|
http_status: httpStatus,
|
|
body: text.length > TAIL_BYTES ? text.slice(0, TAIL_BYTES) : text,
|
|
},
|
|
matched,
|
|
};
|
|
|
|
const stdout_tail = text.length > TAIL_BYTES ? text.slice(-TAIL_BYTES) : text;
|
|
const stderr_tail = matched ? '' :
|
|
`http status=${httpStatus} statusOk=${statusOk} equalsMatched=${equalsMatched}`;
|
|
|
|
return {
|
|
status: matched ? 'passed' : 'failed',
|
|
exit_code: matched ? 0 : 1,
|
|
duration_ms,
|
|
stdout_tail,
|
|
stderr_tail,
|
|
assertion_result,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute a sandboxed command. Returns { status, exit_code, duration_ms,
|
|
* stdout_tail, stderr_tail }.
|
|
*/
|
|
async function executeSandboxed(test) {
|
|
// HTTP runner — validate target & expected_signal, fetch, assert.
|
|
// Must be checked BEFORE the command emptiness guard since http tests
|
|
// have no `command` field — they have `target`/`method`/`expected_signal`.
|
|
if (test && test.runner === 'http') {
|
|
return await executeHttp(test);
|
|
}
|
|
|
|
const command = test && test.command;
|
|
if (typeof command !== 'string' || command.trim() === '') {
|
|
return {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '', stderr_tail: 'empty command',
|
|
};
|
|
}
|
|
|
|
const denied = applyRunnerGate(test, command);
|
|
if (denied) return denied;
|
|
|
|
// SQL runner — execute via the dedicated read-only Postgres role. Keeps the
|
|
// process isolation for bash intact but avoids forking psql inside a
|
|
// sandboxed shell context.
|
|
if (test.runner === 'sql') {
|
|
return await executeSql(command);
|
|
}
|
|
|
|
let argv;
|
|
try {
|
|
argv = splitCommand(command);
|
|
} catch (e) {
|
|
return {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '', stderr_tail: e.message,
|
|
};
|
|
}
|
|
|
|
const [bin, ...args] = argv;
|
|
|
|
const spawnOpts = {
|
|
shell: false,
|
|
cwd: '/tmp',
|
|
env: {
|
|
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
|
},
|
|
detached: true, // put child in its own process group so we can kill the tree
|
|
};
|
|
|
|
// If running as root, try to drop privileges to 'nobody'.
|
|
if (process.getuid && process.getuid() === 0) {
|
|
try {
|
|
const { stdout } = await execFileP('id', ['-u', 'nobody'], { timeout: 2000 });
|
|
const uid = parseInt(stdout.trim(), 10);
|
|
const { stdout: gOut } = await execFileP('id', ['-g', 'nobody'], { timeout: 2000 });
|
|
const gid = parseInt(gOut.trim(), 10);
|
|
if (Number.isFinite(uid) && Number.isFinite(gid)) {
|
|
spawnOpts.uid = uid;
|
|
spawnOpts.gid = gid;
|
|
}
|
|
} catch (e) {
|
|
log.warn({ err: e.message }, 'bdd-runner: could not resolve nobody uid/gid, running as current user');
|
|
}
|
|
}
|
|
|
|
const started = Date.now();
|
|
return await new Promise((resolve) => {
|
|
let child;
|
|
try {
|
|
child = spawn(bin, args, spawnOpts);
|
|
} catch (e) {
|
|
return resolve({
|
|
status: 'error', exit_code: null, duration_ms: Date.now() - started,
|
|
stdout_tail: '', stderr_tail: `spawn failed: ${e.message}`,
|
|
});
|
|
}
|
|
|
|
const stdoutChunks = [];
|
|
const stderrChunks = [];
|
|
let stdoutLen = 0, stderrLen = 0;
|
|
const MAX_BUFFER = 1024 * 1024; // 1MB hard cap per stream
|
|
|
|
child.stdout.on('data', d => {
|
|
if (stdoutLen < MAX_BUFFER) { stdoutChunks.push(d); stdoutLen += d.length; }
|
|
});
|
|
child.stderr.on('data', d => {
|
|
if (stderrLen < MAX_BUFFER) { stderrChunks.push(d); stderrLen += d.length; }
|
|
});
|
|
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
// Kill the whole process group. `detached:true` above made child the leader.
|
|
if (child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
} catch { /* ignore */ }
|
|
}, EXEC_TIMEOUT_MS);
|
|
|
|
child.on('error', (e) => {
|
|
clearTimeout(timer);
|
|
resolve({
|
|
status: 'error', exit_code: null, duration_ms: Date.now() - started,
|
|
stdout_tail: tailBuffer(Buffer.concat(stdoutChunks)),
|
|
stderr_tail: tailBuffer(Buffer.concat(stderrChunks)) || e.message,
|
|
});
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(timer);
|
|
const duration_ms = Date.now() - started;
|
|
const stdout_tail = tailBuffer(Buffer.concat(stdoutChunks));
|
|
const stderr_tail = tailBuffer(Buffer.concat(stderrChunks));
|
|
if (timedOut) {
|
|
return resolve({ status: 'timeout', exit_code: null, duration_ms, stdout_tail, stderr_tail });
|
|
}
|
|
if (code === 0) {
|
|
return resolve({ status: 'passed', exit_code: 0, duration_ms, stdout_tail, stderr_tail });
|
|
}
|
|
return resolve({
|
|
status: 'failed',
|
|
exit_code: typeof code === 'number' ? code : null,
|
|
duration_ms, stdout_tail, stderr_tail,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poll & dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Atomically claim one queued run: flip its data.status from 'queued' to
|
|
* 'running' and return the row. Returns null if nothing to do.
|
|
*/
|
|
async function claimOneQueuedRun() {
|
|
const runsTableId = await getBddTableId('bdd_test_runs');
|
|
if (!runsTableId) return null;
|
|
|
|
// UPDATE ... RETURNING the first queued row.
|
|
const adapter = await getAdapter();
|
|
const res = await adapter.query(`
|
|
UPDATE table_rows
|
|
SET data = data || jsonb_build_object('status','running','claimed_at', to_char(now(),'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
|
updated_at = NOW()
|
|
WHERE id = (
|
|
SELECT id FROM table_rows
|
|
WHERE table_id = $1 AND data->>'status' = 'queued'
|
|
ORDER BY id ASC
|
|
LIMIT 1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
RETURNING id, base_id, data
|
|
`, [runsTableId]);
|
|
if (!res.rows || res.rows.length === 0) return null;
|
|
const row = res.rows[0];
|
|
row.data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
|
|
return row;
|
|
}
|
|
|
|
async function getTestCommand(testRowId) {
|
|
const testsTableId = await getBddTableId('bdd_tests');
|
|
if (!testsTableId) return null;
|
|
const row = await dbGet(`
|
|
SELECT id, data FROM table_rows WHERE table_id = ? AND id = ?
|
|
`, [testsTableId, testRowId]);
|
|
if (!row) return null;
|
|
const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
|
|
if (data.disabled === true || data.disabled === 'true') return null;
|
|
// ADR-156 Appendix C §1.4: runner kind drives which security gate we apply.
|
|
// Legacy rows (pre-iteration-2) may have a `kind` value like 'curl' instead
|
|
// of a runner kind — fall back to bash in that case.
|
|
const runner = (data.runner || data.kind || 'bash').toString().toLowerCase();
|
|
const runner_config = data.runner_config && typeof data.runner_config === 'object'
|
|
? data.runner_config : {};
|
|
return {
|
|
id: row.id,
|
|
command: data.command,
|
|
runner,
|
|
runner_config,
|
|
review_status: data.review_status || 'pending',
|
|
is_blocking: !!data.is_blocking,
|
|
// HTTP-runner fields (ADR-156 Phase 5A §2.1 http runner). Ignored by other runners.
|
|
target: data.target || null,
|
|
method: data.method || 'GET',
|
|
headers: data.headers && typeof data.headers === 'object' ? data.headers : null,
|
|
body: data.body ?? null,
|
|
expected_signal: data.expected_signal && typeof data.expected_signal === 'object'
|
|
? data.expected_signal : null,
|
|
};
|
|
}
|
|
|
|
async function postRunResult(testRowId, result, triggered_by_id) {
|
|
const token = issueInternalJwt();
|
|
const body = {
|
|
status: result.status,
|
|
exit_code: result.exit_code,
|
|
duration_ms: result.duration_ms,
|
|
stdout_tail: result.stdout_tail,
|
|
stderr_tail: result.stderr_tail,
|
|
assertion_result: result.assertion_result ?? null,
|
|
triggered_by: 'worker',
|
|
triggered_by_id: triggered_by_id || 'bdd-runner',
|
|
};
|
|
const resp = await fetch(`${SELF_BASE_URL}/api/v3/bdd/tests/${testRowId}/runs`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
throw new Error(`self-POST ${resp.status}: ${text.slice(0, 200)}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
/**
|
|
* After the sandbox has produced a result, finalise the *queued* run row by
|
|
* merging the real status + captured tails into its data column. This is the
|
|
* row we claimed at the start — we update it in-place rather than creating a
|
|
* new one so consumers can pair queue + result by id. We ALSO self-POST so
|
|
* the criterion state-machine in routes/v3/bdd.js fires.
|
|
*/
|
|
async function finaliseQueuedRunRow(runRow, testRowId, result) {
|
|
const runsTableId = await getBddTableId('bdd_test_runs');
|
|
if (!runsTableId) return;
|
|
const patch = {
|
|
status: result.status,
|
|
exit_code: result.exit_code,
|
|
duration_ms: result.duration_ms,
|
|
stdout_tail: result.stdout_tail,
|
|
stderr_tail: result.stderr_tail,
|
|
finished_at: new Date().toISOString(),
|
|
};
|
|
if (result.assertion_result !== undefined) {
|
|
patch.assertion_result = result.assertion_result;
|
|
}
|
|
await dbRun(`
|
|
UPDATE table_rows
|
|
SET data = COALESCE(data,'{}'::jsonb) || ?::jsonb,
|
|
updated_at = ${sqlNow()}
|
|
WHERE id = ?
|
|
`, [JSON.stringify(patch), runRow.id]);
|
|
}
|
|
|
|
let pollInFlight = false;
|
|
async function pollOnce() {
|
|
if (pollInFlight) return;
|
|
pollInFlight = true;
|
|
try {
|
|
// Drain: process queued rows until none remain.
|
|
for (;;) {
|
|
const runRow = await claimOneQueuedRun();
|
|
if (!runRow) break;
|
|
|
|
const testRowId = parseInt(runRow.data?.test_id, 10);
|
|
if (!Number.isFinite(testRowId)) {
|
|
await finaliseQueuedRunRow(runRow, null, {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '', stderr_tail: 'invalid test_id on queued row',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const test = await getTestCommand(testRowId);
|
|
// http runner has no `command` — it uses `target`. Only reject when BOTH
|
|
// are absent, or when the test row itself is missing/disabled.
|
|
const hasWork = test && (test.command || (test.runner === 'http' && test.target));
|
|
if (!hasWork) {
|
|
await finaliseQueuedRunRow(runRow, testRowId, {
|
|
status: 'error', exit_code: null, duration_ms: 0,
|
|
stdout_tail: '', stderr_tail: 'test not found or disabled',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
log.info({
|
|
testRowId, runRowId: runRow.id,
|
|
runner: test.runner,
|
|
cmd: String(test.command || test.target || '').slice(0, 120),
|
|
}, 'bdd-runner: executing');
|
|
const result = await executeSandboxed(test);
|
|
|
|
await finaliseQueuedRunRow(runRow, testRowId, result);
|
|
|
|
try {
|
|
await postRunResult(testRowId, result, runRow.data?.triggered_by_id);
|
|
} catch (e) {
|
|
log.warn({ err: e.message, testRowId, runRowId: runRow.id },
|
|
'bdd-runner: self-POST failed (row was finalised directly in DB)');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log.error({ err: e.message }, 'bdd-runner: poll pass failed');
|
|
} finally {
|
|
pollInFlight = false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LISTEN/NOTIFY bootstrap
|
|
// ---------------------------------------------------------------------------
|
|
async function startListener() {
|
|
const { Client } = pg;
|
|
const connString = process.env.POSTGRES_URL;
|
|
const connectionConfig = connString
|
|
? { connectionString: connString }
|
|
: {
|
|
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,
|
|
};
|
|
|
|
const client = new pg.Client(connectionConfig);
|
|
client.on('error', (err) => log.error({ err: err.message }, 'bdd-runner LISTEN client error'));
|
|
client.on('notification', (msg) => {
|
|
log.info({ channel: msg.channel, payload: msg.payload?.slice?.(0, 200) }, 'bdd-runner: NOTIFY');
|
|
// Any signal = kick the poll.
|
|
pollOnce().catch(() => {});
|
|
});
|
|
|
|
await client.connect();
|
|
await client.query('LISTEN "bdd.test_run.queued"');
|
|
await client.query('LISTEN "bdd.criterion.claim_requested"');
|
|
log.info('bdd-runner: LISTEN active on bdd.test_run.queued, bdd.criterion.claim_requested');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entrypoint
|
|
// ---------------------------------------------------------------------------
|
|
async function main() {
|
|
log.info({
|
|
pollInterval: POLL_INTERVAL_MS,
|
|
execTimeout: EXEC_TIMEOUT_MS,
|
|
baseUrl: SELF_BASE_URL,
|
|
}, 'bdd-runner: starting');
|
|
|
|
try {
|
|
await startListener();
|
|
} catch (e) {
|
|
log.error({ err: e.message }, 'bdd-runner: failed to start LISTEN (will continue with polling only)');
|
|
}
|
|
|
|
// Immediate catch-up pass, then periodic safety-net poll.
|
|
await pollOnce().catch(() => {});
|
|
setInterval(() => { pollOnce().catch(() => {}); }, POLL_INTERVAL_MS);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
log.error({ err: e.message, stack: e.stack }, 'bdd-runner: fatal');
|
|
process.exit(1);
|
|
});
|