feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Work packages completed: - WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier, persona loader with project-level overrides. 89 tests, 95.62% coverage. - WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types. 65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior. - WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start + subagent_spawning hooks for Mosaic contract enforcement). - WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs), skills/ (5 universal skills), forge pipeline assets (48 markdown files). Board deliberation: docs/reviews/consolidation-board-memo.md Brief: briefs/monorepo-consolidation.md Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack. 154 new tests total. Zero Python — all TypeScript/ESM.
This commit is contained in:
240
packages/macp/src/gate-runner.ts
Normal file
240
packages/macp/src/gate-runner.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
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 } from './types.js';
|
||||
|
||||
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('bash', ['-lc', 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 };
|
||||
}
|
||||
|
||||
export function runGate(
|
||||
gate: unknown,
|
||||
cwd: string,
|
||||
logPath: string,
|
||||
timeoutSec: number,
|
||||
): GateResult {
|
||||
const gateEntry = normalizeGate(gate);
|
||||
const gateType = gateEntry.type;
|
||||
const command = gateEntry.command;
|
||||
|
||||
if (gateType === 'ci-pipeline') {
|
||||
return {
|
||||
command,
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: 'CI pipeline gate placeholder',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
command: '',
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: '',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
|
||||
const result: GateResult = {
|
||||
command,
|
||||
exit_code: exitCode,
|
||||
type: gateType,
|
||||
output,
|
||||
timed_out: timedOut,
|
||||
passed: false,
|
||||
};
|
||||
|
||||
if (gateType !== 'ai-review') {
|
||||
result.passed = exitCode === 0;
|
||||
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.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,
|
||||
): { allPassed: boolean; gateResults: GateResult[] } {
|
||||
let allPassed = true;
|
||||
const gateResults: GateResult[] = [];
|
||||
|
||||
for (const gate of gates) {
|
||||
const gateEntry = normalizeGate(gate);
|
||||
const gateCmd = gateEntry.command;
|
||||
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
|
||||
|
||||
const label = gateCmd || gateEntry.type;
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.started',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Running gate: ${label}`,
|
||||
);
|
||||
const result = runGate(gate, cwd, logPath, timeoutSec);
|
||||
gateResults.push(result);
|
||||
|
||||
if (result.passed) {
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.passed',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate passed: ${label}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
allPassed = false;
|
||||
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);
|
||||
}
|
||||
|
||||
return { allPassed, gateResults };
|
||||
}
|
||||
Reference in New Issue
Block a user