mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
update
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/** Agent poll CLI for the design-document edit session.
|
||||
*
|
||||
* The picker forks picker-doc-session.mjs on submit; this is how the agent
|
||||
* hears from it, on the live-poll.mjs contract: one-shot by default, block
|
||||
* until one event arrives, print it as JSON on stdout, exit.
|
||||
*
|
||||
* node picker-doc-poll.mjs # block, print one event
|
||||
* node picker-doc-poll.mjs --timeout=600000 # total budget in ms
|
||||
* node picker-doc-poll.mjs --reply <id> <status> [message]
|
||||
*
|
||||
* Events printed: {"type":"edit_request","id","kind","prompt","category",
|
||||
* "payload"} for work, {"type":"timeout"} when the budget runs out (poll
|
||||
* again), {"type":"exit"} when the session ended (stop polling).
|
||||
*
|
||||
* Reply statuses: done (change applied; message shown to the user in the
|
||||
* document), error (could not apply; message explains), retry (release the
|
||||
* request back to pending).
|
||||
*
|
||||
* Session discovery: .impeccable/design-interview/doc-session.json, written
|
||||
* by the session process and removed when it exits; a missing file prints
|
||||
* {"type":"exit"} so a finished session never hangs the loop.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const sessionPath = path.resolve(process.cwd(), '.impeccable/design-interview/doc-session.json');
|
||||
/* Sliced under undici's 300s header timeout, same as live-poll. */
|
||||
const PER_REQUEST_MS = 270_000;
|
||||
const DEFAULT_TOTAL_MS = 600_000;
|
||||
|
||||
async function session() {
|
||||
try {
|
||||
return JSON.parse(await readFile(sessionPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function readFlag(name, fallback) {
|
||||
const exact = args.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (exact) return exact.slice(name.length + 1);
|
||||
const at = args.indexOf(name);
|
||||
if (at !== -1 && args[at + 1]) return args[at + 1];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const info = await session();
|
||||
if (!info) {
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'no-session' }));
|
||||
process.exit(0);
|
||||
}
|
||||
const base = `http://127.0.0.1:${info.port}`;
|
||||
|
||||
if (args.includes('--reply')) {
|
||||
const at = args.indexOf('--reply');
|
||||
const [id, status, ...rest] = args.slice(at + 1).filter((arg) => !arg.startsWith('--'));
|
||||
if (!id || !status) {
|
||||
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message]');
|
||||
process.exit(1);
|
||||
}
|
||||
const response = await fetch(`${base}/doc/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' ') }),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(await response.json()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const totalBudget = Number(readFlag('--timeout', DEFAULT_TOTAL_MS));
|
||||
const deadline = Date.now() + (Number.isFinite(totalBudget) && totalBudget > 0 ? totalBudget : DEFAULT_TOTAL_MS);
|
||||
|
||||
for (;;) {
|
||||
const slice = Math.min(deadline - Date.now(), PER_REQUEST_MS);
|
||||
if (slice <= 0) {
|
||||
console.log(JSON.stringify({ type: 'timeout' }));
|
||||
process.exit(0);
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
const response = await fetch(`${base}/doc/poll?token=${encodeURIComponent(info.token)}&timeout=${slice}`);
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* The session process exited between polls. */
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'session-gone' }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (payload.type === 'timeout') continue;
|
||||
console.log(JSON.stringify(payload));
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/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);
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -203,11 +205,19 @@ async function handleRequest(request, response) {
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
/* The document the review tab is about to reveal stays editable through a
|
||||
detached sibling: it owns the edit endpoints on its own port, so this
|
||||
process can still exit as the agent's completion signal. The tab learns
|
||||
where to reach it from this response; the agent learns from
|
||||
doc-session.json, which the sibling writes at boot. */
|
||||
const doc = await spawnDocSession();
|
||||
response.once('finish', () => {
|
||||
console.log(`ANSWERS ${answersPath}`);
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
sendJson(response, 200, { ok: true });
|
||||
sendJson(response, 200, { ok: true, doc });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,6 +285,28 @@ async function handleRequest(request, response) {
|
||||
await serveFile(response, pickerDir, assetPath);
|
||||
}
|
||||
|
||||
async function spawnDocSession() {
|
||||
try {
|
||||
const docPort = await findOpenPort(port + 1);
|
||||
const docToken = randomUUID();
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(scriptDir, 'picker-doc-session.mjs'),
|
||||
'--port', String(docPort),
|
||||
'--timeout', String(options.timeoutMinutes),
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, IMPECCABLE_DOC_TOKEN: docToken },
|
||||
});
|
||||
child.unref();
|
||||
return { base: `http://127.0.0.1:${docPort}`, token: docToken };
|
||||
} catch {
|
||||
/* The document still renders read-only; only the edit loop is lost. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopWithoutSubmission(message) {
|
||||
if (completed) return;
|
||||
clearTimeout(timeout);
|
||||
|
||||
Reference in New Issue
Block a user