mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-03 01:46:55 +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
141 lines
7.3 KiB
TypeScript
141 lines
7.3 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { isProcessAlive } from '../../browse/src/error-handling';
|
|
import { readPidCmdline, readPidStartTime } from '../../browse/src/xvfb';
|
|
import { isAgentRecordGone, isOurAgent, readAgentRecord } from '../../browse/src/terminal-agent-control';
|
|
|
|
export async function stopQaOnlyBrowser(directory: string, timeoutMs: number): Promise<void> {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 100) throw new Error('QA-only browser cleanup: no settlement budget remains; retaining fixture');
|
|
const worker = Bun.spawn([process.execPath, import.meta.path, directory, String(timeoutMs - 100)], {
|
|
stdout: 'ignore', stderr: 'pipe',
|
|
});
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => { timedOut = true; worker.kill('SIGKILL'); }, timeoutMs);
|
|
const stderr = new Response(worker.stderr).text();
|
|
try {
|
|
const code = await worker.exited;
|
|
const error = await stderr;
|
|
if (timedOut) throw new Error('QA-only browser cleanup: owned worker settlement deadline exceeded; retaining fixture');
|
|
if (code !== 0) throw new Error(error.trim() || `QA-only browser cleanup: worker exited ${code}; retaining fixture`);
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
async function settleOwnedBrowser(directory: string, timeoutMs: number): Promise<void> {
|
|
const started = performance.now();
|
|
const deadline = started + timeoutMs;
|
|
let pending = ['identity verification'];
|
|
const fail = (message: string): never => { throw new Error(`QA-only browser cleanup: ${message}`); };
|
|
const remaining = () => {
|
|
const ms = Math.floor(deadline - performance.now());
|
|
if (ms <= 0) fail(`owned process settlement deadline exceeded (${pending.join(', ')}); retaining fixture`);
|
|
return ms;
|
|
};
|
|
if (fs.lstatSync(directory).isSymbolicLink()) fail('fixture directory is a link');
|
|
const root = fs.realpathSync(directory);
|
|
const stateDir = path.join(root, '.gstack');
|
|
if (!fs.existsSync(stateDir)) return;
|
|
if (fs.lstatSync(stateDir).isSymbolicLink()) fail('state directory is a link');
|
|
const stateFile = path.join(stateDir, 'browse.json');
|
|
const agentFile = path.join(stateDir, 'terminal-agent-pid');
|
|
for (const file of [stateFile, agentFile]) {
|
|
if (fs.existsSync(file) && !fs.lstatSync(file).isFile()) fail('state record is not a regular file');
|
|
}
|
|
if (!fs.existsSync(stateFile)) {
|
|
if (fs.existsSync(agentFile)) fail('terminal record has no daemon state');
|
|
return;
|
|
}
|
|
const raw = fs.readFileSync(stateFile, 'utf8');
|
|
const state = JSON.parse(raw);
|
|
if (!Number.isSafeInteger(state.pid) || state.pid <= 1
|
|
|| typeof state.instanceId !== 'string' || !state.instanceId) fail('invalid daemon identity');
|
|
const agentRaw = fs.existsSync(agentFile) ? fs.readFileSync(agentFile, 'utf8') : undefined;
|
|
const agent = readAgentRecord(stateDir);
|
|
if (!agentRaw || !agent) fail('terminal identity is unavailable');
|
|
if (!isProcessAlive(state.pid)) fail('daemon exited before its owned processes could be identified');
|
|
const daemonStart = readPidStartTime(state.pid);
|
|
const command = readPidCmdline(state.pid);
|
|
if (!daemonStart || !(typeof state.serverPath === 'string' && command.includes(state.serverPath)
|
|
|| command.includes('--server') && /browse/.test(command))) fail('daemon identity is unavailable');
|
|
let cwd: string;
|
|
if (process.platform === 'linux') cwd = fs.realpathSync(`/proc/${state.pid}/cwd`);
|
|
else {
|
|
const result = spawnSync('lsof', ['-a', '-p', String(state.pid), '-d', 'cwd', '-Fn'], {
|
|
encoding: 'utf8', timeout: Math.min(1000, remaining()),
|
|
});
|
|
if (result.error || result.status !== 0) fail('daemon working directory is unavailable');
|
|
cwd = result.stdout.split('\n').find(line => line.startsWith('n'))?.slice(1) ?? '';
|
|
}
|
|
if (cwd !== root) fail('daemon belongs to another fixture');
|
|
if (agent && (agent.ownerPid !== state.pid || agent.ownerStartTime !== daemonStart
|
|
|| !isAgentRecordGone(agent) && !isOurAgent(agent, state.pid))) fail('terminal ownership is unconfirmed');
|
|
const processes = spawnSync('ps', ['-eo', 'pid=,ppid='], {
|
|
encoding: 'utf8', timeout: Math.min(1000, remaining()),
|
|
});
|
|
if (processes.error || processes.status !== 0) fail('owned child identities are unavailable');
|
|
const rows = processes.stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number));
|
|
const descendants = new Set<number>([state.pid]);
|
|
for (let previous = 0; previous !== descendants.size;) {
|
|
previous = descendants.size;
|
|
for (const [pid, parent] of rows) if (descendants.has(parent)) descendants.add(pid);
|
|
}
|
|
const chromium = [...descendants].filter(pid => pid !== state.pid && /chrom|headless_shell/i.test(readPidCmdline(pid)))
|
|
.map(pid => ({ pid, start: readPidStartTime(pid) }));
|
|
if (!chromium.length || chromium.some(child => !child.start)) fail('Chromium identity is unavailable');
|
|
if (state.chromiumPid !== undefined && !chromium.some(child => child.pid === state.chromiumPid
|
|
&& child.start === state.chromiumStartTime)) fail('Chromium ownership is unconfirmed');
|
|
const unchanged = () => {
|
|
if (fs.existsSync(stateFile) && fs.readFileSync(stateFile, 'utf8') !== raw) fail('daemon state was replaced');
|
|
if (fs.existsSync(agentFile) && fs.readFileSync(agentFile, 'utf8') !== agentRaw) fail('terminal state was replaced');
|
|
};
|
|
unchanged();
|
|
if (readPidStartTime(state.pid) !== daemonStart || readPidCmdline(state.pid) !== command) fail('daemon identity changed before stop');
|
|
unchanged();
|
|
remaining();
|
|
try { process.kill(state.pid, 'SIGINT'); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; }
|
|
const settled = (pid: number, start: string) => {
|
|
if (!isProcessAlive(pid)) return true;
|
|
const actual = readPidStartTime(pid);
|
|
if (actual && actual !== start) return true;
|
|
if (process.platform === 'linux') {
|
|
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').match(/^\d+ \(.*\) ([A-Z])/u)?.[1] === 'Z'; }
|
|
catch { return !isProcessAlive(pid); }
|
|
}
|
|
const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
|
|
encoding: 'utf8', timeout: Math.min(1000, remaining()),
|
|
});
|
|
return result.status === 0 && result.stdout.trim().startsWith('Z');
|
|
};
|
|
for (;;) {
|
|
unchanged();
|
|
remaining();
|
|
pending = [
|
|
...(!settled(state.pid, daemonStart) ? [`daemon ${state.pid}`] : []),
|
|
...chromium.filter(child => !settled(child.pid, child.start)).map(child => `Chromium ${child.pid}`),
|
|
...(agent && !isAgentRecordGone(agent) ? [`terminal ${agent.pid}`] : []),
|
|
];
|
|
if (!pending.length) {
|
|
unchanged();
|
|
remaining();
|
|
return;
|
|
}
|
|
if (pending.length === 1 && pending[0] === `daemon ${state.pid}`
|
|
&& performance.now() - started >= Math.min(1000, timeoutMs / 2)) {
|
|
if (readPidStartTime(state.pid) !== daemonStart || readPidCmdline(state.pid) !== command) fail('daemon identity changed before final termination');
|
|
unchanged();
|
|
try { process.kill(state.pid, 'SIGKILL'); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; }
|
|
}
|
|
await Bun.sleep(Math.min(25, remaining()));
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try { await settleOwnedBrowser(process.argv[2], Number(process.argv[3])); }
|
|
catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|