mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { spawn } from 'node:child_process';
|
|
import { constants as osConstants } from 'node:os';
|
|
import { initializeWindowsReviewJob } from './claude-code-windows-job';
|
|
|
|
const MAX_MS = 2_147_483_647;
|
|
class QaDeadlineError extends Error {}
|
|
type QaCommandResult = { exitCode: number; signal: NodeJS.Signals | null; completed: boolean };
|
|
type Emit = (stream: 'stdout' | 'stderr', receipt: Record<string, unknown>, completion?: QaCommandResult) => void;
|
|
|
|
export interface QaDeadline {
|
|
version: 1;
|
|
startedAt: string;
|
|
deadlineAt: string;
|
|
budgetMs: number;
|
|
}
|
|
|
|
function utc(value: unknown): number {
|
|
if (typeof value !== 'string') throw new QaDeadlineError('Invalid UTC timestamp');
|
|
const ms = Date.parse(value);
|
|
if (!Number.isFinite(ms)) throw new QaDeadlineError('Invalid UTC timestamp');
|
|
const canonical = new Date(ms).toISOString();
|
|
if (value !== canonical && value !== canonical.replace('.000Z', 'Z')) throw new QaDeadlineError('Invalid UTC timestamp');
|
|
return ms;
|
|
}
|
|
|
|
function checkedPath(file: string): string {
|
|
if (!file || file.includes('\0') || file.split(/[\\/]/).some(part => part === '..')) {
|
|
throw new QaDeadlineError('Invalid deadline path');
|
|
}
|
|
const absolute = path.resolve(file);
|
|
const root = path.parse(absolute).root;
|
|
const parts = absolute.slice(root.length).split(path.sep);
|
|
if (!parts.at(-1)) throw new QaDeadlineError('Invalid deadline path');
|
|
let current = root;
|
|
for (const [index, part] of parts.entries()) {
|
|
if (process.platform === 'win32' && (/[<>:"|?*\x00-\x1f]/.test(part) || /[. ]$/.test(part))) {
|
|
throw new QaDeadlineError('Invalid deadline path');
|
|
}
|
|
current = path.join(current, part);
|
|
let stat: fs.Stats;
|
|
try { stat = fs.lstatSync(current); } catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && index === parts.length - 1) break;
|
|
throw new QaDeadlineError('Deadline parent directory is unavailable');
|
|
}
|
|
if (stat.isSymbolicLink()) throw new QaDeadlineError('Symlinked deadline paths are forbidden');
|
|
if (index < parts.length - 1 && !stat.isDirectory()) throw new QaDeadlineError('Invalid deadline parent directory');
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
export function startQaDeadline(file: string, seconds: string, earlierUtc?: string): QaDeadline {
|
|
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,3})?$/.test(seconds)) throw new QaDeadlineError('Invalid deadline duration');
|
|
const [whole, fraction = ''] = seconds.split('.');
|
|
const budgetMs = Number(whole) * 1000 + Number(fraction.padEnd(3, '0'));
|
|
if (!Number.isSafeInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_MS) throw new QaDeadlineError('Invalid deadline duration');
|
|
const started = Date.now();
|
|
const earlier = earlierUtc === undefined ? Infinity : utc(earlierUtc);
|
|
const state: QaDeadline = {
|
|
version: 1,
|
|
startedAt: new Date(started).toISOString(),
|
|
deadlineAt: new Date(Math.min(started + budgetMs, earlier)).toISOString(),
|
|
budgetMs,
|
|
};
|
|
const target = checkedPath(file);
|
|
const temporary = path.join(path.dirname(target), `.qa-deadline-${randomUUID()}`);
|
|
let fd: number | undefined;
|
|
let created = false;
|
|
try {
|
|
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
created = true;
|
|
fs.writeFileSync(fd, JSON.stringify(state) + '\n');
|
|
fs.fsyncSync(fd);
|
|
fs.fchmodSync(fd, 0o400);
|
|
fs.closeSync(fd);
|
|
fd = undefined;
|
|
fs.linkSync(temporary, target);
|
|
} catch {
|
|
throw new QaDeadlineError('Cannot create deadline receipt; it must not already exist');
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
if (created) fs.rmSync(temporary, { force: true });
|
|
}
|
|
return state;
|
|
}
|
|
|
|
export function readQaDeadline(file: string): QaDeadline {
|
|
const target = checkedPath(file);
|
|
let fd: number | undefined;
|
|
try {
|
|
fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
const stat = fs.fstatSync(fd);
|
|
if (!stat.isFile() || stat.size > 4096 || stat.nlink !== 1) throw new Error();
|
|
const state = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
if (!state || Object.keys(state).sort().join(',') !== 'budgetMs,deadlineAt,startedAt,version'
|
|
|| state.version !== 1 || !Number.isSafeInteger(state.budgetMs) || state.budgetMs <= 0 || state.budgetMs > MAX_MS
|
|
|| utc(state.deadlineAt) > utc(state.startedAt) + state.budgetMs) throw new Error();
|
|
return state;
|
|
} catch {
|
|
throw new QaDeadlineError('Missing or malformed deadline receipt');
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
export function qaDeadlineStatus(state: QaDeadline) {
|
|
const now = Date.now();
|
|
if (now < utc(state.startedAt)) throw new QaDeadlineError('Clock moved before deadline start; refusing dispatch');
|
|
const remainingMs = Math.max(0, utc(state.deadlineAt) - now);
|
|
return { ...state, observedAt: new Date(now).toISOString(), remainingMs, expired: remainingMs === 0 };
|
|
}
|
|
|
|
export interface QaCommandCapture {
|
|
write(stream: 'stdout' | 'stderr', chunk: Buffer): void;
|
|
complete(result: QaCommandResult): void;
|
|
}
|
|
|
|
export async function runQaDeadlineCommand(file: string, command: string, args: string[], emit: Emit, capture?: QaCommandCapture): Promise<number> {
|
|
if (!['linux', 'darwin', 'win32'].includes(process.platform)) throw new QaDeadlineError('Process containment is unavailable on this platform');
|
|
let status = qaDeadlineStatus(readQaDeadline(file));
|
|
if (status.expired) {
|
|
emit('stderr', { event: 'expired', ...status });
|
|
return 124;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
try { await initializeWindowsReviewJob(); } catch { throw new QaDeadlineError('Windows process containment is unavailable; no command was started'); }
|
|
}
|
|
status = qaDeadlineStatus(readQaDeadline(file));
|
|
if (status.expired) {
|
|
emit('stderr', { event: 'expired', ...status });
|
|
return 124;
|
|
}
|
|
return new Promise<number>(resolve => {
|
|
status = qaDeadlineStatus(status);
|
|
if (status.expired) {
|
|
emit('stderr', { event: 'expired', ...status });
|
|
resolve(124);
|
|
return;
|
|
}
|
|
let outcome: number | undefined;
|
|
let settled = false;
|
|
const child = spawn(command, args, { detached: process.platform !== 'win32', stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true });
|
|
const kill = () => {
|
|
if (!child.pid) return;
|
|
try {
|
|
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
else process.kill(-child.pid, 'SIGKILL');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') outcome = 2;
|
|
}
|
|
};
|
|
const finish = (code: number, signal: NodeJS.Signals | null = null, completed = false) => {
|
|
const finishedAt = Date.now();
|
|
if (settled) return;
|
|
settled = true;
|
|
if (outcome === undefined && finishedAt >= utc(status.deadlineAt)) outcome = 124;
|
|
clearTimeout(timer);
|
|
kill();
|
|
process.off('SIGINT', interrupt);
|
|
process.off('SIGTERM', terminate);
|
|
process.off('SIGHUP', hangup);
|
|
process.off('exit', kill);
|
|
child.stdout?.destroy();
|
|
child.stderr?.destroy();
|
|
const completion = { exitCode: outcome ?? code, signal, completed: completed && outcome === undefined && signal === null };
|
|
emit('stderr', { event: 'finished', observedAt: new Date(finishedAt).toISOString(),
|
|
deadlineAt: status.deadlineAt, timedOut: outcome === 124, exitCode: outcome ?? code }, completion);
|
|
capture?.complete(completion);
|
|
resolve(outcome ?? code);
|
|
};
|
|
const stop = (code: number) => { if (settled) return; outcome ??= code; kill(); finish(code); };
|
|
const interrupt = () => stop(130);
|
|
const terminate = () => stop(143);
|
|
const hangup = () => stop(129);
|
|
process.on('SIGINT', interrupt);
|
|
process.on('SIGTERM', terminate);
|
|
process.on('SIGHUP', hangup);
|
|
process.on('exit', kill);
|
|
const timer = setTimeout(() => stop(124), Math.max(1, utc(status.deadlineAt) - Date.now()));
|
|
child.once('error', () => finish(127));
|
|
child.once('exit', (code, signal) => {
|
|
if (!capture) finish(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 1), signal, true);
|
|
else if (!settled) kill();
|
|
});
|
|
if (capture) {
|
|
for (const stream of ['stdout', 'stderr'] as const) {
|
|
child[stream]!.on('data', chunk => {
|
|
if (settled) return;
|
|
try { capture.write(stream, chunk); } catch { stop(2); }
|
|
});
|
|
child[stream]!.once('error', () => stop(2));
|
|
}
|
|
child.once('close', (code, signal) => finish(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 1), signal,
|
|
child.stdout!.readableEnded && child.stderr!.readableEnded));
|
|
}
|
|
emit('stderr', { event: 'started', ...status });
|
|
});
|
|
}
|
|
|
|
async function runWindowsWorker(args: string[], emit: Emit): Promise<number> {
|
|
const status = qaDeadlineStatus(readQaDeadline(args[1]));
|
|
if (status.expired) {
|
|
emit('stderr', { event: 'expired', ...status });
|
|
return 124;
|
|
}
|
|
return runQaWindowsWorker(args, emit, path.resolve(import.meta.dir, '../bin/gstack-qa-deadline'), 'qa-deadline-receipt');
|
|
}
|
|
|
|
export async function runQaWindowsWorker(args: string[], emit: Emit, entrypoint: string, messageType: string,
|
|
captureFiles?: { stdout: number; stderr: number }): Promise<number> {
|
|
try { await initializeWindowsReviewJob(); } catch { throw new QaDeadlineError('Windows process containment is unavailable; no command was started'); }
|
|
return new Promise<number>(resolve => {
|
|
const worker = spawn(process.execPath, [...process.execArgv, entrypoint, '--receipt-worker', ...args], {
|
|
stdio: ['inherit', captureFiles?.stdout ?? 'inherit', captureFiles?.stderr ?? 'inherit', 'ipc'], windowsHide: true,
|
|
});
|
|
const kill = () => { worker.kill('SIGKILL'); };
|
|
process.on('exit', kill);
|
|
worker.on('message', (message: any) => {
|
|
if (message?.type === messageType && ['stdout', 'stderr'].includes(message.stream)
|
|
&& message.receipt && typeof message.receipt === 'object') emit(message.stream, message.receipt, message.completion);
|
|
});
|
|
worker.once('error', () => {
|
|
process.off('exit', kill);
|
|
emit('stderr', { event: 'error', message: 'Cannot start deadline worker' });
|
|
resolve(2);
|
|
});
|
|
worker.once('close', (code, signal) => {
|
|
process.off('exit', kill);
|
|
resolve(code ?? (signal ? 128 + (osConstants.signals[signal] ?? 1) : 2));
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function withQaReceiptOutput(receiptWorker: boolean, messageType: string, format: (receipt: Record<string, unknown>) => string,
|
|
run: (emit: Emit) => Promise<number>): Promise<number> {
|
|
const output = {
|
|
stdout: fs.createWriteStream('', { fd: 1, autoClose: false }),
|
|
stderr: fs.createWriteStream('', { fd: 2, autoClose: false }),
|
|
};
|
|
const writes: Promise<void>[] = [];
|
|
let writeFailed = false;
|
|
const failed = () => { writeFailed = true; };
|
|
output.stdout.on('error', failed);
|
|
output.stderr.on('error', failed);
|
|
const emit: Emit = (stream, receipt, completion) => {
|
|
writes.push(new Promise<void>(resolve => {
|
|
const done = (error?: Error | null) => { if (error) writeFailed = true; resolve(); };
|
|
try {
|
|
if (receiptWorker) process.send!({ type: messageType, stream, receipt, ...(completion ? { completion } : {}) }, done);
|
|
else output[stream].write(format(receipt), done);
|
|
} catch { writeFailed = true; resolve(); }
|
|
}));
|
|
};
|
|
try { return await run(emit); }
|
|
finally {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
await Promise.race([
|
|
Promise.all(writes),
|
|
new Promise<void>(resolve => { timer = setTimeout(() => { writeFailed = true; resolve(); }, 5000); }),
|
|
]);
|
|
clearTimeout(timer);
|
|
if (writeFailed) return 2;
|
|
}
|
|
}
|
|
|
|
export async function qaDeadlineMain(args: string[], receiptWorker = false): Promise<number> {
|
|
return withQaReceiptOutput(receiptWorker, 'qa-deadline-receipt', receipt => '\nQA_DEADLINE ' + JSON.stringify({ guard: 'qa-deadline', ...receipt }) + '\n', async emit => {
|
|
try {
|
|
const [action, file, ...rest] = args;
|
|
if (action === 'start' && file && (rest.length === 1 || rest.length === 2)) {
|
|
const status = qaDeadlineStatus(startQaDeadline(file, rest[0], rest[1]));
|
|
emit('stdout', { event: 'start', ...status });
|
|
return status.expired ? 124 : 0;
|
|
}
|
|
if (action === 'status' && file && rest.length === 0) {
|
|
const status = qaDeadlineStatus(readQaDeadline(file));
|
|
emit('stdout', { event: 'status', ...status });
|
|
return status.expired ? 124 : 0;
|
|
}
|
|
if (action === 'run' && file && rest[0] === '--' && rest[1]) {
|
|
if (process.platform === 'win32' && !receiptWorker) return await runWindowsWorker(args, emit);
|
|
return await runQaDeadlineCommand(file, rest[1], rest.slice(2), emit);
|
|
}
|
|
throw new QaDeadlineError('Usage: gstack-qa-deadline start FILE SECONDS [EARLIER_UTC] | status FILE | run FILE -- COMMAND ARGS...');
|
|
} catch (error) {
|
|
emit('stderr', { event: 'error', message: error instanceof QaDeadlineError ? error.message : 'Deadline guard failed' });
|
|
return 2;
|
|
}
|
|
});
|
|
}
|