mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
update
This commit is contained in:
+227
-10
@@ -8,16 +8,29 @@
|
||||
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 { copyFile, readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SEEDS } from './palette.mjs';
|
||||
import {
|
||||
clearDraft,
|
||||
fontRelativePath,
|
||||
migrate,
|
||||
paths,
|
||||
pidAlive,
|
||||
readAnswers,
|
||||
readDraft,
|
||||
readJsonSoft,
|
||||
writeDraft,
|
||||
writeJsonAtomic,
|
||||
} from './design-context/store.mjs';
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pickerDir = path.join(scriptDir, 'picker');
|
||||
const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json');
|
||||
const fontsDir = path.resolve(process.cwd(), '.impeccable/design-interview/fonts');
|
||||
const brandAssetsDir = path.resolve(process.cwd(), '.impeccable/design-interview/assets');
|
||||
const store = paths(process.cwd());
|
||||
const answersPath = store.answersJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'];
|
||||
@@ -46,12 +59,17 @@ Options:
|
||||
--port PORT Scan for an open port from PORT (default: 8500)
|
||||
--cues-dir PATH Visual cues directory (default: .impeccable/visual-cues)
|
||||
--timeout MINUTES Exit 2 if nothing submits (default: 60)
|
||||
--fresh Start blank, ignoring any previous answers or draft
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
PICKER_URL URL Printed when the server is ready
|
||||
ANSWERS PATH Printed after answers.json is written
|
||||
|
||||
Also served, for the design context document the questionnaire reveals:
|
||||
/context.json The chat half of the interview, from the design-context store
|
||||
/cue.png The chosen cue image, copied into the store at submit
|
||||
|
||||
See reference/visual-cues.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
@@ -65,11 +83,21 @@ function readOption(args, index) {
|
||||
return { value: args[index + 1], next: index + 1 };
|
||||
}
|
||||
function parseArgs(args) {
|
||||
const options = { port: 8500, cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'), timeoutMinutes: 60 };
|
||||
const options = {
|
||||
port: 8500,
|
||||
cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'),
|
||||
timeoutMinutes: 60,
|
||||
fresh: false,
|
||||
doc: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--help' || arg === '-h') return { help: true };
|
||||
/* Value-less flags are read before the guard below, which would reject
|
||||
them, and before readOption, which demands a value for every flag. */
|
||||
if (arg === '--fresh') { options.fresh = true; continue; }
|
||||
if (arg === '--doc') { options.doc = true; continue; }
|
||||
if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`);
|
||||
|
||||
const { value, next } = readOption(args, index);
|
||||
@@ -182,9 +210,17 @@ if (options.help) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* A project interviewed by an older release keeps its answers, assets, and
|
||||
uploaded faces under the pre-store layout. Bring them across before serving. */
|
||||
await migrate(process.cwd());
|
||||
|
||||
const port = await findOpenPort(options.port);
|
||||
let completed = false;
|
||||
let timeout;
|
||||
let docWatch;
|
||||
/* In document mode the run already happened: this process serves the document
|
||||
built from it, and the edit session is what it waits on. */
|
||||
let docSession = null;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
@@ -201,13 +237,21 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && requestPath === '/submit') {
|
||||
/* Document mode is showing a run that already finished; there is nothing
|
||||
left to submit, and writing one would overwrite the answers it renders. */
|
||||
if (options.doc) {
|
||||
sendJson(response, 409, { error: 'The document is open; there is nothing to submit' });
|
||||
return;
|
||||
}
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
const answers = await readJsonBody(request);
|
||||
await mkdir(path.dirname(answersPath), { recursive: true });
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
await writeJsonAtomic(answersPath, answers);
|
||||
await copyChosenCue(answers);
|
||||
/* The run is on the record now, so the half-finished copy of it goes. */
|
||||
await clearDraft();
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -215,7 +259,7 @@ async function handleRequest(request, response) {
|
||||
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. */
|
||||
runtime/session.json, which the sibling writes at boot. */
|
||||
const doc = await spawnDocSession();
|
||||
response.once('finish', () => {
|
||||
console.log(`ANSWERS ${answersPath}`);
|
||||
@@ -226,6 +270,19 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* The questionnaire posts its whole form after every screen change, so a run
|
||||
the visitor walks away from resumes where they left it instead of starting
|
||||
over. The submission supersedes the draft and removes it. */
|
||||
if (request.method === 'POST' && requestPath === '/autosave') {
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
await writeDraft(await readJsonBody(request));
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploaded faces are stored, not parsed: the questionnaire defers validation
|
||||
// to the end, so the server only needs to put the bytes where the agent can
|
||||
// reach them and hand back the path the answers will carry.
|
||||
@@ -244,7 +301,7 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { path: path.join('.impeccable/design-interview/fonts', name) });
|
||||
sendJson(response, 200, { path: fontRelativePath(name) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,10 +309,40 @@ async function handleRequest(request, response) {
|
||||
sendJson(response, 405, { error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
/* One fetch tells the client how to start: which surface it is serving, and
|
||||
the answers to restore, if any. Never cached, because the draft moves
|
||||
while the questionnaire is open and a stale copy would restore a run the
|
||||
visitor has already moved past. */
|
||||
if (requestPath === '/boot.json') {
|
||||
const { prior, priorSource } = await resolvePrior();
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
sendJson(response, 200, {
|
||||
mode: options.doc ? 'doc' : 'questionnaire',
|
||||
prior,
|
||||
priorSource,
|
||||
/* Present only where the document is live for edits. Absent leaves it
|
||||
rendering read-only, which is the honest state when no session took. */
|
||||
doc: docSession ? { base: `http://127.0.0.1:${docSession.port}`, token: docSession.token } : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cues.json') {
|
||||
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
/* The chat half of the interview, and the chosen cue, both live in the store
|
||||
rather than the generation workspace. The document reads them after this
|
||||
process exits, so they carry the same cache rule the cue images do. */
|
||||
if (requestPath === '/context.json') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'context.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cue.png') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'cue.png', ['.png']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/fonts.json') {
|
||||
await serveFile(response, options.cuesDir, 'fonts.json', ['.json']);
|
||||
return;
|
||||
@@ -309,7 +396,35 @@ async function handleRequest(request, response) {
|
||||
await serveFile(response, pickerDir, assetPath);
|
||||
}
|
||||
|
||||
/* An unfinished run outranks a finished one: the draft is where the visitor
|
||||
actually is, the submission is where they last were. --fresh declines both. */
|
||||
async function resolvePrior() {
|
||||
if (options.fresh) return { prior: null, priorSource: null };
|
||||
const draft = await readDraft();
|
||||
if (draft) return { prior: draft, priorSource: 'draft' };
|
||||
const answers = await readAnswers();
|
||||
if (answers) return { prior: answers, priorSource: 'submitted' };
|
||||
return { prior: null, priorSource: null };
|
||||
}
|
||||
|
||||
/* The document renders the chosen cue long after this process is gone, and a
|
||||
later reopen has no generation workspace to reach into, so the one picked
|
||||
hero joins the store. A seed or custom palette names no cue: nothing to copy. */
|
||||
async function copyChosenCue(answers) {
|
||||
const slug = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
if (!slug || slug !== path.basename(slug)) return;
|
||||
try {
|
||||
await mkdir(path.dirname(store.cuePng), { recursive: true });
|
||||
await copyFile(path.join(options.cuesDir, `${slug}.png`), store.cuePng);
|
||||
} catch {
|
||||
/* Not a cue palette, or the workspace is gone. */
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnDocSession() {
|
||||
/* The read-only path is otherwise unreachable from a test, and a document
|
||||
that renders without an edit session is a real state worth exercising. */
|
||||
if (process.env.IMPECCABLE_DOC_SESSION_DISABLE === '1') return null;
|
||||
try {
|
||||
const docPort = await findOpenPort(port + 1);
|
||||
const docToken = randomUUID();
|
||||
@@ -331,6 +446,92 @@ async function spawnDocSession() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Document mode: serving the design context document on its own.
|
||||
============================================================ */
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Does the recorded session answer for itself? Also marks it adopted. */
|
||||
async function probeSession(record) {
|
||||
if (!record?.port || !record?.token) return false;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${record.port}/doc/state?token=${encodeURIComponent(record.token)}`,
|
||||
{ signal: AbortSignal.timeout(2000) },
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionRecord(deadlineMs) {
|
||||
const until = Date.now() + deadlineMs;
|
||||
for (;;) {
|
||||
const record = await readJsonSoft(store.sessionJson);
|
||||
if (record?.port) return record;
|
||||
if (Date.now() > until) return null;
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live session per project.
|
||||
*
|
||||
* A session that answers is rejoined, so reopening a tab closed a minute ago
|
||||
* lands back in the session the agent is already polling, and the probe itself
|
||||
* is what keeps it from being reaped. A dead record is cleared, and a recorded
|
||||
* process that will not answer is stopped and waited out before a replacement
|
||||
* is forked: two sessions would write one discovery file, and the loser's
|
||||
* shutdown would carry off the winner's record.
|
||||
*/
|
||||
async function adoptDocSession() {
|
||||
const recorded = await readJsonSoft(store.sessionJson);
|
||||
if (recorded && pidAlive(recorded.pid)) {
|
||||
if (await probeSession(recorded)) return recorded;
|
||||
try { process.kill(recorded.pid, 'SIGTERM'); } catch { /* already gone */ }
|
||||
for (let waited = 0; waited < 5000 && pidAlive(recorded.pid); waited += 200) await sleep(200);
|
||||
}
|
||||
await rm(store.sessionJson, { force: true }).catch(() => {});
|
||||
|
||||
if (!await spawnDocSession()) return null;
|
||||
/* A session forked before any tab exists has a short window to be adopted or
|
||||
it dies young, and in document mode the tab arrives only once a person
|
||||
opens the URL. This probe is the adoption. */
|
||||
const record = await waitForSessionRecord(5000);
|
||||
if (!record) return null;
|
||||
await probeSession(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/* The session ending is this process's completion signal in document mode.
|
||||
Liveness is the recorded process plus an answer from it, never the presence
|
||||
of the discovery file on its own: a session that crashes leaves the file
|
||||
behind, and a sibling shutting down can carry the file off while the real
|
||||
session is still serving. */
|
||||
function watchDocSession(record) {
|
||||
let misses = 0;
|
||||
const tick = async () => {
|
||||
if (completed) return;
|
||||
if (!pidAlive(record.pid)) return finishDocMode();
|
||||
misses = (await probeSession(record)) ? 0 : misses + 1;
|
||||
if (misses >= 2) return finishDocMode();
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
};
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
}
|
||||
|
||||
function finishDocMode() {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(docWatch);
|
||||
console.log('DOC_SESSION_ENDED');
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
function stopWithoutSubmission(message) {
|
||||
if (completed) return;
|
||||
clearTimeout(timeout);
|
||||
@@ -339,12 +540,28 @@ function stopWithoutSubmission(message) {
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
/* Document mode needs a run to show and a session to keep it editable, both
|
||||
settled before the URL is printed: an agent that reads PICKER_URL is told
|
||||
the document is ready. */
|
||||
if (options.doc) {
|
||||
if (!await readAnswers()) {
|
||||
console.error('No design interview found. Run /impeccable document to create one.');
|
||||
process.exit(1);
|
||||
}
|
||||
docSession = await adoptDocSession();
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`PICKER_URL http://127.0.0.1:${port}`);
|
||||
timeout = setTimeout(
|
||||
() => stopWithoutSubmission('Picker timed out without a submission.'),
|
||||
() => stopWithoutSubmission(options.doc
|
||||
? 'Design context document closed without an edit session.'
|
||||
: 'Picker timed out without a submission.'),
|
||||
options.timeoutMinutes * 60_000,
|
||||
);
|
||||
/* With no session there is nothing to outlive, so the ceiling is the only
|
||||
limit and the document stays up read-only until it runs out. */
|
||||
if (options.doc && docSession) watchDocSession(docSession);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
|
||||
Reference in New Issue
Block a user