mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
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>
124 lines
5.9 KiB
JavaScript
124 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Recover the next agent action from the durable live-session journal.
|
|
*/
|
|
|
|
import { createLiveSessionStore } from './live/session-store.mjs';
|
|
import { enterLiveRoot } from './live/roots.mjs';
|
|
|
|
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
|
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
|
return `live-poll.mjs --reply ${id} done --data '<json>'`;
|
|
}
|
|
|
|
export function manualApplyResumeHint(event = {}) {
|
|
const summary = event.manualApplySummary || summarizeManualApplyEvent(event);
|
|
const parts = [];
|
|
if (summary.pageUrl) parts.push(`page ${summary.pageUrl}`);
|
|
if (summary.chunk) parts.push(`chunk ${summary.chunk.index}/${summary.chunk.total}`);
|
|
if (Number.isFinite(summary.opCount)) parts.push(`${summary.opCount} op(s)`);
|
|
if (Number.isFinite(summary.entryCount)) parts.push(`${summary.entryCount} entr${summary.entryCount === 1 ? 'y' : 'ies'}`);
|
|
if (summary.files?.length) parts.push(`likely files: ${summary.files.join(', ')}`);
|
|
const scope = parts.length ? ` (${parts.join(', ')})` : '';
|
|
return `Manual Apply pending${scope}. If you have not already leased it, run live-poll.mjs. Apply the source edits from the manual_edit_apply batch, then reply with ${manualApplyReplyCommand(event.id)}. Polling only leases this work item; it does not commit source edits. Do not run live-commit-manual-edits.mjs for this leased event. Do not poll again before replying.`;
|
|
}
|
|
|
|
function summarizeManualApplyEvent(event = {}) {
|
|
const entries = Array.isArray(event.batch?.entries) ? event.batch.entries : [];
|
|
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
|
|
return {
|
|
pageUrl: event.pageUrl || null,
|
|
chunk: event.chunk || null,
|
|
entryCount: entries.length,
|
|
opCount,
|
|
files: collectManualApplyFiles(event.batch),
|
|
};
|
|
}
|
|
|
|
function collectManualApplyFiles(batch) {
|
|
const files = [];
|
|
for (const entry of batch?.entries || []) {
|
|
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
|
|
}
|
|
for (const candidate of batch?.candidates || []) {
|
|
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
|
|
for (const item of candidate.textMatches || []) files.push(item.file);
|
|
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
|
|
for (const item of candidate.locatorMatches || []) files.push(item.file);
|
|
for (const item of candidate.contextTextMatches || []) files.push(item.file);
|
|
}
|
|
return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
|
|
}
|
|
|
|
/**
|
|
* The browser's render truth, folded into a small block the agent reads before
|
|
* it decides what to do. `arrivedVariants` only says the agent published;
|
|
* `renderState` says whether any of it reached a screen.
|
|
*/
|
|
export function renderSummary(snapshot = {}) {
|
|
return {
|
|
renderState: snapshot.renderState ?? null,
|
|
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
|
|
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
|
|
};
|
|
}
|
|
|
|
export function mountFailureAction(snapshot = {}) {
|
|
const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [];
|
|
const latest = failures[failures.length - 1];
|
|
if (!latest) return null;
|
|
const where = latest.url ? ` from ${latest.url}` : '';
|
|
const why = latest.error ? ` (${latest.error})` : '';
|
|
return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file <manifest or source path> for the queued variant_mount_failed event (or republish) so the browser retries.`;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { id: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--id') out.id = argv[++i];
|
|
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
|
else if (arg === '--help' || arg === '-h') out.help = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function resumeCli() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
|
return;
|
|
}
|
|
|
|
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
|
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
|
if (!snapshot) {
|
|
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
|
return;
|
|
}
|
|
|
|
const pending = snapshot.pendingEvent || null;
|
|
const render = renderSummary(snapshot);
|
|
// A failed render outranks the generic pending-event hint: the agent needs to
|
|
// know the user is staring at an error card, not at variants. A leased manual
|
|
// Apply still outranks both, because abandoning that lease loses user edits.
|
|
const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null;
|
|
const nextAction = pending?.type === 'manual_edit_apply'
|
|
? manualApplyResumeHint(pending)
|
|
: mountAction || (pending
|
|
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
|
: snapshot.phase === 'carbonize_required'
|
|
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
|
: snapshot.phase === 'accept_requested'
|
|
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
|
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`);
|
|
|
|
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2));
|
|
}
|
|
|
|
const _running = process.argv[1];
|
|
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
|
enterLiveRoot();
|
|
resumeCli();
|
|
}
|