Files
gstack/lib/qa-deadline.ts
T
Garry Tan dcaea52800 v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983)
* 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
2026-09-29 06:07:35 -07:00

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;
}
});
}