mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
update
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/** What the design context document lets a person edit, and where it lands.
|
||||
*
|
||||
* Every editable field has an id the browser sends and this file resolves into
|
||||
* a file and a path inside it. That is what makes applying a change a
|
||||
* deterministic write rather than a search: the document names the field, not
|
||||
* the text it happens to hold.
|
||||
*
|
||||
* `file` is the store file the value lives in. For `context`, the path is
|
||||
* relative to the top-level `context` object, so `product.purpose` addresses
|
||||
* `context.product.purpose` inside context.json.
|
||||
*
|
||||
* `downstream` names the document the agent reconciles afterwards. The value
|
||||
* itself is already applied by the time the agent hears about it; what needs a
|
||||
* reader is the prose around it.
|
||||
*/
|
||||
|
||||
export const BINDINGS = {
|
||||
'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' },
|
||||
|
||||
'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_LEN = 2000;
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null);
|
||||
|
||||
/**
|
||||
* Turn what a contenteditable produced into something safe to write.
|
||||
*
|
||||
* Everything arriving here was typed into a browser, so it is treated as text
|
||||
* and nothing else: control characters go, newlines collapse (every bound field
|
||||
* is a single line in the document), and the length is capped where the field
|
||||
* says so. A value that survives is a string; a value that cannot be one throws.
|
||||
*/
|
||||
export function sanitizeValue(binding, raw) {
|
||||
if (binding.kind === 'color') {
|
||||
const value = String(raw ?? '').trim().toUpperCase();
|
||||
if (!HEX.test(value)) throw new Error('Expected a #rrggbb color');
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = String(raw ?? '')
|
||||
/* Newlines first, because they are the one control character with a
|
||||
meaning here: a pasted paragraph becomes one line rather than nothing. */
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
if (!text) throw new Error('Expected some text');
|
||||
return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN);
|
||||
}
|
||||
|
||||
/** Read a dotted path out of a plain object, without creating anything. */
|
||||
export function readPath(root, dotted) {
|
||||
return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root);
|
||||
}
|
||||
|
||||
/** Write a dotted path into a plain object, creating the objects on the way. */
|
||||
export function writePath(root, dotted, value) {
|
||||
const keys = dotted.split('.');
|
||||
const last = keys.pop();
|
||||
let node = root;
|
||||
for (const key of keys) {
|
||||
if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
|
||||
node = node[key];
|
||||
}
|
||||
node[last] = value;
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/** Taking a design context out of a project, and putting one into another.
|
||||
*
|
||||
* Two shapes, because they answer different questions. `design-context.md` is
|
||||
* for a reader, human or otherwise: one document that says what was decided
|
||||
* and why, which can be handed to another tool as the rules to follow. The
|
||||
* bundle is for this toolchain: everything needed to rebuild the store
|
||||
* somewhere else, including the bytes of the files the user supplied.
|
||||
*
|
||||
* The bundle carries the schema version, not the store. A store file's era is
|
||||
* readable from its own keys, and stamping the browser's submission would mean
|
||||
* rewriting what it sent.
|
||||
*/
|
||||
|
||||
import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
paths,
|
||||
readAnswers,
|
||||
readContext,
|
||||
readJsonSoft,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
writeJsonAtomic,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
export const BUNDLE_KIND = 'impeccable-design-context';
|
||||
export const BUNDLE_SCHEMA = 1;
|
||||
|
||||
const MAX_FILE_BYTES = 1024 * 1024;
|
||||
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const MIME = new Map([
|
||||
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'],
|
||||
['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
||||
]);
|
||||
|
||||
/* Exactly the three places an export puts bytes, and so exactly the three an
|
||||
import will write them back to. Anything else in a bundle is not ours. */
|
||||
const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/;
|
||||
|
||||
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
|
||||
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
|
||||
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
|
||||
|
||||
/* ============================================================
|
||||
Export
|
||||
============================================================ */
|
||||
|
||||
async function collectFiles(cwd, { includeAssets = true } = {}) {
|
||||
const target = paths(cwd);
|
||||
const files = [];
|
||||
const skipped = [];
|
||||
let total = 0;
|
||||
|
||||
const take = async (absolute, relative) => {
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await readFile(absolute);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) {
|
||||
skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' });
|
||||
return;
|
||||
}
|
||||
total += bytes.length;
|
||||
files.push({
|
||||
path: relative,
|
||||
mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream',
|
||||
base64: bytes.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
if (!includeAssets) return { files, skipped };
|
||||
|
||||
for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) {
|
||||
let names = [];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`);
|
||||
}
|
||||
await take(target.cuePng, 'cue.png');
|
||||
return { files, skipped };
|
||||
}
|
||||
|
||||
export async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) {
|
||||
const target = paths(cwd);
|
||||
const answers = await readAnswers(cwd);
|
||||
if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.');
|
||||
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const cues = await readJsonSoft(target.cuesJson);
|
||||
const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
/* A seed or custom palette names no cue, so there is no image and no dealt
|
||||
entry to carry. The hexes in the answers are the palette of record. */
|
||||
const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null;
|
||||
|
||||
const { files, skipped } = await collectFiles(cwd, { includeAssets });
|
||||
let designMd = null;
|
||||
try {
|
||||
designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8');
|
||||
} catch {
|
||||
/* Not written yet, which an import is told about rather than guessing. */
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: BUNDLE_SCHEMA,
|
||||
kind: BUNDLE_KIND,
|
||||
exportedAt: now.toISOString(),
|
||||
product: { name: stored.context?.product?.name || '' },
|
||||
context: stored,
|
||||
answers,
|
||||
/* Whole, never trimmed: the questionnaire validates the manifest by its
|
||||
pair count and quietly falls back to its own set at any other number. */
|
||||
fonts: await readJsonSoft(target.fontsManifestJson),
|
||||
chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null,
|
||||
designMd,
|
||||
files,
|
||||
...(skipped.length ? { skipped } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
The readable compilation
|
||||
============================================================ */
|
||||
|
||||
const line = (label, value) => (value ? `- **${label}:** ${value}\n` : '');
|
||||
|
||||
function paletteTable(answers) {
|
||||
const rows = ROLES
|
||||
.map((role) => [role, String(answers[`palette-${role}`] || '')])
|
||||
.filter(([, hex]) => hex);
|
||||
if (!rows.length) return '';
|
||||
return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
function perSurfaceTable(answers, surfaces) {
|
||||
const rows = [];
|
||||
for (const key of PER_SURFACE) {
|
||||
for (const mode of surfaces) {
|
||||
const value = answers[`${key}-${mode}`];
|
||||
if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]);
|
||||
}
|
||||
}
|
||||
if (!rows.length) return '';
|
||||
return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows
|
||||
.map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`)
|
||||
.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** One document a reader, or another tool, can follow without this toolchain. */
|
||||
export function renderMarkdown(bundle) {
|
||||
const context = bundle.context?.context || {};
|
||||
const answers = bundle.answers || {};
|
||||
const name = bundle.product?.name || 'This product';
|
||||
const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean);
|
||||
const out = [];
|
||||
|
||||
out.push(`# Design context: ${name}\n\n`);
|
||||
out.push('The decisions this product\'s design follows, and the reasoning behind them. ');
|
||||
out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n');
|
||||
|
||||
const audience = context.audience || {};
|
||||
if (Object.keys(audience).length) {
|
||||
out.push('## Audience\n\n');
|
||||
out.push(line('Primary', audience.primary));
|
||||
out.push(line('Secondary', audience.secondary));
|
||||
out.push(line('On arrival', audience.emotion));
|
||||
out.push(line('Leaving with', audience.leaving));
|
||||
if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`);
|
||||
if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`);
|
||||
if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const product = context.product || {};
|
||||
if (Object.keys(product).length) {
|
||||
out.push('## Product\n\n');
|
||||
out.push(line('Purpose', product.purpose));
|
||||
out.push(line('Success', product.success));
|
||||
out.push(line('Platform', product.platform));
|
||||
out.push(line('Primary conversion', product.conversion));
|
||||
if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`);
|
||||
if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`);
|
||||
if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const brand = context.brand || {};
|
||||
if (Object.keys(brand).length) {
|
||||
out.push('## Brand\n\n');
|
||||
if (brand.words?.length) out.push(line('Words', brand.words.join(', ')));
|
||||
out.push(line('Personality', brand.personality));
|
||||
if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`);
|
||||
if (brand.voice?.length) {
|
||||
out.push('\nVoice, as wording rather than adjectives:\n\n');
|
||||
for (const pair of brand.voice) {
|
||||
if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`);
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const interview = context.interview || {};
|
||||
if (interview.references?.length || interview.antiReference) {
|
||||
out.push('## References\n\n');
|
||||
for (const reference of interview.references || []) {
|
||||
if (typeof reference === 'string') out.push(`- ${reference}\n`);
|
||||
else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`);
|
||||
}
|
||||
const anti = interview.antiReference;
|
||||
if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`);
|
||||
else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out.push('## Decisions\n\n');
|
||||
if (surfaces.length) {
|
||||
out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `);
|
||||
out.push('The first of these owns any answer stated once for the whole product.\n\n');
|
||||
}
|
||||
out.push('### Palette\n\n');
|
||||
out.push(paletteTable(answers));
|
||||
if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`);
|
||||
|
||||
out.push('### Typography\n\n');
|
||||
out.push(line('Heading', answers['font-heading']));
|
||||
out.push(line('Body', answers['font-body']));
|
||||
out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`));
|
||||
out.push('\n');
|
||||
|
||||
if (answers['icon-pack-name']) {
|
||||
out.push('### Icons\n\n');
|
||||
out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`);
|
||||
if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`);
|
||||
out.push('\nEvery icon comes from this pack; do not mix sets.\n\n');
|
||||
}
|
||||
|
||||
const perSurface = perSurfaceTable(answers, surfaces);
|
||||
if (perSurface) {
|
||||
out.push('### Per surface\n\n');
|
||||
out.push(perSurface);
|
||||
}
|
||||
if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`);
|
||||
|
||||
if (bundle.designMd) {
|
||||
out.push('## DESIGN.md\n\n');
|
||||
out.push('The design document this context produced, verbatim.\n\n');
|
||||
out.push('<!-- begin DESIGN.md -->\n\n');
|
||||
out.push(bundle.designMd.trim());
|
||||
out.push('\n\n<!-- end DESIGN.md -->\n');
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) {
|
||||
const bundle = await buildBundle(cwd, { includeAssets, now });
|
||||
const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir;
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
const markdownPath = path.join(destination, 'design-context.md');
|
||||
const bundlePath = path.join(destination, 'design-context.bundle.json');
|
||||
await writeFile(markdownPath, renderMarkdown(bundle));
|
||||
await writeJsonAtomic(bundlePath, bundle);
|
||||
return { markdownPath, bundlePath, skipped: bundle.skipped || [] };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Import
|
||||
============================================================ */
|
||||
|
||||
export function validateBundle(bundle) {
|
||||
if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle');
|
||||
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`);
|
||||
if (bundle.schemaVersion !== BUNDLE_SCHEMA) {
|
||||
throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`);
|
||||
}
|
||||
if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers');
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) {
|
||||
validateBundle(bundle);
|
||||
const target = paths(cwd);
|
||||
|
||||
await writeAnswers(bundle.answers, cwd);
|
||||
const context = bundle.context && typeof bundle.context === 'object'
|
||||
? bundle.context
|
||||
: { schemaVersion: SCHEMA_VERSION };
|
||||
await writeContext(context, cwd);
|
||||
|
||||
let written = 0;
|
||||
for (const file of Array.isArray(bundle.files) ? bundle.files : []) {
|
||||
const relative = String(file?.path || '');
|
||||
/* Containment is not enough on its own: a bundle could otherwise name a
|
||||
store file and overwrite what was just written. Only the three places an
|
||||
export puts bytes are accepted. */
|
||||
if (!ALLOWED_FILE.test(relative)) {
|
||||
process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`);
|
||||
continue;
|
||||
}
|
||||
const absolute = path.resolve(target.storeDir, relative);
|
||||
if (path.relative(target.storeDir, absolute).startsWith('..')) continue;
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64'));
|
||||
written += 1;
|
||||
}
|
||||
|
||||
/* The questionnaire cannot run without a cue manifest: its palette screen
|
||||
loads the deck and the built-in seeds together, and neither arrives if the
|
||||
file is missing. An imported project gets a valid one either way, carrying
|
||||
the chosen cue's dealt values when the bundle brought them. */
|
||||
if (!(await readJsonSoft(target.cuesJson))) {
|
||||
await writeJsonAtomic(target.cuesJson, {
|
||||
cues: [],
|
||||
...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }),
|
||||
});
|
||||
}
|
||||
if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) {
|
||||
await writeJsonAtomic(target.fontsManifestJson, bundle.fonts);
|
||||
}
|
||||
|
||||
let designWritten = false;
|
||||
if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) {
|
||||
const designPath = path.resolve(cwd, 'DESIGN.md');
|
||||
if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) {
|
||||
await writeFile(designPath, bundle.designMd);
|
||||
designWritten = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/** The save flow behind the design context document.
|
||||
*
|
||||
* A person edits fields in the document; the edits stage in the browser and
|
||||
* arrive here as one batch when they press Apply. Applying is deterministic:
|
||||
* every change names a binding, the binding names a file and a path, and the
|
||||
* value is written through the store. Nothing is searched for and no model is
|
||||
* involved, which is what makes a save either complete or refused rather than
|
||||
* approximately done.
|
||||
*
|
||||
* What the agent gets afterwards is the reconciliation, not the write. The
|
||||
* values are already on disk by the time the batch reaches a poll; DESIGN.md
|
||||
* and PRODUCT.md are the agent's to bring in line with them.
|
||||
*
|
||||
* browser --POST /doc/save-------> applied here, journaled, batch queued
|
||||
* agent --GET /doc/poll-------> save_batch (leased)
|
||||
* agent --POST /doc/reply------> acknowledged, version bumped
|
||||
* browser --GET /doc/state------> version moved, so re-read and re-render
|
||||
*
|
||||
* The batch is journaled before it is offered and cleared only on an
|
||||
* acknowledgement, so a session that dies mid-flight re-offers it on the next
|
||||
* boot rather than losing the work.
|
||||
*/
|
||||
|
||||
import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs';
|
||||
import {
|
||||
appendJournal,
|
||||
readAnswers,
|
||||
readContext,
|
||||
replayJournal,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
const MAX_CHANGES = 100;
|
||||
/* Long enough that an agent doing real prose work is never raced, short enough
|
||||
that an agent that died does not hold the batch for the session's lifetime. */
|
||||
const LEASE_MS = 10 * 60_000;
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) {
|
||||
/* Recovered from the journal at boot: a batch the agent never acknowledged
|
||||
is still owed, whoever was running when it was made. */
|
||||
const replayed = replayJournal(cwd);
|
||||
let pending = replayed.pendingBatch
|
||||
? { ...replayed.pendingBatch, leaseUntil: 0 }
|
||||
: null;
|
||||
let counter = Number(replayed.lastSeq) || 0;
|
||||
|
||||
const summary = () => (pending
|
||||
? { id: pending.id, status: pending.status, count: pending.changes.length }
|
||||
: null);
|
||||
|
||||
function validate(body) {
|
||||
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
||||
if (!changes?.length) throw httpError(400, 'changes must be a non-empty array');
|
||||
if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`);
|
||||
|
||||
return changes.map((change) => {
|
||||
const binding = bindingFor(String(change?.bindingId ?? ''));
|
||||
if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`);
|
||||
let value;
|
||||
try {
|
||||
value = sanitizeValue(binding, change.to);
|
||||
} catch (error) {
|
||||
throw httpError(400, `${change.bindingId}: ${error.message}`);
|
||||
}
|
||||
return {
|
||||
bindingId: String(change.bindingId),
|
||||
binding,
|
||||
from: typeof change.from === 'string' ? change.from : '',
|
||||
to: value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** One read and one write per file, so a save lands whole or not at all. */
|
||||
async function applyToStore(changes) {
|
||||
const files = new Map();
|
||||
const load = async (file) => {
|
||||
if (!files.has(file)) {
|
||||
files.set(file, file === 'answers'
|
||||
? (await readAnswers(cwd)) || {}
|
||||
: (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION });
|
||||
}
|
||||
return files.get(file);
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const document = await load(change.binding.file);
|
||||
/* context.json wraps its payload, so a binding path addresses the
|
||||
context object rather than the file's own root. */
|
||||
const root = change.binding.file === 'context'
|
||||
? (document.context ??= {})
|
||||
: document;
|
||||
change.previous = String(readPath(root, change.binding.path) ?? '');
|
||||
writePath(root, change.binding.path, change.to);
|
||||
}
|
||||
|
||||
if (files.has('answers')) await writeAnswers(files.get('answers'), cwd);
|
||||
if (files.has('context')) await writeContext(files.get('context'), cwd);
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
hasPending: () => Boolean(pending),
|
||||
|
||||
/** POST /doc/save */
|
||||
async save(body) {
|
||||
if (pending) throw httpError(409, 'A save is already applying');
|
||||
const changes = validate(body);
|
||||
await applyToStore(changes);
|
||||
|
||||
for (const change of changes) {
|
||||
appendJournal({
|
||||
type: 'change',
|
||||
bindingId: change.bindingId,
|
||||
from: change.previous,
|
||||
to: change.to,
|
||||
}, cwd);
|
||||
}
|
||||
|
||||
counter += 1;
|
||||
const id = `batch-${String(counter).padStart(3, '0')}`;
|
||||
const recorded = changes.map(({ bindingId, previous, to, binding }) => ({
|
||||
bindingId,
|
||||
from: previous,
|
||||
to,
|
||||
downstream: binding.downstream,
|
||||
}));
|
||||
appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd);
|
||||
pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 };
|
||||
onChange();
|
||||
return { id, count: recorded.length };
|
||||
},
|
||||
|
||||
/** The event a polling agent is handed, or nothing when none is due. */
|
||||
takeBatchEvent(replyCommandFor) {
|
||||
if (!pending || pending.leaseUntil > Date.now()) return null;
|
||||
/* Stamped before anything awaits, so a second poll arriving in the same
|
||||
tick cannot be handed the same batch. */
|
||||
pending.leaseUntil = Date.now() + LEASE_MS;
|
||||
return {
|
||||
type: 'save_batch',
|
||||
id: pending.id,
|
||||
changes: pending.changes,
|
||||
downstream: pending.changes.filter((change) => change.downstream !== 'none'),
|
||||
replyCommand: replyCommandFor(pending.id),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /doc/reply for a batch.
|
||||
*
|
||||
* An unknown id keeps the lease and says which batch is actually owed, so
|
||||
* an agent that replied to the wrong thing can correct itself rather than
|
||||
* leaving the work stranded.
|
||||
*/
|
||||
async reply(body) {
|
||||
if (!pending) throw httpError(404, 'No save is waiting for a reply');
|
||||
if (body.id !== pending.id) {
|
||||
throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`);
|
||||
}
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) {
|
||||
throw httpError(400, 'status must be done, error, or retry');
|
||||
}
|
||||
|
||||
if (body.status === 'retry') {
|
||||
pending.leaseUntil = 0;
|
||||
onChange();
|
||||
return { ok: true, status: 'pending' };
|
||||
}
|
||||
|
||||
/* The agent's own follow-on writes ride here rather than going to the
|
||||
store directly, so this process stays the only writer while it runs. */
|
||||
const applied = await applyAgentUpdates(body, cwd);
|
||||
appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd);
|
||||
pending = null;
|
||||
onChange();
|
||||
return { ok: true, status: body.status, applied };
|
||||
},
|
||||
|
||||
/** Journaled so the tab re-reads on a font or freeform request too. */
|
||||
noteRequest(id, status) {
|
||||
appendJournal({ type: 'request', id, status }, cwd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-value updates an agent attaches to its reply.
|
||||
*
|
||||
* Answers keys are written as given, since the questionnaire's own vocabulary
|
||||
* is wider than the bound fields; context values go through their binding when
|
||||
* one exists, so the same rules apply to both writers.
|
||||
*/
|
||||
async function applyAgentUpdates(body, cwd) {
|
||||
const applied = { answers: 0, context: 0 };
|
||||
|
||||
if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) {
|
||||
const answers = (await readAnswers(cwd)) || {};
|
||||
for (const [key, value] of Object.entries(body.answers)) {
|
||||
if (typeof value !== 'string' && !Array.isArray(value)) continue;
|
||||
answers[key] = value;
|
||||
applied.answers += 1;
|
||||
}
|
||||
if (applied.answers) await writeAnswers(answers, cwd);
|
||||
}
|
||||
|
||||
if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) {
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const root = (stored.context ??= {});
|
||||
for (const [dotted, value] of Object.entries(body.context)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const binding = bindingFor(dotted);
|
||||
let next = value;
|
||||
if (binding) {
|
||||
try {
|
||||
next = sanitizeValue(binding, value);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
writePath(root, binding ? binding.path : dotted, next);
|
||||
applied.context += 1;
|
||||
}
|
||||
if (applied.context) await writeContext(stored, cwd);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/** The design-context store: the one place that knows where design context lives.
|
||||
*
|
||||
* Layout, under the project root:
|
||||
*
|
||||
* .impeccable/design-context/
|
||||
* context.json { schemaVersion, modes, context } the chat half of the interview
|
||||
* answers.json the questionnaire submission, flat FormData shape
|
||||
* assets/ brand files the user supplied
|
||||
* fonts/ font faces the user uploaded
|
||||
* cue.png the chosen hero, copied at submit so the document stands alone
|
||||
* runtime/ session.json, journal.jsonl, draft.json (gitignored)
|
||||
* exports/ design-context.md, design-context.bundle.json (gitignored)
|
||||
*
|
||||
* Two rules hold this together. Every write goes through writeJsonAtomic, so a
|
||||
* reader never sees a torn file. Every read comes off disk, so no process ever
|
||||
* answers from a copy the file has moved past.
|
||||
*
|
||||
* Zero dependencies beyond node: builtins, like every other picker script.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const STORE_DIR = '.impeccable/design-context';
|
||||
export const WORKSPACE_DIR = '.impeccable/visual-cues';
|
||||
/* The shape of context.json. Bump only when the shape changes, never for a release. */
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
const LEGACY_DIR = '.impeccable/design-interview';
|
||||
const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`;
|
||||
|
||||
export function paths(cwd = process.cwd()) {
|
||||
const store = path.resolve(cwd, STORE_DIR);
|
||||
const runtime = path.join(store, 'runtime');
|
||||
return {
|
||||
storeDir: store,
|
||||
contextJson: path.join(store, 'context.json'),
|
||||
answersJson: path.join(store, 'answers.json'),
|
||||
assetsDir: path.join(store, 'assets'),
|
||||
fontsDir: path.join(store, 'fonts'),
|
||||
cuePng: path.join(store, 'cue.png'),
|
||||
runtimeDir: runtime,
|
||||
sessionJson: path.join(runtime, 'session.json'),
|
||||
journalJsonl: path.join(runtime, 'journal.jsonl'),
|
||||
draftJson: path.join(runtime, 'draft.json'),
|
||||
exportsDir: path.join(store, 'exports'),
|
||||
workspaceDir: path.resolve(cwd, WORKSPACE_DIR),
|
||||
cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'),
|
||||
fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The project-relative path an uploaded font is reported by, and stored under. */
|
||||
export function fontRelativePath(name) {
|
||||
return path.join(STORE_DIR, 'fonts', name);
|
||||
}
|
||||
|
||||
export async function writeJsonAtomic(filePath, value) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
||||
await rename(temporary, filePath);
|
||||
}
|
||||
|
||||
export async function readJsonSoft(filePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson);
|
||||
export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value);
|
||||
export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson);
|
||||
export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value);
|
||||
export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson);
|
||||
export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value);
|
||||
export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {});
|
||||
|
||||
/* ============================================================
|
||||
The journal: append-only, replayed on every read.
|
||||
============================================================ */
|
||||
|
||||
/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */
|
||||
export function appendJournal(event, cwd = process.cwd()) {
|
||||
const { runtimeDir, journalJsonl } = paths(cwd);
|
||||
const seq = replayJournal(cwd).lastSeq + 1;
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`);
|
||||
return seq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the journal into the state a booting session needs.
|
||||
*
|
||||
* Lines the fold cannot use are collected rather than thrown: a legacy
|
||||
* doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn
|
||||
* final line is possible after a hard kill. Neither can move lastSeq or
|
||||
* resurrect a batch, so both are diagnostics, not failures.
|
||||
*/
|
||||
export function replayJournal(cwd = process.cwd()) {
|
||||
const { journalJsonl } = paths(cwd);
|
||||
const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] };
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(journalJsonl, 'utf8');
|
||||
} catch {
|
||||
return state;
|
||||
}
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) });
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) {
|
||||
state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null });
|
||||
continue;
|
||||
}
|
||||
state.entries.push(entry);
|
||||
if (entry.seq > state.lastSeq) state.lastSeq = entry.seq;
|
||||
if (entry.type === 'batch') {
|
||||
state.pendingBatch = entry.status === 'pending' ? entry : null;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Migration from the pre-store layout.
|
||||
============================================================ */
|
||||
|
||||
export function pidAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
/* EPERM means the process exists and is not ours to signal. */
|
||||
return error.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFile(from, to) {
|
||||
if (fs.existsSync(to) || !fs.existsSync(from)) return false;
|
||||
await mkdir(path.dirname(to), { recursive: true });
|
||||
await rename(from, to);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Directories move child by child: renaming onto an existing directory fails,
|
||||
and a run interrupted halfway leaves a destination that already exists. */
|
||||
async function moveDirContents(fromDir, toDir) {
|
||||
if (!fs.existsSync(fromDir)) return;
|
||||
await mkdir(toDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(fromDir)) {
|
||||
await moveFile(path.join(fromDir, name), path.join(toDir, name));
|
||||
}
|
||||
try {
|
||||
if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir);
|
||||
} catch {
|
||||
/* Something arrived between the read and the remove; leaving it is safe. */
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploaded-face paths were recorded as strings inside the answers themselves. */
|
||||
function rewriteFontSources(answers) {
|
||||
if (!answers || typeof answers !== 'object') return null;
|
||||
let touched = false;
|
||||
for (const [key, value] of Object.entries(answers)) {
|
||||
if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue;
|
||||
answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`);
|
||||
touched = true;
|
||||
}
|
||||
return touched ? answers : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a pre-store project onto the current layout. Idempotent and silent:
|
||||
* a project that is already current, or was never interviewed, does nothing.
|
||||
*
|
||||
* A live session of the old shape holds the old paths in its own constants, so
|
||||
* migrating under it would strand its writes. That case defers to the next boot.
|
||||
*/
|
||||
export async function migrate(cwd = process.cwd()) {
|
||||
const legacyDir = path.resolve(cwd, LEGACY_DIR);
|
||||
if (!fs.existsSync(legacyDir)) {
|
||||
await migrateContextFromCues(cwd);
|
||||
return { migrated: false, deferred: false };
|
||||
}
|
||||
|
||||
const legacySession = path.join(legacyDir, 'doc-session.json');
|
||||
const session = await readJsonSoft(legacySession);
|
||||
if (session && pidAlive(session.pid)) return { migrated: false, deferred: true };
|
||||
|
||||
const target = paths(cwd);
|
||||
await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson);
|
||||
await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl);
|
||||
await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir);
|
||||
await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir);
|
||||
|
||||
const answers = await readJsonSoft(target.answersJson);
|
||||
const rewritten = rewriteFontSources(answers);
|
||||
if (rewritten) await writeJsonAtomic(target.answersJson, rewritten);
|
||||
|
||||
await rm(legacySession, { force: true }).catch(() => {});
|
||||
try {
|
||||
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
|
||||
} catch {
|
||||
/* Files the migration does not own stay where they are. */
|
||||
}
|
||||
|
||||
await migrateContextFromCues(cwd);
|
||||
return { migrated: true, deferred: false };
|
||||
}
|
||||
|
||||
/* The chat half of the interview used to ride inside the cue manifest. It is
|
||||
not a generation artifact, so it moves to the store; cues.json keeps its
|
||||
cues and palette and is left untouched. */
|
||||
async function migrateContextFromCues(cwd) {
|
||||
const target = paths(cwd);
|
||||
if (fs.existsSync(target.contextJson)) return;
|
||||
const cues = await readJsonSoft(target.cuesJson);
|
||||
if (!cues) return;
|
||||
const hasModes = Array.isArray(cues.modes);
|
||||
const hasContext = cues.context && typeof cues.context === 'object';
|
||||
if (!hasModes && !hasContext) return;
|
||||
await writeJsonAtomic(target.contextJson, {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
...(hasModes ? { modes: cues.modes } : {}),
|
||||
...(hasContext ? { context: cues.context } : {}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user