mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Design-document edit session (self-contained, zero dependencies).
|
|
*
|
|
* The picker server forks this detached sibling the moment the questionnaire
|
|
* submits, so the review tab's design context document stays connected after
|
|
* the picker itself exits 0 (the agent's completion signal). It runs on its
|
|
* own pre-scanned port with CORS open to the picker origin, and it mediates
|
|
* three parties the way the live server does, scaled down to polling:
|
|
*
|
|
* browser --POST /doc/edit-----------> applied here (simple edits)
|
|
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
|
|
* agent --POST /doc/reply---------> queue status + version bump
|
|
* browser --GET /doc/state (poll)--> { version, requests } -> re-render
|
|
*
|
|
* Simple edits (a palette color) are deterministic: this process rewrites
|
|
* answers.json and swaps the value in DESIGN.md itself, no model involved.
|
|
* Anything needing judgment queues for the agent, which long-polls through
|
|
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
|
|
*
|
|
* Session discovery for the agent CLI: .impeccable/design-interview/
|
|
* doc-session.json { pid, port, token }. Removed on exit. Every applied
|
|
* simple edit is journaled to doc-edits.jsonl in the same directory so the
|
|
* agent can reconcile prose (a renamed color's description) at session end.
|
|
*
|
|
* Usage (spawned by picker-server.mjs, not by hand):
|
|
* node picker-doc-session.mjs --port 8501 --timeout 60
|
|
* with IMPECCABLE_DOC_TOKEN in the environment.
|
|
*/
|
|
|
|
import http from 'node:http';
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview');
|
|
const answersPath = path.join(interviewDir, 'answers.json');
|
|
const sessionPath = path.join(interviewDir, 'doc-session.json');
|
|
const ledgerPath = path.join(interviewDir, 'doc-edits.jsonl');
|
|
const fontsDir = path.join(interviewDir, 'fonts');
|
|
const designPath = path.resolve(process.cwd(), 'DESIGN.md');
|
|
|
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
|
const ROLES = new Set(['primary', 'secondary', 'tertiary', 'neutral']);
|
|
const REQUEST_KINDS = new Set(['font', 'freeform']);
|
|
/* Long polls are sliced under common proxy/undici header timeouts, the same
|
|
270s ceiling live-poll uses. */
|
|
const MAX_POLL_MS = 270_000;
|
|
/* The tab polls /doc/state every couple of seconds while open; when it has
|
|
been quiet this long the session is over and the agent's poll gets exit. */
|
|
const BROWSER_GONE_MS = 10 * 60_000;
|
|
/* A tab adopts the session within seconds of the submit that forked it. If
|
|
no poll ever arrives (a test harness, a closed tab), die young instead of
|
|
holding a port for the full ceiling. */
|
|
const ADOPT_GRACE_MS = 90_000;
|
|
|
|
const args = process.argv.slice(2);
|
|
const readArg = (name, fallback) => {
|
|
const at = args.indexOf(name);
|
|
return at !== -1 && args[at + 1] ? args[at + 1] : fallback;
|
|
};
|
|
const port = Number(readArg('--port', '0'));
|
|
const timeoutMinutes = Number(readArg('--timeout', '60'));
|
|
const token = process.env.IMPECCABLE_DOC_TOKEN || '';
|
|
if (!port || !token) {
|
|
console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.');
|
|
process.exit(1);
|
|
}
|
|
|
|
let version = 1;
|
|
let requestSeq = 0;
|
|
const requests = [];
|
|
let lastBrowserSeen = Date.now();
|
|
let adopted = false;
|
|
const parkedPolls = [];
|
|
|
|
function sendJson(response, statusCode, body) {
|
|
response.writeHead(statusCode, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
function httpError(statusCode, message) {
|
|
const error = new Error(message);
|
|
error.statusCode = statusCode;
|
|
return error;
|
|
}
|
|
|
|
async function readJsonBody(request) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
|
|
chunks.push(chunk);
|
|
}
|
|
let value;
|
|
try {
|
|
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
} catch {
|
|
throw httpError(400, 'Body must be valid JSON');
|
|
}
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
|
|
return value;
|
|
}
|
|
|
|
const summarize = (entry) => ({
|
|
id: entry.id,
|
|
kind: entry.kind,
|
|
prompt: entry.prompt,
|
|
category: entry.category,
|
|
status: entry.status,
|
|
message: entry.message || '',
|
|
});
|
|
|
|
async function appendLedger(entry) {
|
|
await mkdir(interviewDir, { recursive: true });
|
|
await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' });
|
|
}
|
|
|
|
/* ============================================================
|
|
Simple edits — deterministic, applied here.
|
|
============================================================ */
|
|
|
|
async function applyColorEdit({ role, value }) {
|
|
if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role');
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color');
|
|
const hex = value.toUpperCase();
|
|
|
|
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
|
const previous = String(answers[`palette-${role}`] || '').toUpperCase();
|
|
answers[`palette-${role}`] = hex;
|
|
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
|
|
|
/* DESIGN.md may not exist yet (the agent writes the seed while the user
|
|
reads the document); the answers file is the source it will seed from,
|
|
so an early edit is already carried. */
|
|
let designTouched = false;
|
|
if (previous && previous !== hex) {
|
|
try {
|
|
const source = await readFile(designPath, 'utf8');
|
|
/* A hex value is regex-safe: a literal # and hex digits. */
|
|
const swapped = source.replace(new RegExp(previous, 'gi'), hex);
|
|
if (swapped !== source) {
|
|
await writeFile(designPath, swapped);
|
|
designTouched = true;
|
|
}
|
|
} catch {
|
|
/* No DESIGN.md yet. */
|
|
}
|
|
}
|
|
|
|
await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched });
|
|
return { role, from: previous, to: hex, designTouched };
|
|
}
|
|
|
|
const SIMPLE_EDITS = { color: applyColorEdit };
|
|
|
|
/* ============================================================
|
|
Complex edits — queued for the agent.
|
|
============================================================ */
|
|
|
|
function wakeParkedPolls() {
|
|
while (parkedPolls.length) {
|
|
const parked = parkedPolls.shift();
|
|
clearTimeout(parked.timer);
|
|
parked.resolve();
|
|
}
|
|
}
|
|
|
|
function nextPending() {
|
|
return requests.find((entry) => entry.status === 'pending');
|
|
}
|
|
|
|
async function handleDocPoll(response, query) {
|
|
const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS);
|
|
const deadline = Date.now() + budget;
|
|
|
|
for (;;) {
|
|
if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) {
|
|
sendJson(response, 200, { type: 'exit', reason: 'browser-gone' });
|
|
return;
|
|
}
|
|
const entry = nextPending();
|
|
if (entry) {
|
|
entry.status = 'working';
|
|
bumpVersion();
|
|
sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
|
|
return;
|
|
}
|
|
const remaining = deadline - Date.now();
|
|
if (remaining <= 0) {
|
|
sendJson(response, 200, { type: 'timeout' });
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) };
|
|
parkedPolls.push(parked);
|
|
});
|
|
}
|
|
}
|
|
|
|
function bumpVersion() {
|
|
version += 1;
|
|
}
|
|
|
|
/* ============================================================
|
|
Server
|
|
============================================================ */
|
|
|
|
const server = http.createServer((request, response) => {
|
|
void handleRequest(request, response).catch((error) => {
|
|
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
|
|
else response.destroy();
|
|
});
|
|
});
|
|
|
|
async function handleRequest(request, response) {
|
|
const url = new URL(request.url, 'http://localhost');
|
|
const requestPath = url.pathname;
|
|
|
|
if (request.method === 'OPTIONS') {
|
|
response.writeHead(204, {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename',
|
|
'Access-Control-Max-Age': '600',
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
/* Font uploads carry bytes, not JSON; token rides the query string. */
|
|
if (request.method === 'POST' && requestPath === '/font-upload') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
const name = path.basename(request.headers['x-font-filename'] || '');
|
|
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
|
|
throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename');
|
|
}
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
|
|
chunks.push(chunk);
|
|
}
|
|
await mkdir(fontsDir, { recursive: true });
|
|
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
|
sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) });
|
|
return;
|
|
}
|
|
|
|
if (request.method === 'GET' && requestPath === '/doc/state') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
lastBrowserSeen = Date.now();
|
|
adopted = true;
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
version,
|
|
requests: requests.map(summarize),
|
|
agentWaiting: parkedPolls.length > 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method === 'GET' && requestPath === '/doc/answers') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
|
sendJson(response, 200, { ok: true, version, answers });
|
|
return;
|
|
}
|
|
|
|
if (request.method === 'GET' && requestPath === '/doc/poll') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
await handleDocPoll(response, url.searchParams);
|
|
return;
|
|
}
|
|
|
|
if (request.method !== 'POST') throw httpError(404, 'Not found');
|
|
const body = await readJsonBody(request);
|
|
if (body.token !== token) throw httpError(403, 'Bad token');
|
|
|
|
if (requestPath === '/doc/edit') {
|
|
const apply = SIMPLE_EDITS[body.kind];
|
|
if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`);
|
|
const applied = await apply(body);
|
|
bumpVersion();
|
|
sendJson(response, 200, { ok: true, version, applied });
|
|
return;
|
|
}
|
|
|
|
if (requestPath === '/doc/request') {
|
|
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
|
|
const prompt = String(body.prompt || '').trim();
|
|
if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max');
|
|
requestSeq += 1;
|
|
const entry = {
|
|
id: `req-${String(requestSeq).padStart(3, '0')}`,
|
|
kind: body.kind,
|
|
prompt,
|
|
category: String(body.category || ''),
|
|
payload: body.payload && typeof body.payload === 'object' ? body.payload : {},
|
|
status: 'pending',
|
|
message: '',
|
|
};
|
|
requests.push(entry);
|
|
bumpVersion();
|
|
wakeParkedPolls();
|
|
sendJson(response, 200, { ok: true, id: entry.id, version });
|
|
return;
|
|
}
|
|
|
|
if (requestPath === '/doc/reply') {
|
|
const entry = requests.find((item) => item.id === body.id);
|
|
if (!entry) throw httpError(404, 'Unknown request id');
|
|
if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
|
|
entry.status = body.status === 'retry' ? 'pending' : body.status;
|
|
entry.message = String(body.message || '');
|
|
bumpVersion();
|
|
if (entry.status === 'pending') wakeParkedPolls();
|
|
sendJson(response, 200, { ok: true, version });
|
|
return;
|
|
}
|
|
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
|
|
server.listen(port, '127.0.0.1', async () => {
|
|
await mkdir(interviewDir, { recursive: true });
|
|
await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`);
|
|
});
|
|
|
|
server.on('error', () => process.exit(1));
|
|
|
|
/* The session dies with its audience: no browser poll for BROWSER_GONE_MS,
|
|
or the hard ceiling, whichever lands first. */
|
|
const reaper = setInterval(() => {
|
|
const quiet = Date.now() - lastBrowserSeen;
|
|
if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown();
|
|
}, 15_000);
|
|
const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000);
|
|
|
|
async function shutdown() {
|
|
clearInterval(reaper);
|
|
clearTimeout(ceiling);
|
|
wakeParkedPolls();
|
|
await rm(sessionPath, { force: true }).catch(() => {});
|
|
server.close(() => process.exit(0));
|
|
server.closeAllConnections?.();
|
|
setTimeout(() => process.exit(0), 1_000).unref();
|
|
}
|
|
|
|
process.once('SIGINT', shutdown);
|
|
process.once('SIGTERM', shutdown);
|