Compare commits

..
Author SHA1 Message Date
timmy 147e8b99e6 fix(demo): keep each gate-card value on its own phrase boundary
Quality gates / quality (push) Waiting to run
The first take wrapped "7 passed · 0 failed" across two lines and split
"exit 1" in a caption, which reads as a mistake on a phone. Values are now
one fact per row, the label column fits, and the exit code is bound with a
non-breaking space. Verified by rendering the exact card at 405×720 and
reading the pixels back before re-recording.
2026-11-05 12:37:12 +00:00
timmy 4f81c35d48 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.
2026-11-05 12:18:22 +00:00
timmy b2f95f8239 fix(server): announce the port actually bound, not the port requested
`PORT=0` binds a kernel-assigned port but the startup line echoed the request,
printing ":0" — an address nothing listens on. The line is now the release
gate's only evidence of acceptance (see #93), so it has to be true: read the
bound address from the listener and print that. Covered by a new case in
tests/staging-health.test.js which serves /api/healthz on the announced port
and fails if the line still says ":0" (verified by reverting the log).
2026-11-05 12:18:22 +00:00
rockachopa bd4420b4d6 Merge pull request 'fix(test): decide base-path rejection from the server startup line (#93)' (#94) from timmy/93-deterministic-basepath-gate into main
Quality gates / quality (push) Waiting to run
2026-11-05 12:07:19 +00:00
3 changed files with 111 additions and 2 deletions

No files matched your search

+85 -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,90 @@ 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',
// One value per row, each short enough to wrap on a word boundary: splitting "0 failed" across two
// lines detaches a result from its label and reads as a mistake on the phone.
rows: [
['rejected', `"timmy-staging" · exit ${refusedStartup.code}`],
['reason', refusalLine],
['accepted', `"/timmy-staging" · port ${acceptedStartup.port} · HTTP ${acceptedStartup.status}`],
['line', '"Timmy is listening" is the only acceptance evidence'],
['ports', 'kernel-assigned · a stolen port is retried'],
['gate file', 'node --test tests/staging-health.test.js'],
['result', `${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\u00a01, 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();
+1 -1
View File
@@ -180,4 +180,4 @@ http.createServer(async(req,res)=>{
}
res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
}).listen(port,host,function(){const bound=this.address();console.log(`Timmy is listening on http://${host}:${bound.port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`)});
+25
View File
@@ -229,3 +229,28 @@ test('malformed and escaping base paths are rejected at startup', async () => {
assert.match(outcome.stderr, /TIMMY_BASE_PATH/);
}
});
// The startup line is the gate's only evidence of acceptance, so it has to name the port the process
// actually holds. It used to echo the requested port, which reads as ":0" for a kernel-assigned bind
// and sends anyone following it to a port nothing is listening on.
test('the startup line names the port actually bound, not the requested one', async t => {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: '0', TIMMY_VISION_ENABLED: '0', TIMMY_AGENT_ENABLED: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const outcome = await startupOutcome(child);
try {
assert.equal(outcome.listening, true, `the server must announce listening: ${outcome.stderr}`);
assert.doesNotMatch(outcome.stdout, / listening on http:\/\/[^:\s]+:0\b/);
const bound = /Timmy is listening on http:\/\/[^:]+:(\d+) ·/.exec(outcome.stdout);
assert.ok(bound, `the startup line must carry the bound port: ${outcome.stdout}`);
const response = await fetch(`http://127.0.0.1:${bound[1]}/api/healthz`);
assert.equal(response.status, 200);
assert.equal((await response.json()).ok, true);
} finally {
const closed = new Promise(resolve => child.once('close', resolve));
if (child.exitCode === null) child.kill('SIGTERM');
await closed;
}
});