ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: mos-dt-0 <[email protected]>
364 lines
9.9 KiB
TypeScript
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 };
|
|
}
|