Files
pbakaus_impeccable/skill/scripts/picker-doc-session.mjs
T
2026-09-01 10:02:40 +05:00

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);