mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept
A ground-up hardening of live mode, driven by a production session in a nested-app monorepo that hit six distinct failure classes. Full design rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now has a mechanical fix and a regression test. Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot (keyed on dev-server configs, not monorepo brand markers), persists a manifest, and every live CLI re-anchors onto it at startup, so a helper run from the wrong directory can no longer fork session state. Context files are discovered upward to the git root. Render truth: variant_mounted / variant_mount_failed events give the journal per-variant mount state; failures reach the agent's poll queue, raise a persistent error card with Retry (no more localStorage wipe), and an attach probe names root/dev-server mismatches explicitly. The browser rehydrates from the server when localStorage is gone. Svelte: the scaffolder now parses with the app's own svelte 5 compiler. Control flow survives (an each collection crosses the contract as one structured prop), keyed each blocks hydrate synthetic keys, and anything a detached preview cannot support falls back to source-preview instead of shipping a wrong scaffold. Preview modules live in per-publish revision directories, defeating stale transform caches. Accept: CSS is reconciled, not appended. Matching selectors are replaced, params bake from params.json kinds, the compiler's unused-selector pass prunes superseded rules (pre-existing dead rules protected), a selector- loss postcondition refuses any write that would drop hand-written rules, and live-complete refuses to finish while live plumbing remains in source. Also: framework registry (live/frameworks/) with a crash-safe injection journal, session-store snapshot caching with read-only reads, protocol enum consolidation, steer Send button, honest DESIGN-panel empty states. Testing: new unit suites (roots, AST scaffolder, accept CSS, accept pipeline, framework conformance); e2e now fails on preview-tree 404s, proves computed-style mount for every variant, drives the Tune panel through baked params, and injects failures (broken mounts, republish, storage loss). New runtime fixtures: monorepo-nested-vite (repo root != app root) and vite8-sveltekit-stateful (each blocks + state). Nightly full-matrix cron. An independent adversarial review pass preceded this commit; its blocker and major findings are fixed and regression-tested. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
839dd10079
commit
17dabf4b7e
+117
-16
@@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs';
|
||||
import { validateEvent } from './live/event-validation.mjs';
|
||||
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
LIVE_COMMANDS,
|
||||
VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
|
||||
} from './live/vocabulary.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
@@ -51,24 +54,46 @@ import {
|
||||
} from './live/manual-apply.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
bumpSvelteComponentPreviewRevision,
|
||||
removeAllSvelteComponentSessions,
|
||||
sweepInactiveSvelteComponentSessions,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
// Anchor the whole process on the live roots manifest before anything derives
|
||||
// a path from cwd. A server started from the wrong directory re-roots itself
|
||||
// onto the appRoot the boot decided on instead of minting a second project.
|
||||
const LIVE_ROOTS = enterLiveRoot(process.cwd());
|
||||
|
||||
// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
|
||||
// that outlives an `impeccable document` run (or a context file created after
|
||||
// boot) reports current truth instead of a boot-time snapshot. The roots
|
||||
// manifest wins when the ambient resolution misses (nested app inheriting
|
||||
// repo-level context files).
|
||||
function resolveProjectContext() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
const designPath = ctx.designPath
|
||||
? path.resolve(process.cwd(), ctx.designPath)
|
||||
: (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
|
||||
const hasProduct = ctx.hasProduct
|
||||
|| !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
|
||||
return {
|
||||
...ctx,
|
||||
hasProduct,
|
||||
hasDesign: !!designPath,
|
||||
resolvedDesignPath: designPath,
|
||||
contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
|
||||
designContextDir: ctx.designContextDir
|
||||
|| (designPath ? path.dirname(designPath) : null),
|
||||
};
|
||||
}
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
// The browser checkpoints for several unrelated reasons (see checkpointPayload
|
||||
// in live-browser.js). Only these two report that variant availability changed,
|
||||
// and only they may drive variant_progress / the *_reviewable phases.
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
@@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
generationCompletedAt: snapshot.generationCompletedAt ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
// Render truth, so a browser with no localStorage can rehydrate to the
|
||||
// same comparison the server already knows about.
|
||||
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
|
||||
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
|
||||
renderState: snapshot.renderState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -618,7 +648,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
return !!resolveProjectContext().hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const projectContext = resolveProjectContext();
|
||||
const mdPath = projectContext.resolvedDesignPath;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
if (msg.type !== 'checkpoint') {
|
||||
// `variant_mounted` is the happy path: it is journaled above so the
|
||||
// snapshot carries render truth, but there is nothing for the agent to
|
||||
// do about it, so it stays out of the poll queue and off the SSE bus.
|
||||
// `variant_mount_failed` is the opposite: the agent published something
|
||||
// the browser could not render, and only the agent can fix it, so it
|
||||
// goes to the queue as a first-class event.
|
||||
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') {
|
||||
enqueueEvent(msg);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
const base = { file: normalized };
|
||||
const metadataFile = normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('node_modules/.impeccable-live/')
|
||||
if (!metadataFile.includes('.impeccable/live/previews/')
|
||||
&& !metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')) return base;
|
||||
|
||||
@@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
|
||||
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
|
||||
// New pollers send sourceEventType explicitly; default to generate only for
|
||||
// older callers so a late worker cannot acknowledge a queued Accept.
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') {
|
||||
// A `done` reply to a mount failure is the republish that unblocks the
|
||||
// browser. Without this the ack would look for a `generate` that was
|
||||
// already retired, the mount-failure event would stay queued, and the next
|
||||
// poll would hand the same failure back to the agent forever.
|
||||
if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
|
||||
return 'generate';
|
||||
}
|
||||
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
|
||||
// never sets sourceEventType on it (the poller is a fresh process that cannot
|
||||
// know what it leased). Returning undefined here makes acknowledgePendingEvent
|
||||
@@ -1264,6 +1309,15 @@ function handlePollPost(req, res) {
|
||||
return;
|
||||
}
|
||||
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
|
||||
// A publish (done reply carrying a component manifest) snapshots the
|
||||
// variant files into a fresh revision dir before the browser is told:
|
||||
// the import path changes every publish, so no transform cache can pin a
|
||||
// stale compile of a republished module (node_modules is unwatched).
|
||||
if (replyFileMeta.previewMode === 'svelte-component'
|
||||
&& msg.id
|
||||
&& (msg.type === 'done' || !msg.type)) {
|
||||
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
}
|
||||
if (state.sessionStore && msg.id && !skipJournalReply) {
|
||||
try {
|
||||
const eventType = msg.type === 'steer_done'
|
||||
@@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A previous run that died without its shutdown hook leaves preview component
|
||||
* dirs behind. Drop the ones whose session the store no longer considers
|
||||
* active; anything still active is mid-generation and must survive a restart.
|
||||
*/
|
||||
function sweepOrphanSvelteComponentSessionsOnStartup() {
|
||||
try {
|
||||
const activeIds = (state.sessionStore?.listActiveSessions() || [])
|
||||
.map((snapshot) => snapshot?.id)
|
||||
.filter(Boolean);
|
||||
const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
|
||||
if (result.removed.length > 0 || result.removedRoot) {
|
||||
console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept receipts are a short-lived idempotency record for a single accept.
|
||||
// Nothing reads one after the session that wrote it is gone, so they only need
|
||||
// to outlive a crash-and-retry window.
|
||||
const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function sweepStaleAcceptReceiptsOnStartup() {
|
||||
try {
|
||||
const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
|
||||
let removed = 0;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(file).mtimeMs >= cutoff) continue;
|
||||
fs.rmSync(file, { force: true });
|
||||
removed++;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
try {
|
||||
const result = applyDeferredSvelteComponentAccepts(process.cwd());
|
||||
@@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({
|
||||
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
|
||||
});
|
||||
applyLegacyDeferredAcceptsOnStartup();
|
||||
sweepOrphanSvelteComponentSessionsOnStartup();
|
||||
sweepStaleAcceptReceiptsOnStartup();
|
||||
restorePendingEventsFromStore();
|
||||
manualApply.pruneStaleEvidence();
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
|
||||
Reference in New Issue
Block a user