feat(demo): record the base-path gate on camera
Quality gates / quality (push) Waiting to run

No app screen changed this release, so the demo now shows the nearest real
user-visible effect: `timmy-staging` is refused at startup with a reason that
names TIMMY_BASE_PATH, `/timmy-staging` starts and serves the app, `PORT=0`
announces the port it actually bound, and the gate file itself runs on screen
(7 passed, 0 failed). Every value on the card is read from those real runs.
This commit is contained in:
timmy committed 2026-11-05 12:18:22 +00:00
1 parent b2f95f8239
commit 4f81c35d48
1 file changed
+83 -1
+83 -1
View File
@@ -1,5 +1,5 @@
import { chromium } from 'playwright';
import { execFile } from 'node:child_process';
import { spawn, execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import os from 'node:os';
@@ -304,6 +304,88 @@ async function evidenceCardProof(id) {
await caption(`TIMMY ${version} • RELEASE DEMO`, 1200);
await caption(`This release: ${releaseDelta}`, 2600);
await caption('Automated checks replay this synthetic path before review', 1200);
// Issue #93 — the release gate itself, on camera. No app screen changed in this release, so the scene
// shows the nearest real user-visible effect: a misconfigured base path makes the app refuse to start
// instead of serving at a URL it cannot honour. Both probes spawn the committed server.mjs and quote
// what it actually printed; the third runs the gate file that decides the release.
async function startupProbe(basePath) {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: process.cwd(),
env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: basePath, TIMMY_VISION_ENABLED: '0', TIMMY_AGENT_ENABLED: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
const line = /^Timmy is listening on http:\/\/([^:]+):(\d+).*$/m;
const started = Date.now();
let outcome = null;
while (Date.now() - started < 20_000) {
const match = line.exec(stdout);
if (match) { outcome = { state: 'listening', port: Number(match[2]), line: match[0], ms: Date.now() - started }; break; }
if (child.exitCode !== null) { outcome = { state: 'exit', code: child.exitCode, ms: Date.now() - started }; break; }
await sleep(50);
}
if (!outcome) {
child.kill('SIGKILL');
throw new Error(`startup probe for ${JSON.stringify(basePath)} neither listened nor exited`);
}
const result = { ...outcome, stderr: stderr.trim() };
if (outcome.state === 'listening') {
result.status = (await fetch(`http://127.0.0.1:${outcome.port}${basePath.replace(/\/$/, '')}/`)).status;
const closed = new Promise(resolve => child.once('close', resolve));
child.kill('SIGTERM');
await closed;
}
return result;
}
const refusedStartup = await startupProbe('timmy-staging');
if (refusedStartup.state !== 'exit' || refusedStartup.code === 0 || !/TIMMY_BASE_PATH/.test(refusedStartup.stderr)) {
throw new Error(`a bare base path must be refused at startup: ${JSON.stringify(refusedStartup)}`);
}
const acceptedStartup = await startupProbe('/timmy-staging');
if (acceptedStartup.state !== 'listening' || acceptedStartup.status !== 200) {
throw new Error(`a canonical base path must start and serve the app: ${JSON.stringify(acceptedStartup)}`);
}
const gateStarted = Date.now();
const gateRun = await execFileAsync(process.execPath, ['--test', 'tests/staging-health.test.js'], { cwd: process.cwd() })
.then(result => ({ code: 0, out: result.stdout }))
.catch(error => ({ code: typeof error.code === 'number' ? error.code : 1, out: `${error.stdout || ''}${error.stderr || ''}` }));
const gateSeconds = ((Date.now() - gateStarted) / 1000).toFixed(1);
const gatePass = /^# pass (\d+)$/m.exec(gateRun.out);
const gateFail = /^# fail (\d+)$/m.exec(gateRun.out);
if (gateRun.code !== 0 || !gatePass || !gateFail || Number(gatePass[1]) !== 7 || Number(gateFail[1]) !== 0) {
throw new Error(`the base-path gate must pass 6/6 on the committed tree: ${gateRun.out.slice(-900)}`);
}
const refusalLine = (/TIMMY_BASE_PATH[^.]*\./.exec(refusedStartup.stderr) || ['TIMMY_BASE_PATH was not named'])[0];
await caption('No app screen changed in this release — the nearest visible effect is startup: a bad base path must refuse, not serve', 3200);
await evidenceCard({
id: 'release-gate-basepath',
title: 'RELEASE GATE · BASE PATH',
subtitle: 'the running app and the gate file — real output, not app UI',
rows: [
['rejected', `"timmy-staging" · exit ${refusedStartup.code} in ${refusedStartup.ms} ms`],
['reason', refusalLine],
['accepted', `"/timmy-staging" · port ${acceptedStartup.port} in ${acceptedStartup.ms} ms · served ${acceptedStartup.status}`],
['gate rule', 'accepted only if the child prints its own "Timmy is listening" line'],
['timeout', 'neither exit nor listening → indeterminate, never "accepted"'],
['ports', 'PORT=0 → the line names the bound port; a stolen port is retried'],
['gate file', `node --test staging-health · ${gatePass[1]} passed · ${gateFail[1]} failed · ${gateSeconds} s`],
],
font: '600 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace',
keyWidth: 66,
rowGap: '8px',
});
await sleep(420);
const basePathGateProof = await evidenceCardProof('release-gate-basepath');
if (!basePathGateProof.ok) throw new Error(`Base-path gate card is not fully readable on screen: ${JSON.stringify(basePathGateProof)}`);
await caption('Bare "timmy-staging" is rejected by the app itself: exit 1, and the reason names TIMMY_BASE_PATH', 3000);
await caption('The gate decides that from the server’s own startup line, never from a timer a slow host can beat', 2800);
await caption('Asked for port 0, the app now names the port it actually bound — the gate follows that instead of guessing', 3000);
await caption('And the gate takes its ports from the kernel, so a port stolen by an unrelated socket cannot fail a build that passed', 3000);
await page.evaluate(() => document.querySelector('#release-gate-basepath')?.remove());
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
await tap('[data-scan]', 450);
await page.getByText(/Self-hosted model ready/i).waitFor();