mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
541 lines
20 KiB
JavaScript
541 lines
20 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/save----------> applied to the store, batch queued
|
|
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
|
|
* agent --POST /doc/reply---------> queue status + version bump
|
|
* browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read
|
|
*
|
|
* It also serves the document's own images: GET /brand-assets/* for what the
|
|
* user supplied, GET /assets/* for the ones the picker ships. Both are
|
|
* token-gated and read-only. They are here rather than on the picker server
|
|
* because article images load when a view opens, which is always after the
|
|
* picker has exited.
|
|
*
|
|
* Edits made in the document stage in the browser and arrive here as one batch.
|
|
* Applying them is deterministic and belongs to this process: each change names
|
|
* a field, the field names a place in the store, and the value is written
|
|
* there. What reaches the agent afterwards is the reconciliation the store
|
|
* cannot do for itself, the prose in DESIGN.md and PRODUCT.md that describes
|
|
* those values. Anything needing judgment up front, a font change or a freeform
|
|
* ask, queues for the agent the same way, and it long-polls through
|
|
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
|
|
*
|
|
* This process is the only writer of the store while it runs; the agent's own
|
|
* follow-on values ride in on its reply. That is what keeps a save and an
|
|
* agent working at the same time from overwriting each other.
|
|
*
|
|
* Session discovery for the agent CLI: .impeccable/design-context/runtime/
|
|
* session.json { pid, port, token }. Removed on exit. Every applied change is
|
|
* journaled to runtime/journal.jsonl beside it, so a session that dies with a
|
|
* batch outstanding re-offers it and the agent can reconcile prose at the 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 { execFileSync } from 'node:child_process';
|
|
import http from 'node:http';
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs';
|
|
import { createSaveRoutes } from './design-context/session-routes.mjs';
|
|
|
|
const store = paths(process.cwd());
|
|
const answersPath = store.answersJson;
|
|
const contextPath = store.contextJson;
|
|
const sessionPath = store.sessionJson;
|
|
const fontsDir = store.fontsDir;
|
|
const brandAssetsDir = store.assetsDir;
|
|
/* The built picker beside this script: the document's own images (section
|
|
foils, rail textures, placeholder brand assets) are served from here after
|
|
the submit-flow picker server has exited. Read-only, image types only. */
|
|
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
|
|
|
|
/* The Hooks page reads and writes hook config through hook-admin.mjs, the one
|
|
writer whose shapes stay validated; this server only ferries JSON either
|
|
way. Sync is fine: the admin runs in milliseconds, holds no sockets, and a
|
|
moment of backpressure on the doc port costs nothing. */
|
|
const hookAdminScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-admin.mjs');
|
|
function runHookAdmin(args, input) {
|
|
const stdout = execFileSync(process.execPath, [hookAdminScript, ...args], {
|
|
cwd: process.cwd(),
|
|
input: input ?? '',
|
|
encoding: 'utf-8',
|
|
timeout: 15_000,
|
|
});
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
|
const BRAND_ASSET_MIME = new Map([
|
|
['.svg', 'image/svg+xml'],
|
|
['.png', 'image/png'],
|
|
['.jpg', 'image/jpeg'],
|
|
['.jpeg', 'image/jpeg'],
|
|
['.webp', 'image/webp'],
|
|
['.gif', 'image/gif'],
|
|
]);
|
|
const PICKER_ASSET_MIME = new Map([
|
|
['.png', 'image/png'],
|
|
['.jpg', 'image/jpeg'],
|
|
['.jpeg', 'image/jpeg'],
|
|
['.webp', 'image/webp'],
|
|
['.svg', 'image/svg+xml'],
|
|
]);
|
|
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 = [];
|
|
/* The save flow lives in its own module; this shell keeps the server, the
|
|
timers, and the token. Every applied save bumps the same version the tab
|
|
polls, so the document re-reads itself without a second signal. */
|
|
const saves = createSaveRoutes({ onChange: () => { bumpVersion(); wakeParkedPolls(); } });
|
|
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 || '',
|
|
});
|
|
|
|
/* ============================================================
|
|
Requests that need judgment, 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;
|
|
}
|
|
/* The values are already in the store; what is handed over is the prose
|
|
still owed to DESIGN.md and PRODUCT.md. The reply command travels with
|
|
the event so the instruction cannot drift from the contract. */
|
|
const batch = saves.takeBatchEvent((id) => `node picker-doc-poll.mjs --reply ${id} done "One line the user sees in the tab"`);
|
|
if (batch) {
|
|
sendJson(response, 200, batch);
|
|
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: fontRelativePath(name) });
|
|
return;
|
|
}
|
|
|
|
/* Brand-asset images for the document's Brand article. The picker server
|
|
serves the same directory while it lives; it exits on submit, and the
|
|
article's images load after that, so the tab fetches them from here
|
|
with the session token on the query string, the same rule as the
|
|
sibling GET routes. Filenames only, extension-gated, one directory. */
|
|
if (request.method === 'GET' && requestPath.startsWith('/brand-assets/')) {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
let assetName;
|
|
try {
|
|
assetName = decodeURIComponent(requestPath.slice('/brand-assets/'.length));
|
|
} catch {
|
|
throw httpError(400, 'Invalid path');
|
|
}
|
|
const extension = path.extname(assetName).toLowerCase();
|
|
const filePath = path.resolve(brandAssetsDir, assetName);
|
|
if (!assetName || assetName !== path.basename(assetName)
|
|
|| !BRAND_ASSET_MIME.has(extension)
|
|
|| path.relative(brandAssetsDir, filePath).startsWith('..')) {
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
let body;
|
|
try {
|
|
body = await readFile(filePath);
|
|
} catch {
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
response.writeHead(200, {
|
|
'Content-Type': BRAND_ASSET_MIME.get(extension),
|
|
'Content-Length': body.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Cache-Control': 'max-age=86400',
|
|
});
|
|
response.end(body);
|
|
return;
|
|
}
|
|
|
|
/* The chosen cue, copied into the store at submit (picker-server.mjs
|
|
copyChosenCue). The components cards and the Color article render it after
|
|
the picker server has exited, so the tab fetches it here: token-gated, one
|
|
fixed file, PNG only, the trust model of the routes beside it. A run whose
|
|
palette named no cue has no file, which is the 404. */
|
|
if (request.method === 'GET' && requestPath === '/cue.png') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
let body;
|
|
try {
|
|
body = await readFile(store.cuePng);
|
|
} catch {
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
response.writeHead(200, {
|
|
'Content-Type': 'image/png',
|
|
'Content-Length': body.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Cache-Control': 'max-age=86400',
|
|
});
|
|
response.end(body);
|
|
return;
|
|
}
|
|
|
|
/* The document's own static images, for the tab that outlives the picker
|
|
server: the submit flow exits on /submit, and article images only load when
|
|
a view opens, which is always after that. Same trust model as the route
|
|
above, token-gated and read-only, but contained rather than flat, because
|
|
the vendored files sit in per-category subdirectories. */
|
|
if (request.method === 'GET' && requestPath.startsWith('/assets/')) {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
let assetPath;
|
|
try {
|
|
assetPath = decodeURIComponent(requestPath.slice('/assets/'.length));
|
|
} catch {
|
|
throw httpError(400, 'Invalid path');
|
|
}
|
|
const extension = path.extname(assetPath).toLowerCase();
|
|
const filePath = path.resolve(pickerAssetsDir, assetPath);
|
|
const contained = path.relative(pickerAssetsDir, filePath);
|
|
if (!assetPath
|
|
|| assetPath.includes('\0')
|
|
|| !PICKER_ASSET_MIME.has(extension)
|
|
|| contained.startsWith('..')
|
|
|| path.isAbsolute(contained)) {
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
let body;
|
|
try {
|
|
body = await readFile(filePath);
|
|
} catch {
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
response.writeHead(200, {
|
|
'Content-Type': PICKER_ASSET_MIME.get(extension),
|
|
'Content-Length': body.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Cache-Control': 'max-age=86400',
|
|
});
|
|
response.end(body);
|
|
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,
|
|
batch: saves.summary(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
/* The Hooks page's live state: the shared config's hook switch and the
|
|
detector ignore lists, read through hook-admin so this server never
|
|
parses config shapes itself. */
|
|
if (request.method === 'GET' && requestPath === '/doc/hooks') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
let state;
|
|
try {
|
|
state = runHookAdmin(['state']);
|
|
} catch (error) {
|
|
throw httpError(500, String(error.stderr || error.message || error).trim().split('\n')[0]);
|
|
}
|
|
sendJson(response, 200, { ok: true, state });
|
|
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;
|
|
}
|
|
|
|
/* The chat half of the run, read fresh so an agent's rewrite reaches the tab. */
|
|
if (request.method === 'GET' && requestPath === '/doc/context') {
|
|
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
|
let stored = null;
|
|
try {
|
|
stored = JSON.parse(await readFile(contextPath, 'utf8'));
|
|
} catch {
|
|
/* A run whose chat half was never recorded still has a document. */
|
|
}
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
version,
|
|
modes: stored?.modes ?? null,
|
|
context: stored?.context ?? null,
|
|
});
|
|
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');
|
|
|
|
/* Everything staged in the document arrives at once. Applying is this
|
|
process's job; reconciling the prose around it is the agent's. */
|
|
if (requestPath === '/doc/save') {
|
|
const applied = await saves.save(body);
|
|
sendJson(response, 200, { ok: true, version, ...applied });
|
|
return;
|
|
}
|
|
|
|
/* The Hooks page's Apply: the full desired state arrives at once and is
|
|
written by hook-admin.mjs apply, with no model in the loop. Same
|
|
deterministic contract as /doc/save: complete or refused, never
|
|
approximately done. */
|
|
if (requestPath === '/doc/hooks') {
|
|
if (!body.state || typeof body.state !== 'object' || Array.isArray(body.state)) {
|
|
throw httpError(400, 'state must be a JSON object');
|
|
}
|
|
let state;
|
|
try {
|
|
state = runHookAdmin(['apply'], JSON.stringify(body.state));
|
|
} catch (error) {
|
|
throw httpError(400, String(error.stderr || error.message || error).trim().split('\n')[0]);
|
|
}
|
|
sendJson(response, 200, { ok: true, state });
|
|
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') {
|
|
// A save and a request are both replied to here, told apart by the id.
|
|
if (saves.hasPending() && String(body.id || '').startsWith('batch-')) {
|
|
const result = await saves.reply(body);
|
|
sendJson(response, 200, { ok: true, version, ...result });
|
|
return;
|
|
}
|
|
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 || '');
|
|
/* Values attached to the reply land in the store here, same as a batch
|
|
reply: the session stays the only writer while it runs. */
|
|
const applied = entry.status === 'pending' ? null : await saves.applyAgentUpdates(body);
|
|
saves.noteRequest(entry.id, entry.status);
|
|
bumpVersion();
|
|
if (entry.status === 'pending') wakeParkedPolls();
|
|
sendJson(response, 200, { ok: true, version, applied });
|
|
return;
|
|
}
|
|
|
|
throw httpError(404, 'Not found');
|
|
}
|
|
|
|
server.listen(port, '127.0.0.1', async () => {
|
|
await migrate(process.cwd());
|
|
await writeJsonAtomic(sessionPath, { pid: process.pid, port, token });
|
|
});
|
|
|
|
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();
|
|
/* Only if it is still ours. A session that outlived its tab can be shutting
|
|
down at the moment a newer one writes the same path, and taking the file
|
|
with it would leave the live session undiscoverable. */
|
|
const recorded = await readJsonSoft(sessionPath);
|
|
if (!recorded || recorded.pid === process.pid) {
|
|
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);
|