mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +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
@@ -5,17 +5,26 @@
|
||||
|
||||
import { canCreateInsert } from './insert-ui.mjs';
|
||||
|
||||
// The accepted visual action values come from the canonical vocabulary so the
|
||||
// validator, the picker UI, and the marketing demo never drift. Imported (not
|
||||
// just re-exported) so it is also in scope for the validators below.
|
||||
import { VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { VISUAL_ACTIONS };
|
||||
// The accepted protocol values come from the canonical vocabulary so the
|
||||
// validator, the store, the server, and the picker UI never drift. Imported
|
||||
// (not just re-exported) so they are also in scope for the validators below.
|
||||
import { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS };
|
||||
|
||||
const AGENT_PHASE_SET = new Set(AGENT_PHASES);
|
||||
|
||||
const ID_PATTERN = /^[0-9a-f]{8}$/;
|
||||
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
|
||||
const INSERT_POSITIONS = new Set(['before', 'after']);
|
||||
const FORBIDDEN_MANUAL_EDIT_TEXT_CHARS = ['<', '{', '}', '`'];
|
||||
|
||||
// Mount acknowledgements carry a module URL and a raw exception message from
|
||||
// the page. Both are attacker-adjacent (any script on the page can POST them
|
||||
// with the token it can already read), so they are length-capped before they
|
||||
// reach the journal.
|
||||
export const MOUNT_URL_MAX_LENGTH = 2000;
|
||||
export const MOUNT_ERROR_MAX_LENGTH = 1000;
|
||||
|
||||
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
|
||||
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
|
||||
|
||||
@@ -92,6 +101,36 @@ function validateManualEditEvent(msg, label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidMountVariant(value) {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount acknowledgements are the browser's answer to "did the thing you
|
||||
* published actually render". They are validated strictly because the render
|
||||
* truth in the session snapshot is built from them: a malformed ack that slid
|
||||
* through would report a variant as mounted that never was.
|
||||
*/
|
||||
function validateMountAck(msg) {
|
||||
if (!isValidId(msg.id)) return 'variant_mounted: missing or malformed id';
|
||||
if (!isValidMountVariant(msg.variant)) return 'variant_mounted: variant must be an integer 1-999';
|
||||
if (msg.url !== undefined) {
|
||||
if (typeof msg.url !== 'string') return 'variant_mounted: url must be string';
|
||||
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mounted: url too long';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateMountFailure(msg) {
|
||||
if (!isValidId(msg.id)) return 'variant_mount_failed: missing or malformed id';
|
||||
if (!isValidMountVariant(msg.variant)) return 'variant_mount_failed: variant must be an integer 1-999';
|
||||
if (typeof msg.url !== 'string' || !msg.url.trim()) return 'variant_mount_failed: url required';
|
||||
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mount_failed: url too long';
|
||||
if (typeof msg.error !== 'string' || !msg.error.trim()) return 'variant_mount_failed: error required';
|
||||
if (msg.error.length > MOUNT_ERROR_MAX_LENGTH) return 'variant_mount_failed: error too long';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateEvent(msg) {
|
||||
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
|
||||
switch (msg.type) {
|
||||
@@ -120,13 +159,21 @@ export function validateEvent(msg) {
|
||||
return null;
|
||||
case 'agent_phase':
|
||||
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
|
||||
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
|
||||
return 'agent_phase: missing or malformed phase';
|
||||
if (typeof msg.phase !== 'string' || !msg.phase) return 'agent_phase: missing phase';
|
||||
// The enum, not a shape pattern. A phase the browser cannot rank is a
|
||||
// phase the progress bar cannot show, so accepting an arbitrary
|
||||
// lowercase word only defers the failure to the UI.
|
||||
if (!AGENT_PHASE_SET.has(msg.phase)) {
|
||||
return 'agent_phase: unknown phase ' + msg.phase + ' (expected one of ' + AGENT_PHASES.join(', ') + ')';
|
||||
}
|
||||
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
|
||||
return 'agent_phase: durationMs must be a non-negative number';
|
||||
}
|
||||
return null;
|
||||
case 'variant_mounted':
|
||||
return validateMountAck(msg);
|
||||
case 'variant_mount_failed':
|
||||
return validateMountFailure(msg);
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
|
||||
Reference in New Issue
Block a user