Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147e8b99e6 | ||
|
|
4f81c35d48 | ||
|
|
b2f95f8239 | ||
|
|
bd4420b4d6 |
No files matched your search
@@ -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
@@ -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'}`)});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user