Files
stack/packages/macp/src/gate-runner.ts
T
mos-dt-0andjarvis d7e303d3c0
ci/woodpecker/push/publish Pipeline was canceled
fix(macp): fail-closed typed gate states — RI-2-002 (#1275) (#1293)
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-18 05:56:38 +00:00

364 lines
9.9 KiB
TypeScript

import { spawnSync } from 'node:child_process';
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
/** Typed reason stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
export interface RunGateOptions {
/**
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
* every result is typed `simulated` and never satisfies anything.
*/
simulate?: boolean;
}
export interface NormalizedGate {
command: string;
type: string;
fail_on: string;
}
export function normalizeGate(gate: unknown): NormalizedGate {
if (typeof gate === 'string') {
return { command: gate, type: 'mechanical', fail_on: 'blocker' };
}
if (typeof gate === 'object' && gate !== null && !Array.isArray(gate)) {
const g = gate as Record<string, unknown>;
return {
command: String(g['command'] ?? ''),
type: String(g['type'] ?? 'mechanical'),
fail_on: String(g['fail_on'] ?? 'blocker'),
};
}
return { command: '', type: 'mechanical', fail_on: 'blocker' };
}
export function runShell(
command: string,
cwd: string,
logPath: string,
timeoutSec: number,
): { exitCode: number; output: string; timedOut: boolean } {
mkdirSync(dirname(logPath), { recursive: true });
const header = `\n[${nowISO()}] COMMAND: ${command}\n`;
appendFileSync(logPath, header, 'utf-8');
let exitCode: number;
let output = '';
let timedOut = false;
try {
const result = spawnSync('sh', ['-c', command], {
cwd,
timeout: Math.max(1, timeoutSec) * 1000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
output = (result.stdout ?? '') + (result.stderr ?? '');
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
timedOut = true;
exitCode = 124;
appendFileSync(logPath, `[${nowISO()}] TIMEOUT: exceeded ${timeoutSec}s\n`, 'utf-8');
} else {
exitCode = result.status ?? 1;
}
} catch {
exitCode = 1;
}
if (output) appendFileSync(logPath, output, 'utf-8');
appendFileSync(logPath, `[${nowISO()}] EXIT: ${exitCode}\n`, 'utf-8');
return { exitCode, output, timedOut };
}
export function countAIFindings(parsedOutput: unknown): { blockers: number; total: number } {
if (typeof parsedOutput !== 'object' || parsedOutput === null || Array.isArray(parsedOutput)) {
return { blockers: 0, total: 0 };
}
const obj = parsedOutput as Record<string, unknown>;
const stats = obj['stats'];
let blockers = 0;
let total = 0;
if (typeof stats === 'object' && stats !== null && !Array.isArray(stats)) {
const s = stats as Record<string, unknown>;
blockers = Number(s['blockers']) || 0;
total = blockers + (Number(s['should_fix']) || 0) + (Number(s['suggestions']) || 0);
}
const findings = obj['findings'];
if (Array.isArray(findings)) {
if (blockers === 0) {
blockers = findings.filter(
(f) =>
typeof f === 'object' &&
f !== null &&
(f as Record<string, unknown>)['severity'] === 'blocker',
).length;
}
if (total === 0) {
total = findings.length;
}
}
return { blockers, total };
}
function simulatedResult(gateEntry: NormalizedGate): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: SIMULATED_GATE_REASON,
timed_out: false,
passed: false,
status: 'simulated',
reason: SIMULATED_GATE_REASON,
};
}
function capabilityFailureResult(
gateEntry: NormalizedGate,
code: GateResult['capability_code'],
reason: string,
): GateResult {
return {
command: gateEntry.command,
exit_code: 1,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'capability_failure',
capability_code: code,
reason,
};
}
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'waiting',
capability_code: 'MACP_AUTHORITY_REQUIRED',
reason,
};
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
options: RunGateOptions = {},
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
// Explicit simulation only: never executes, typed simulated, never satisfying.
if (options.simulate) {
return simulatedResult(gateEntry);
}
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
if (gateType === 'ci-pipeline') {
return capabilityFailureResult(
gateEntry,
'MACP_NO_CI_PIPELINE',
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
);
}
if (!command) {
// A manual gate with no automation waits for human sign-off: not pass, not fail.
if (gateType === 'manual') {
return waitingResult(
gateEntry,
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
);
}
// Any other commandless gate is an absent capability — never a vacuous pass.
return capabilityFailureResult(
gateEntry,
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
);
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
const result: GateResult = {
command,
exit_code: exitCode,
type: gateType,
output,
timed_out: timedOut,
passed: false,
status: 'failed',
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
result.status = result.passed ? 'passed' : 'failed';
return result;
}
const failOn = gateEntry.fail_on || 'blocker';
let parsedOutput: unknown = undefined;
let blockers = 0;
let findingsCount = 0;
let parseError: string | undefined;
try {
parsedOutput = output.trim() ? JSON.parse(output) : {};
const counts = countAIFindings(parsedOutput);
blockers = counts.blockers;
findingsCount = counts.total;
} catch (exc) {
parseError = String(exc instanceof Error ? exc.message : exc);
}
if (failOn === 'any') {
result.passed = exitCode === 0 && findingsCount === 0 && !timedOut && parseError === undefined;
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.status = result.passed ? 'passed' : 'failed';
result.fail_on = failOn;
result.blockers = blockers;
result.findings = findingsCount;
if (parsedOutput !== undefined) {
result.parsed_output = parsedOutput;
}
if (parseError !== undefined) {
result.parse_error = parseError;
}
return result;
}
export function runGates(
gates: unknown[],
cwd: string,
logPath: string,
timeoutSec: number,
eventsPath: string,
taskId: string,
options: RunGateOptions = {},
): RunGatesResult {
const gateResults: GateResult[] = [];
let hasCapabilityFailure = false;
let hasSimulated = false;
let hasFailed = false;
let hasWaiting = false;
for (const gate of gates) {
const gateEntry = normalizeGate(gate);
const gateCmd = gateEntry.command;
const label = gateCmd || gateEntry.type;
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
emitEvent(
eventsPath,
'rail.check.started',
taskId,
'gated',
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec, options);
gateResults.push(result);
if (result.status === 'passed') {
emitEvent(
eventsPath,
'rail.check.passed',
taskId,
'gated',
'quality-gate',
`Gate passed: ${label}`,
);
continue;
}
if (result.status === 'waiting') {
hasWaiting = true;
emitEvent(
eventsPath,
'rail.check.waiting',
taskId,
'gated',
'quality-gate',
`Gate waiting: ${label}${result.reason ?? 'manual gate awaits sign-off'}`,
);
continue;
}
if (result.status === 'simulated') {
hasSimulated = true;
emitEvent(
eventsPath,
'rail.check.simulated',
taskId,
'gated',
'quality-gate',
`Gate simulated (non-satisfying): ${label}`,
);
continue;
}
if (result.status === 'capability_failure') {
hasCapabilityFailure = true;
emitEvent(
eventsPath,
'rail.check.failed',
taskId,
'gated',
'quality-gate',
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label}${result.reason ?? 'required capability is absent'}`,
);
continue;
}
hasFailed = true;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
} else if (result.type === 'ai-review' && result.parse_error) {
message = `AI review gate output was not valid JSON: ${label}`;
} else {
message = `Gate failed (${result.exit_code}): ${label}`;
}
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
const state: GateStatus = hasCapabilityFailure
? 'capability_failure'
: hasSimulated
? 'simulated'
: hasFailed
? 'failed'
: hasWaiting
? 'waiting'
: 'passed';
return { allPassed: state === 'passed', gateResults, state };
}