Improve Live progressive responsiveness

Add transactional progressive publication, durable cancellation, responsive accept cleanup, and framework-safe Svelte and Nuxt previews.\n\nAI-assisted: OpenAI Codex.
This commit is contained in:
Paul Bakaus
2026-07-12 17:54:50 -07:00
parent ff67ad359e
commit 2106a2881f
40 changed files with 3833 additions and 206 deletions
+4
View File
@@ -125,6 +125,8 @@ export const SUITES = {
'tests/live-browser-session.test.mjs',
'tests/live-browser-source.test.mjs',
'tests/live-benchmark.test.mjs',
'tests/live-generation-preflight.test.mjs',
'tests/live-generation-publisher.test.mjs',
'tests/live-commit-manual-edits.test.mjs',
'tests/live-completion.test.mjs',
'tests/live-copy-edit-agent.test.mjs',
@@ -141,11 +143,13 @@ export const SUITES = {
'tests/live-manual-edits-buffer.test.mjs',
'tests/live-poll.test.mjs',
'tests/live-poll-stream.test.mjs',
'tests/live-provider-benchmark.test.mjs',
'tests/live-recovery-commands.test.mjs',
'tests/live-reference.test.mjs',
'tests/live-server.test.mjs',
'tests/live-session-store.test.mjs',
'tests/live-target-context.test.mjs',
'tests/live-vue-component.test.mjs',
'tests/live-wrap.test.mjs',
'tests/live-wrap-buffer-aware.test.mjs',
],
+55 -16
View File
@@ -17,18 +17,25 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; plan three distinct directions; deliver variants using the harness policy below; `--reply done`; poll again.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until a cleanup owner runs `live-complete.mjs --id EVENT_ID`; Codex delegates that cleanup and resumes the foreground poll immediately, while synchronous harnesses finish cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
- **Codex**: run the poll in the **foreground** (blocking shell; not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
- **Codex**: the main thread is the **foreground poll supervisor**. Keep the poll command itself in a yielded foreground exec session and retain its session id; do not suffix it with `&`. A yielded foreground process continues while other tool calls run, whereas a traditional shell-backgrounded child may be reaped when its shell exits. On `generate`, spawn one generation subagent/worker, give it the event plus scaffold, then poll again immediately in the main thread. The worker publishes variants and posts the generation reply; the supervisor remains available for early Accept/Discard and the next Go. Do not put the poll itself in a subagent or a fire-and-forget background shell: browser control events must return to the main thread immediately.
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
Generation delivery policy:
- **Default (Claude Code, Cursor, and other harnesses):** keep the established atomic single-edit delivery unless that harness has independently demonstrated that progressive tool calls are faster and reliable. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
<codex>
- **Codex progressive override:** deliver progressively through `live-publish.mjs`, never by editing project source directly. Publish variant 1 as soon as it is complete, then publish each additional validated variant (or the largest ready prefix) without waiting for later siblings. Attach parameter manifests only with the final set. The browser makes every arrived variant immediately reviewable and acceptable; Accept/Discard durably cancel unfinished revisions.
</codex>
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
## Start
@@ -96,14 +103,14 @@ Server restart rule: start `live-server.mjs` again, then poll. Startup requeues
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
Speed matters; the user is watching a spinner. Minimize tool calls by using the wrap/insert helper and writing all variants in a single edit.
Speed matters; the user is watching the selected element. Reuse server preflight metadata when available, minimize discovery calls, and follow the harness-specific delivery policy above.
### Insert mode branch
When `event.mode === "insert"`:
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
2. Run the insert helper instead of wrap:
2. If `event.scaffold` is present, use it as the insert-helper result and do **not** run the helper again. Otherwise run the insert helper instead of wrap:
```bash
node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
@@ -113,7 +120,7 @@ node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --positi
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. For Operate/Read surfaces load `operate.md`; Persuade/Experience surfaces use SKILL.md's mode guidance plus `new-work.md` when the variant invents identity (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. For Operate/Read surfaces load `operate.md`; Persuade/Experience surfaces use SKILL.md's mode guidance plus `new-work.md` when the variant invents identity (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -138,6 +145,8 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
```bash
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
```
@@ -157,7 +166,9 @@ Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssS
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
For Nuxt/Vue targets, `live-wrap.mjs` returns `previewMode: "vue-component"` with `file` pointing at an app-local generated manifest under `<appDir>/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at real Vue SFC variants, and `sourceFile` pointing at the untouched `.vue` route. Write `v1.vue`, `v2.vue`, … with one root inside `<template>` and variant CSS in `<style scoped>`; keep dynamic text on the `propContract` bindings as `{{ propName }}`. Do **not** rewrite `sourceFile` during generation: Nuxt/Vite compiles and mounts these dev-only modules without invalidating the route. Accept is the only route write and inlines the selected template/CSS under the source lock; Discard deletes the generated session.
**Params on component-preview paths go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, and both Svelte/Vue previews mount without an HTML variant wrapper. Declare params in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
@@ -295,11 +306,39 @@ In **departure mode**, the prompt narrows the lanes you draw from, not the famil
When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X, the anti-references ban X), the anti-references win; they describe the brand's standing position, the prompt is one moment.
### 6. Write all variants in a single edit
### 6. Deliver variants
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and keeps each delivered state internally complete (no FOUC).
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
<codex>
**Codex transactional progressive override:**
1. Plan all directions and name their parameter axes first so the trio remains coherent.
2. Prepare revision 1 from the scaffolded source:
```bash
node .agents/skills/impeccable/scripts/live-publish.mjs --prepare --id EVENT_ID --file SOURCE_FILE
```
The JSON result contains `artifactFile`, `epoch`, and `expectedSourceHash`. For the normal source-wrapper path, edit **only `artifactFile`** at `insertLine`: write variant 1 and only the CSS it needs. Do not attach `data-impeccable-params` yet.
For `previewMode: "svelte-component"` or `"vue-component"`, `artifactFile` is an isolated manifest and `componentDir` is its isolated component directory. Write `v1.svelte` or `v1.vue` under the returned `componentDir`, set the artifact manifest's `arrivedVariants` to `1`, and leave `params.json` absent. Keep `--file` pointed at the original live manifest on publish; the publisher fences against `targetSourceFile`, promotes the component, then commits the live manifest last. Never edit the live `componentDir` directly.
3. Publish revision 1 with the exact fence values returned by `--prepare`:
```bash
node .agents/skills/impeccable/scripts/live-publish.mjs --id EVENT_ID --epoch EPOCH \
--file SOURCE_FILE --artifact ARTIFACT_FILE --expected-source-hash SOURCE_HASH \
--arrived 1 --expected EVENT_COUNT
```
`{ok:false,error:"stale_generation_epoch"}` means the user already accepted or discarded. Stop immediately, do not touch source, and post the generation reply as canceled/error.
4. Continue with variants 2 and 3. Whenever another variant validates, run `--prepare` again so the next revision starts from the published prefix, add only the newly ready variant(s), then publish with `--arrived READY_COUNT`. Never hold variant 2 merely because variant 3 is unfinished. Attach parameter manifests for every variant only when `READY_COUNT === EVENT_COUNT`. On component-preview paths, preserve every already-published `vN.svelte` / `vN.vue` byte-for-byte; publication rejects a revision that silently changes a variant the user may already be reviewing.
5. Verify the published source parses, then `--reply done`. A late reply is diagnostic only after Accept/Discard and cannot move the durable session backward.
</codex>
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
@@ -323,7 +362,7 @@ Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporar
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
The browser's MutationObserver accepts either delivery shape. On the transactional progressive path it shows arrived variants and pending dots immediately; Accept and Discard are available as soon as one variant exists. Accepting an arrived variant fences the worker before the browser releases the picker, so later publications are rejected.
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
@@ -365,7 +404,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On `svelte-component` and `vue-component` paths, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path.
```html
<div data-impeccable-variant="1" data-impeccable-params='[
@@ -466,7 +505,7 @@ Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already
- The accept event includes `pageUrl`; the poll script must forward it to `live-accept.mjs --page-url PAGE_URL` so accept-time cleanup only scrubs staged copy edits for the current page.
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
- `_acceptResult.handled: true` and `carbonize: true`: post-accept cleanup is required, but it must not stall Codex's control lane. See "Required after accept (carbonize)" below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and stderr banner all point at this required follow-up; none are decorative.
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
@@ -474,7 +513,9 @@ Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
**Codex:** hand these five steps to the session's generation worker (or a dedicated cleanup worker) and restart the foreground poll immediately. The cleanup worker must not poll. It owns the source cleanup, validation, and final `live-complete.mjs --id SESSION_ID`. Track it by source file. A later Generate may be leased and planned while cleanup runs, but it must not publish against the old source revision: wait for cleanup or rerun publisher `--prepare` after a stale-source rejection. The source lock, generation epoch, and expected-source hash are the final safety gates.
**Other harnesses:** unless an equivalent independently supervised cleanup worker is proven, do these five steps synchronously before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
@@ -482,9 +523,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. The Codex supervisor keeps polling throughout; synchronous harnesses poll again only after that verification.
## Handle `discard`
+99 -10
View File
@@ -17,14 +17,21 @@ import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { withSourceLockSync } from './live/source-lock.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
import {
findVueComponentManifest,
inlineVueComponentAccept,
retireVueComponentSession,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
const ACCEPT_LOCK_WAIT_MS = 1_000;
// ---------------------------------------------------------------------------
// CLI
@@ -74,17 +81,80 @@ Output (JSON):
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
const vueComponentManifest = found || svelteComponentManifest ? null : findVueComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
if (!found && !svelteComponentManifest && !vueComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (vueComponentManifest) {
if (isDiscard) {
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'discard:' + id,
() => {
retireVueComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = { handled: false, error: err.message };
}
console.log(JSON.stringify({
...result,
file: vueComponentManifest.sourceFile,
carbonize: false,
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
'accept:' + id,
() => inlineVueComponentAccept(vueComponentManifest, variantNum, process.cwd()),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: vueComponentManifest.sourceFile,
sourceFile: vueComponentManifest.sourceFile,
previewMode: 'vue-component',
componentDir: vueComponentManifest.componentDir,
carbonize: false,
};
}
console.log(JSON.stringify(result));
return;
}
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'discard:' + id,
() => {
removeSvelteComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = { handled: false, error: err.message };
}
console.log(JSON.stringify({
handled: true,
...result,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
@@ -95,11 +165,16 @@ Output (JSON):
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'accept:' + id,
() => inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = {
@@ -235,7 +310,14 @@ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalB
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
function handleDiscard(id, _lines, targetFile) {
return withSourceLockSync(targetFile, 'discard:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleDiscardUnlocked(id, lines, targetFile);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleDiscardUnlocked(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -330,7 +412,14 @@ function reindentContent(contentLines, fromIndent, toIndent) {
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
return withSourceLockSync(targetFile, 'accept:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
+181 -55
View File
@@ -126,6 +126,7 @@
let expectedVariants = 0;
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
let svelteComponentSession = null;
let svelteRuntimePromise = null;
let pendingSvelteComponentRetryObserver = null;
@@ -252,6 +253,13 @@
barConnected: !!barEl?.isConnected,
hasSvelteComponentSession: !!svelteComponentSession,
mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0,
pickActive,
pendingApplyInFlight,
hoveredElement: hoveredElement ? {
tag: hoveredElement.tagName,
classes: hoveredElement.className,
pickable: pickable(hoveredElement),
} : null,
pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver,
recoveryWaitingForAnchor,
evtSourceReadyState: evtSource ? evtSource.readyState : null,
@@ -1966,6 +1974,7 @@
*/
function setLiveState(next) {
state = next;
document.documentElement.dataset.impeccableLiveState = next;
syncPageInteractionCursor();
}
@@ -2516,18 +2525,23 @@
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
marginLeft: 'auto',
});
// Variants currently arrive atomically in a single file edit, so a
// per-variant counter would lie. Say what's true.
status.textContent = recoveryWaitingForAnchor
? 'Variants ready. Reveal the selected element to resume.'
: (arrivedVariants < expectedVariants
? 'Generating ' + expectedVariants + ' variants...'
: 'Done');
: generationStatusText();
row.appendChild(status);
return row;
}
function generationStatusText() {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return 'Done';
if (generationPhase === 'picked_up') return 'Agent picked up the request...';
if (generationPhase === 'scaffolding') return 'Finding the source...';
if (generationPhase === 'source_ready') return 'Source ready. Generating...';
if (generationPhase === 'scaffold_fallback') return 'Agent is locating the source...';
return 'Generating ' + expectedVariants + ' variants...';
}
// Cycling row
const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
@@ -2557,7 +2571,7 @@
color: BP.textDim, minWidth: '24px', textAlign: 'center',
});
counter.id = PREFIX + '-variant-counter';
counter.textContent = visibleVariant + '/' + arrivedVariants;
counter.textContent = visibleVariant + '/' + expectedVariants;
row.appendChild(counter);
// Next
@@ -2614,6 +2628,15 @@
// Spacer
row.appendChild(el('div', { flex: '1' }));
if (arrivedVariants < expectedVariants) {
const remaining = expectedVariants - arrivedVariants;
const progress = el('span', {
fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
});
progress.textContent = remaining + ' more arriving...';
row.appendChild(progress);
}
// Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
const accept = el('button', {
padding: '5px 14px', borderRadius: '5px',
@@ -2628,7 +2651,11 @@
accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
if (arrivedVariants === 0) {
accept.style.opacity = '0.3';
accept.style.pointerEvents = 'none';
accept.title = 'Accept becomes available when the first variant arrives';
}
row.appendChild(accept);
// Discard
@@ -4835,6 +4862,10 @@
return String(filePath || '').endsWith('manifest.json');
}
function isFrameworkComponentPreviewMode(mode) {
return mode === 'svelte-component' || mode === 'vue-component';
}
function parseOriginalMarkupElement(originalMarkup) {
const parser = new DOMParser();
const doc = parser.parseFromString('<div id="impeccable-anchor">' + originalMarkup + '</div>', 'text/html');
@@ -5029,9 +5060,21 @@
}
}
function loadSvelteRuntime(runtimeModule) {
function resolveComponentModuleUrl(manifest, modulePath) {
const pathValue = String(modulePath || '');
if (manifest?.previewMode === 'vue-component' && pathValue.startsWith('/@fs/')) {
// Nuxt mounts Vite below buildAssetsDir. Sending /@fs directly to the
// page origin reaches Nitro's route fallback and returns text/html.
const assetsDir = String(window.__NUXT__?.config?.app?.buildAssetsDir || '/_nuxt/');
const base = assetsDir.endsWith('/') ? assetsDir : assetsDir + '/';
return new URL(base + pathValue.slice('/@fs/'.length), location.origin).href;
}
return new URL(pathValue, location.origin).href;
}
function loadSvelteRuntime(runtimeModule, manifest) {
const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js';
const url = new URL(modulePath, location.origin).href;
const url = resolveComponentModuleUrl(manifest, modulePath);
if (!svelteRuntimePromise) {
svelteRuntimePromise = import(/* @vite-ignore */ url);
}
@@ -5065,7 +5108,8 @@
async function loadSvelteComponentVariantSource(manifest, variantNum) {
const dir = String(manifest?.componentDir || '').replace(/^\/+/, '');
if (!dir || !variantNum) return '';
const sourcePath = dir + '/v' + variantNum + '.svelte';
const extension = manifest.componentExtension || (manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const sourcePath = dir + '/v' + variantNum + '.' + extension;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath);
try {
const res = await fetch(url);
@@ -5084,6 +5128,7 @@
async function applySvelteComponentVariantStyle(variantNum) {
if (!svelteComponentSession || !variantNum) return;
const { manifest, sessionId } = svelteComponentSession;
if (manifest?.previewMode === 'vue-component') return;
const source = await loadSvelteComponentVariantSource(manifest, variantNum);
const css = extractSvelteComponentStyle(source);
removeSvelteComponentVariantStyle(svelteComponentSession);
@@ -5221,7 +5266,7 @@
if (!sourceOriginal) return values;
const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl);
for (const entry of contract) {
const token = '{' + entry.expr + '}';
const token = entry.previewToken || ('{' + entry.expr + '}');
values[entry.prop] = map.get(token) || '';
}
return values;
@@ -5233,9 +5278,12 @@
try {
const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement;
svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null;
const runtime = await loadSvelteRuntime(manifest.runtimeModule);
const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte';
const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now();
const runtime = await loadSvelteRuntime(manifest.runtimeModule, manifest);
const extension = manifest.componentExtension || (manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const moduleBase = manifest.componentModuleBase
|| ('/' + String(manifest.componentDir || '').replace(/^\/+/, ''));
const modulePath = String(moduleBase).replace(/\/+$/, '') + '/v' + variantNum + '.' + extension;
const moduleUrl = resolveComponentModuleUrl(manifest, modulePath) + '?t=' + Date.now();
const mod = await import(/* @vite-ignore */ moduleUrl);
const Component = mod.default;
if (svelteComponentSession.mountedInstance && runtime.unmount) {
@@ -5275,7 +5323,7 @@
if (svelteComponentSession?.sessionId === sessionId) {
svelteComponentSession.swapAnchor = null;
}
console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err);
console.error('[impeccable] Failed to mount component variant ' + variantNum + ' for ' + sessionId + ':', err);
return false;
}
}
@@ -5340,21 +5388,26 @@
if (manifest.id !== sessionId) return;
const paramsByVariant = await loadSvelteComponentParams(manifest);
const availableVariants = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
const componentPreviewMode = isFrameworkComponentPreviewMode(manifest.previewMode)
? manifest.previewMode
: 'svelte-component';
currentSessionId = sessionId;
expectedVariants = Number(manifest.count) || expectedVariants || 1;
rememberSessionFileMeta({
sourceFile: manifest.sourceFile,
previewFile: manifestPath,
previewMode: 'svelte-component',
previewMode: componentPreviewMode,
});
if (state !== 'CYCLING') setLiveState('GENERATING');
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper && svelteComponentSession?.sessionId === sessionId) {
recoveryWaitingForAnchor = false;
svelteComponentSession.manifest = manifest;
svelteComponentSession.paramsByVariant = paramsByVariant;
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1;
await mountSvelteComponentVariant(visibleVariant || 1);
setLiveState('CYCLING');
@@ -5366,14 +5419,14 @@
const liveEl = findLiveElementForSvelteManifest(manifest);
if (!liveEl?.parentElement) {
console.warn('[impeccable] Could not find original element in live DOM.');
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants
? visibleVariant
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true });
enterRecoveryWaitingForAnchor({ checkpointReason: 'component_preview_anchor_missing', trackScroll: true });
waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest });
return;
}
@@ -5381,7 +5434,7 @@
const wrapper = document.createElement('div');
wrapper.dataset.impeccableVariants = sessionId;
wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1);
wrapper.dataset.impeccablePreview = 'svelte-component';
wrapper.dataset.impeccablePreview = componentPreviewMode;
wrapper.style.display = 'contents';
const mountTarget = document.createElement('div');
@@ -5419,8 +5472,8 @@
recoveryWaitingForAnchor = false;
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
arrivedVariants = Number(manifest.count) || expectedVariants || 1;
expectedVariants = arrivedVariants;
arrivedVariants = availableVariants;
expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
@@ -5445,9 +5498,9 @@
refreshParamsPanel();
positionBar();
saveSession();
console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.');
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount Svelte component variants:', err);
console.error('[impeccable] Failed to mount component-preview variants:', err);
abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.');
}
}
@@ -6049,6 +6102,7 @@
updating = true;
arrivedVariants = count;
generationPhase = arrivedVariants >= expectedVariants ? 'variants_ready' : 'variants_progress';
if (visibleVariant === 0 && arrivedVariants > 0) {
const saved = loadSession();
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
@@ -6064,7 +6118,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (arrivedVariants > 0) {
setLiveState('CYCLING');
recoveryWaitingForAnchor = false;
hideShaderOverlay();
@@ -6072,13 +6126,18 @@
updateSelectedElement();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
if (arrivedVariants >= expectedVariants && expectedVariants > 0) refreshParamsPanel();
else hideParamsPanel();
positionBar();
} else if (state === 'GENERATING') {
updateBarContent('generating');
}
saveSession();
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
sendCheckpoint(
arrivedVariants >= expectedVariants && expectedVariants > 0
? 'variants_ready'
: 'variants_progress',
);
updating = false;
});
@@ -6158,6 +6217,34 @@
case 'agent_polling':
syncAgentPollingUi(!!msg.connected);
break;
case 'agent_phase':
if (msg.id === currentSessionId && state === 'GENERATING') {
generationPhase = msg.phase || generationPhase;
updateBarContent('generating');
}
break;
case 'variant_progress':
if (msg.id === currentSessionId) {
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
}
break;
case 'steer_done':
maybeCompleteSteer(msg);
break;
@@ -6175,6 +6262,10 @@
case 'done':
if (maybeCompleteSteer(msg)) break;
rememberSessionFileMeta(msg);
if (msg.id === currentSessionId && isFrameworkComponentPreviewMode(currentPreviewMode) && currentPreviewFile) {
injectSvelteComponentsFromManifest(currentPreviewFile, msg.id);
break;
}
// Variants already arrived via HMR → normal transition.
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
@@ -6215,9 +6306,10 @@
if (maybeCompleteAcceptedSession(msg)) break;
break;
case 'agent_done':
// Carbonize accepts are not terminal until live-complete.mjs sends
// the final complete event. Keep the browser in its recoverable
// saving state while the source cleanup is still in flight.
// The deterministic accept has already committed the reviewed DOM
// and fenced generation. Carbonize may continue in the background;
// it must not hold the foreground picker hostage.
if (msg.data?.carbonize === true && maybeCompleteAcceptedSession(msg)) break;
break;
case 'discarded':
if (msg.id && msg.id === currentSessionId) {
@@ -6684,6 +6776,7 @@
expectedVariants = selectedCount;
arrivedVariants = 0;
visibleVariant = 0;
generationPhase = 'queued';
resetSessionFileMeta();
// Flip to GENERATING immediately so the bar morphs without waiting on
@@ -6759,6 +6852,7 @@
expectedVariants = selectedCount;
arrivedVariants = 0;
visibleVariant = 0;
generationPhase = 'queued';
resetSessionFileMeta();
selectedElement = placeholderElement;
insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement);
@@ -6975,9 +7069,9 @@
// preview mounts are covered by the same shader regression checks.
const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase();
if (adapter === 'svelte' || adapter === 'sveltekit') return true;
if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true;
if (isFrameworkComponentPreviewMode(currentPreviewMode) || svelteComponentSession) return true;
const wrapper = el?.closest?.('[data-impeccable-variants]');
return wrapper?.dataset?.impeccablePreview === 'svelte-component';
return isFrameworkComponentPreviewMode(wrapper?.dataset?.impeccablePreview);
}
function paintsShaderProxySurface(node) {
@@ -7111,7 +7205,10 @@
// presentation-only. Wait only for the helper to accept the event before
// starting CPU-heavy capture; this yields the browser task and prevents
// rasterization from delaying the fetch itself.
if (!hasAnnotations) await sendEvent(basePayload);
if (!hasAnnotations) {
basePayload.clientSentAt = Date.now();
await sendEvent(basePayload);
}
let screenshotPath;
let blob;
@@ -7150,6 +7247,7 @@
// Annotated requests must wait for capture + upload because the screenshot
// is semantic input. Plain requests were already dispatched above.
if (hasAnnotations) {
basePayload.clientSentAt = Date.now();
sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
}
}
@@ -7531,7 +7629,6 @@ void main() {
if (variantSelectionPromise) {
try { await variantSelectionPromise; } catch { /* failed selection falls back below */ }
}
if (!currentSessionId || arrivedVariants === 0) return;
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
const acceptPayload = {
@@ -7539,7 +7636,9 @@ void main() {
id: currentSessionId,
variantId: String(visibleVariant),
pageUrl: location.pathname,
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
@@ -7553,7 +7652,7 @@ void main() {
const acceptedSessionId = currentSessionId;
const acceptedVariant = visibleVariant;
const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId
|| acceptWrapper?.dataset?.impeccablePreview === 'svelte-component';
|| isFrameworkComponentPreviewMode(acceptWrapper?.dataset?.impeccablePreview);
const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
setLiveState('SAVING');
@@ -7568,7 +7667,17 @@ void main() {
saveSession();
sendEvent(acceptPayload, { throwOnError: true })
.then(() => {})
.then(() => {
const pending = pendingAcceptedSession;
if (!pending || pending.id !== acceptedSessionId) return;
// POST /events returns only after the accept intent is durable and the
// generation epoch is fenced. Source promotion/carbonize can finish in
// the background; the foreground picker is free immediately.
markSessionHandled();
setLiveState('CONFIRMED');
document.documentElement.dataset.impeccableAcceptToPickingMs = String(Date.now() - acceptPayload.clientSentAt);
scheduleAcceptCleanup(pending);
})
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
@@ -7597,17 +7706,26 @@ void main() {
}
function scheduleAcceptCleanup(accepted) {
setTimeout(function() {
if (!accepted?.isSvelteComponent && !acceptedDomAlreadyClean(accepted)) {
setTimeout(function() {
if (pendingAcceptedSession?.id !== accepted?.id) return;
if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted);
cleanupAcceptedSession();
}, 1800);
return;
queueMicrotask(function() {
if (pendingAcceptedSession?.id !== accepted?.id) return;
// Svelte previews live in an adapter-owned mount rather than in source
// wrapper markup. Promote the mounted variant before releasing the
// session so the old adapter instance cannot linger behind the next
// Pick → Go loop while carbonize finishes in the background.
if (accepted?.isSvelteComponent) {
commitAcceptedSvelteComponentToDom(accepted.id);
}
cleanupAcceptedSession();
}, 1200);
});
// Let React/Vue/Svelte own the HMR reconciliation. Mutating their DOM in
// the same turn as the source update causes removeChild/NotFoundError
// races. Static servers still need a fallback, but it must not keep Live
// in SAVING or block the user's next pick.
if (!accepted?.isSvelteComponent) {
setTimeout(function() {
if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted);
}, 1200);
}
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
@@ -7711,6 +7829,8 @@ void main() {
clearSession();
resetSessionFileMeta();
selectedElement = null;
hoveredElement = null;
pagePickSkipClick = false;
currentSessionId = null;
selectedAction = 'impeccable';
pendingAcceptedSession = null;
@@ -7776,8 +7896,8 @@ void main() {
const previewFile = normalizeSessionPath(meta.previewFile);
const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null);
if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) {
currentPreviewMode = 'svelte-component';
if (isFrameworkComponentPreviewMode(previewMode) || isSvelteComponentManifestPath(file)) {
currentPreviewMode = isFrameworkComponentPreviewMode(previewMode) ? previewMode : 'svelte-component';
currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile);
currentSourceFile = sourceFile || currentSourceFile;
return;
@@ -7870,7 +7990,7 @@ void main() {
saveSession();
queueCheckpoint(reason || 'browser_restore_without_wrapper');
const restoreFile = currentPreviewMode === 'svelte-component'
const restoreFile = isFrameworkComponentPreviewMode(currentPreviewMode)
? currentPreviewFile
: (currentSourceFile || currentPreviewFile);
if (restoreFile) {
@@ -7883,7 +8003,7 @@ void main() {
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false;
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
}
@@ -7974,6 +8094,8 @@ void main() {
clearSession();
resetSessionFileMeta();
selectedElement = null;
hoveredElement = null;
pagePickSkipClick = false;
currentSessionId = null;
selectedAction = 'impeccable';
renderEditBadge('hidden');
@@ -8058,7 +8180,7 @@ void main() {
// would strand the bar in CYCLING at 0/0. If there's no live in-memory mount
// for this wrapper, it's an orphan (reload / failed mount): drop it and let
// the live-server's SSE re-inject the manifest if the session is still live.
if (wrapper.dataset.impeccablePreview === 'svelte-component'
if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)
&& svelteComponentSession?.sessionId !== sessionId) {
wrapper.remove();
if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true;
@@ -8067,7 +8189,7 @@ void main() {
return false;
}
if (wrapper.dataset.impeccablePreview === 'svelte-component') {
if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) {
if (!svelteComponentSession?.mountedVariant) {
return true;
}
@@ -8115,7 +8237,7 @@ void main() {
insertPlaceholderSnapshot = saved.insertPlaceholder;
}
const resumedState = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
// Find the visible variant's content element for highlight positioning.
const isInsert = wrapper.dataset.impeccableMode === 'insert';
@@ -8139,7 +8261,11 @@ void main() {
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
queueCheckpoint('browser_resumed');
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
if (variantObserver) variantObserver.disconnect();
+142 -4
View File
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
@@ -46,10 +48,15 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'app/.impeccable-live/',
'src/.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
'plugins/impeccable-live.client.ts',
'app/plugins/impeccable-live.client.ts',
'src/plugins/impeccable-live.client.ts',
]);
/**
@@ -113,6 +120,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -120,6 +128,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -145,13 +159,28 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
const gitIgnore = ensureLiveGitIgnores(process.cwd());
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
);
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'nuxt',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
@@ -175,12 +204,12 @@ Output (JSON):
if (!anyInserted) process.exit(1);
}
export function ensureLiveGitIgnores(cwd = process.cwd()) {
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
@@ -202,10 +231,119 @@ export function ensureLiveGitIgnores(cwd = process.cwd()) {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
};
}
// ---------------------------------------------------------------------------
// Nuxt adapter
//
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
// generated, dev-only, and outside user-authored source: Live creates one
// marked .client.ts plugin on start and removes it on stop.
// ---------------------------------------------------------------------------
export function detectNuxtProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = '';
if (literalSrcDir) {
const candidate = literalSrcDir[2]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
const normalized = path.posix.normalize(candidate);
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
appDir = normalized === '.' ? '' : normalized;
}
} else if (
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
) {
appDir = 'app';
}
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
return { configFile, appDir, pluginFile };
}
export function buildNuxtPlugin(port) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = 'http://localhost:${port}/live.js';
const liveSelector = 'script[data-impeccable-live-nuxt]';
export default defineNuxtPlugin(() => {
if (!import.meta.dev || typeof document === 'undefined') return;
const expectedSrc = new URL(liveSrc, window.location.href).href;
let script = document.querySelector(liveSelector);
if (script?.src === expectedSrc) return;
script?.remove();
script = document.createElement('script');
script.src = liveSrc;
script.async = true;
script.dataset.impeccableLiveNuxt = '';
document.head.appendChild(script);
import.meta.hot?.dispose(() => {
if (script?.isConnected) script.remove();
});
});
/* /${NUXT_PLUGIN_MARKER} */
`;
}
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
};
}
const content = buildNuxtPlugin(port);
fs.mkdirSync(path.dirname(absFile), { recursive: true });
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
return {
file: project.pluginFile,
inserted: true,
changed: content !== existing,
devOnly: true,
};
}
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
if (!fs.existsSync(absFile)) {
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
}
const content = fs.readFileSync(absFile, 'utf-8');
if (!content.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
removed: false,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} is not managed by Impeccable Live`,
};
}
fs.unlinkSync(absFile);
const pluginDir = path.dirname(absFile);
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
return { file: project.pluginFile, removed: true };
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
+3 -2
View File
@@ -38,8 +38,8 @@ function readServerInfo() {
return record.info;
}
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
return { token, id, type, message, file, data };
export function buildPollReplyPayload(token, { id, type, message, file, data, sourceEventType }) {
return { token, id, type, message, file, data, sourceEventType };
}
export function manualApplyPollBanner(event = {}) {
@@ -207,6 +207,7 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
await postReply(base, token, {
id: event.id,
type: completionType,
sourceEventType: event.type,
message: event._acceptResult?.error,
file: event._acceptResult?.file,
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from './live/generation-publisher.mjs';
const args = process.argv.slice(2);
const result = args.includes('--prepare')
? prepareGenerationArtifact({
id: arg(args, '--id'),
sourceFile: arg(args, '--file'),
})
: publishGenerationArtifact({
id: arg(args, '--id'),
epoch: Number(arg(args, '--epoch')),
sourceFile: arg(args, '--file'),
artifactFile: arg(args, '--artifact'),
expectedSourceHash: arg(args, '--expected-source-hash'),
arrivedVariants: optionalNumber(arg(args, '--arrived')),
expectedVariants: optionalNumber(arg(args, '--expected')),
});
console.log(JSON.stringify(result));
if (!result.ok) process.exitCode = 2;
function arg(values, name) {
const index = values.indexOf(name);
return index >= 0 ? values[index + 1] : undefined;
}
function optionalNumber(value) {
if (value === undefined) return undefined;
const number = Number(value);
return Number.isInteger(number) ? number : undefined;
}
+158 -15
View File
@@ -29,6 +29,7 @@ import {
resolveLiveBrowserScriptParts,
} from './live/browser-script-parts.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
@@ -51,6 +52,7 @@ import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
} from './live/svelte-component.mjs';
import { removeAllVueComponentSessions } from './live/vue-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
@@ -157,28 +159,137 @@ function restorePendingEventsFromStore() {
}
function findAvailablePendingEvent(now = Date.now()) {
for (const entry of state.pendingEvents) {
if (entry.leaseUntil && entry.leaseUntil > now) continue;
return entry;
}
return null;
return state.pendingEvents
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
}
function eventPriority(event = {}) {
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
if (event.type === 'manual_edit_apply' || event.type === 'steer') return 1;
if (event.type === 'generate') return 2;
return 3;
}
function leaseEvent(entry, leaseMs) {
prepareGenerateEventForLease(entry);
if (!entry.event?.id) {
const idx = state.pendingEvents.indexOf(entry);
if (idx !== -1) state.pendingEvents.splice(idx, 1);
return entry.event;
}
entry.leaseUntil = Date.now() + leaseMs;
recordGenerateDelivery(entry);
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return entry.event;
}
function acknowledgePendingEvent(id) {
function recordGenerateDelivery(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.generationReadyAt) return;
const at = Date.now();
entry.event = { ...event, generationReadyAt: at };
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, 'generation_ready', { at });
}
function prepareGenerateEventForLease(entry) {
const event = entry?.event;
if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
recordAgentPhase(event.id, 'picked_up');
recordAgentPhase(event.id, 'scaffolding');
const result = runGenerationPreflight(event, {
cwd: process.cwd(),
scriptsDir: __dirname,
});
entry.event = {
...event,
scaffoldAttempted: true,
scaffoldDurationMs: result.durationMs ?? null,
...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
};
state.sessionStore?.appendEvent(entry.event);
recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
durationMs: result.durationMs ?? null,
previewMode: result.scaffold?.previewMode || 'source',
});
}
function recordAgentPhase(id, phase, details = {}) {
if (!id) return;
const event = {
type: 'agent_phase',
id,
phase,
at: Date.now(),
...details,
};
state.sessionStore?.appendEvent(event);
broadcast(event);
}
function recordGenerationCheckpoint(event) {
if (!event?.id || event.type !== 'checkpoint') return;
if (generationIsFenced(event.id)) return;
const arrived = Number(event.arrivedVariants) || 0;
const expected = Number(event.expectedVariants) || 0;
if (arrived <= 0 || expected <= 0) return;
const previewMode = event.previewMode || 'source';
const previewFile = event.previewFile || event.file;
if (previewFile) {
broadcast({
type: 'variant_progress',
id: event.id,
file: previewFile,
sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
previewFile,
previewMode,
arrivedVariants: arrived,
expectedVariants: expected,
});
}
const details = {
arrivedVariants: arrived,
expectedVariants: expected,
checkpointReason: event.reason || null,
};
const at = Date.now();
if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
}
if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
}
}
function generationIsFenced(id) {
if (!state.sessionStore || !id) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return snapshot?.generationCanceled === true;
} catch {
return false;
}
}
function generationPhaseAlreadyRecorded(id, phase) {
if (!state.sessionStore) return false;
try {
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
return !!snapshot?.generationTimings?.[phase];
} catch {
return false;
}
}
function acknowledgePendingEvent(id, sourceEventType) {
if (!id) return false;
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
const idx = state.pendingEvents.findIndex((entry) => (
entry.event?.id === id
&& (!sourceEventType || entry.event?.type === sourceEventType)
));
if (idx === -1) return false;
const acknowledged = state.pendingEvents[idx].event;
state.pendingEvents.splice(idx, 1);
@@ -187,9 +298,12 @@ function acknowledgePendingEvent(id) {
return acknowledged;
}
function findPendingEventById(id) {
function findPendingEventById(id, sourceEventType) {
if (!id) return null;
const entry = state.pendingEvents.find((item) => item.event?.id === id);
const entry = state.pendingEvents.find((item) => (
item.event?.id === id
&& (!sourceEventType || item.event?.type === sourceEventType)
));
return entry?.event || null;
}
@@ -225,6 +339,8 @@ function summarizeActiveSessionForClient(snapshot = {}) {
visibleVariant: snapshot.visibleVariant ?? null,
checkpointRevision: snapshot.checkpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
};
}
@@ -698,6 +814,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
return;
}
}
recordGenerationCheckpoint(msg);
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
@@ -784,7 +901,9 @@ function sessionFileMetadataFromPollReply(file) {
const normalized = file.split(path.sep).join('/');
const base = { file: normalized };
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
if (!normalized.includes('node_modules/.impeccable-live/')
&& !normalized.includes('src/lib/impeccable/')
&& !normalized.includes('/.impeccable-live/')) return base;
let full;
try {
@@ -797,18 +916,33 @@ function sessionFileMetadataFromPollReply(file) {
try {
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode) || !manifest.sourceFile) return base;
return {
file: String(manifest.sourceFile).split(path.sep).join('/'),
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
previewFile: normalized,
previewMode: 'svelte-component',
previewMode: manifest.previewMode,
};
} catch {
return base;
}
}
function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
const pendingTypes = new Set(
pendingEvents
.filter((entry) => entry.event?.id === msg.id)
.map((entry) => entry.event?.type),
);
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
if (msg.type === 'complete') return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
if (msg.type === 'steer_done') return 'steer';
// `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.
return msg.type === 'agent_done' || msg.type === 'done' ? 'generate' : undefined;
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
@@ -869,7 +1003,8 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
return;
}
const pendingEventBeforeAck = findPendingEventById(msg.id);
const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -879,7 +1014,7 @@ function handlePollPost(req, res) {
}));
return;
}
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
let skipJournalReply = false;
let existingSession = null;
if (!acknowledgedEvent && state.sessionStore && msg.id) {
@@ -971,6 +1106,11 @@ function cleanupSvelteComponentSessionsBeforeExit() {
} catch (err) {
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
}
try {
removeAllVueComponentSessions(process.cwd());
} catch (err) {
console.warn('[impeccable] Vue component session cleanup failed:', err.message);
}
}
function applyLegacyDeferredAcceptsOnStartup() {
@@ -1083,7 +1223,10 @@ if (args.includes('--background')) {
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
// The detached child is typically listening in 35-45ms. A 200ms polling
// floor dominated configured cold Live startup; poll cheaply and return
// as soon as the child has written its ready record.
await new Promise(r => setTimeout(r, 5));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
+61 -15
View File
@@ -20,6 +20,11 @@ import {
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import {
buildVueComponentCssAuthoring,
scaffoldVueComponentSession,
shouldUseVueComponentInjection,
} from './live/vue-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -160,11 +165,29 @@ The agent should insert variant HTML at insertLine.`);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
const normalizedText = String(text).replace(/\s+/g, ' ').trim();
if (normalizedText.length < 8) {
// Very short labels cannot disambiguate siblings reliably. Preserve
// the legacy behavior for these low-information picker events.
match = candidates[0];
} else {
// Rendered text that is absent from every candidate usually means
// the source uses expressions or component props. Picking the first
// same-class sibling silently edits the wrong instance (observed on
// Astro result cards), so stop and surface every candidate instead.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
reason: 'rendered_text_not_in_source',
file: path.relative(process.cwd(), targetFile),
candidates: candidates.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
}));
process.exit(1);
}
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
@@ -269,6 +292,8 @@ The agent should insert variant HTML at insertLine.`);
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
const useVueComponent = !useSvelteComponent && shouldUseVueComponentInjection(targetFile);
const useFrameworkComponent = useSvelteComponent || useVueComponent;
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -315,6 +340,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let vueSession = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -334,6 +360,23 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (useVueComponent) {
// Nuxt route-module HMR can invalidate the active page while a generated
// wrapper is only partially written. Stage real Vue SFCs in an app-local
// dev module tree and leave the route untouched until Accept.
vueSession = scaffoldVueComponentSession({
id,
count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), vueSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -356,15 +399,18 @@ The agent should insert variant HTML at insertLine.`);
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
const vueComponentAuthoring = useVueComponent ? buildVueComponentCssAuthoring(count) : null;
const componentSession = svelteSession || vueSession;
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : useVueComponent ? 'vue-component' : undefined;
console.log(JSON.stringify({
file: outputRelFile,
sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode: componentPreviewMode,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
@@ -374,10 +420,10 @@ The agent should insert variant HTML at insertLine.`);
endLine: outputEndLine, // 1-indexed
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
styleMode: componentPreviewMode || styleMode.mode,
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: svelteComponentAuthoring || vueComponentAuthoring || buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
@@ -0,0 +1,91 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
if (!target.elementId && !target.classes) return null;
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
}
export function runGenerationPreflight(event, {
cwd = process.cwd(),
scriptsDir,
execFileSyncImpl = execFileSync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
const startedAt = performance.now();
try {
const stdout = execFileSyncImpl(process.execPath, command.args, {
cwd,
encoding: 'utf-8',
timeout: timeoutMs,
stdio: ['ignore', 'pipe', 'pipe'],
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
};
} catch (error) {
return {
ok: false,
mode: command.mode,
durationMs: performance.now() - startedAt,
error: compactError(error),
};
}
}
function replaceTarget(event) {
return normalizeTarget(event.element || {});
}
function insertTarget(event) {
return {
...normalizeTarget(event.insert?.anchor || {}),
position: event.insert?.position === 'before' ? 'before' : 'after',
};
}
function normalizeTarget(target) {
const classes = Array.isArray(target.classes)
? target.classes.join(' ')
: String(target.classes || '').trim();
const text = typeof target.textContent === 'string'
? target.textContent.trim().slice(0, 80)
: '';
return {
elementId: target.id || target.elementId || undefined,
classes: classes || undefined,
tag: target.tagName || target.tag || undefined,
text: text || undefined,
};
}
function compactError(error) {
const stderr = error?.stderr ? String(error.stderr).trim() : '';
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
return String(message).slice(0, 500);
}
+549
View File
@@ -0,0 +1,549 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { createLiveSessionStore } from './session-store.mjs';
import { withSourceLockSync } from './source-lock.mjs';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
export function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) {
if (!id) return failure('missing_session_id');
if (!sourceFile) return failure('missing_file');
const requestedPath = resolveInside(cwd, sourceFile);
if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
if (snapshot.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
const source = fs.readFileSync(sourcePath, 'utf-8');
const revision = Number(snapshot.publishedRevision || 0) + 1;
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
if (componentTarget) {
return prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target: componentTarget,
artifactDir,
cwd,
});
}
const extension = path.extname(sourcePath) || '.html';
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
fs.mkdirSync(artifactDir, { recursive: true });
fs.writeFileSync(artifactPath, source, 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
expectedSourceHash: sha256(source),
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('prepare_failed', { message: error?.message || String(error) });
}
}
export function publishGenerationArtifact({
id,
epoch,
sourceFile,
artifactFile,
expectedSourceHash,
arrivedVariants,
expectedVariants,
cwd = process.cwd(),
} = {}) {
if (!id) return failure('missing_session_id');
if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch');
if (!sourceFile || !artifactFile) return failure('missing_file');
const requestedPath = resolveInside(cwd, sourceFile);
const artifactPath = resolveInside(cwd, artifactFile);
if (!requestedPath || !artifactPath) return failure('path_outside_project');
if (!fs.existsSync(requestedPath)) return failure('source_missing');
if (!fs.existsSync(artifactPath)) return failure('artifact_missing');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const artifactManifest = readJson(artifactPath);
const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode);
if (Boolean(componentTarget) !== isComponentArtifact) {
return failure('artifact_preview_mode_mismatch');
}
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
return failure('artifact_preview_mode_mismatch');
}
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
if (snapshot.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
if (Number(snapshot.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: snapshot.generationEpoch || 1 });
}
const current = fs.readFileSync(sourcePath, 'utf-8');
const currentHash = sha256(current);
if (!expectedSourceHash || currentHash !== expectedSourceHash) {
return failure('source_hash_mismatch', { actualSourceHash: currentHash });
}
if (componentTarget) {
return publishComponentArtifact({
id,
epoch,
snapshot,
target: componentTarget,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
store,
cwd,
});
}
const artifact = fs.readFileSync(artifactPath, 'utf-8');
if (!artifact.includes('data-impeccable-variants="' + id + '"')) {
return failure('artifact_missing_session_wrapper');
}
const delivered = countDeliveredVariants(artifact);
if (delivered < 1) return failure('artifact_has_no_variants');
if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) {
return failure('artifact_variant_count_mismatch', { delivered });
}
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
for (let variant = 1; variant <= priorArrived; variant++) {
const currentVariant = extractVariantBlock(current, variant);
const artifactVariant = extractVariantBlock(artifact, variant);
if (!currentVariant || !artifactVariant) {
return failure('published_variant_missing', { variant });
}
if (sha256(currentVariant) !== sha256(artifactVariant)) {
return failure('published_variant_changed', { variant });
}
}
const currentPreviewCss = extractPreviewCss(current, id);
const artifactPreviewCss = extractPreviewCss(artifact, id);
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
return failure('published_variant_css_changed');
}
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
if (commitSnapshot?.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase });
}
if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 });
}
const artifactHash = sha256(artifact);
atomicReplace(sourcePath, artifact);
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('publish_failed', { message: error?.message || String(error) });
}
}
function prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target,
artifactDir,
cwd,
}) {
const artifactComponentDir = path.join(
artifactDir,
id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(),
);
fs.mkdirSync(artifactComponentDir, { recursive: true });
copyDirectoryFiles(target.componentPath, artifactComponentDir);
const artifactPath = path.join(artifactComponentDir, 'manifest.json');
const artifactManifest = {
...target.manifest,
componentDir: relative(cwd, artifactComponentDir),
};
fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, requestedPath),
targetSourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
componentDir: relative(cwd, artifactComponentDir),
previewMode: target.manifest.previewMode,
expectedSourceHash: sha256(source),
};
}
function publishComponentArtifact({
id,
epoch,
snapshot,
target,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
store,
cwd,
}) {
if (!artifactManifest || typeof artifactManifest !== 'object') {
return failure('artifact_manifest_invalid');
}
if (artifactManifest.id !== id || target.manifest.id !== id) {
return failure('artifact_session_mismatch');
}
const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir);
if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) {
return failure('artifact_component_dir_mismatch');
}
if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) {
return failure('artifact_not_staged');
}
const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest);
if (immutableMismatch) {
return failure('artifact_manifest_changed', { field: immutableMismatch });
}
const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0);
const declared = optionalPositiveInteger(artifactManifest.arrivedVariants);
const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared;
if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants');
if (expected > 0 && delivered > expected) {
return failure('artifact_variant_count_mismatch', { delivered, expected });
}
if (declared !== null && declared !== delivered) {
return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered });
}
const priorArrived = Math.max(
optionalPositiveInteger(target.manifest.arrivedVariants) || 0,
Number(snapshot.arrivedVariants || 0),
);
if (delivered < priorArrived) {
return failure('artifact_variant_count_regressed', { delivered, priorArrived });
}
const componentExtension = target.manifest.componentExtension
|| (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const variantContents = [];
for (let variant = 1; variant <= delivered; variant++) {
const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension);
if (!regularFileInside(artifactComponentPath, artifactVariantPath)) {
return failure('artifact_variant_missing', { variant });
}
const content = fs.readFileSync(artifactVariantPath, 'utf-8');
if (!content.trim()) return failure('artifact_variant_empty', { variant });
const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension);
if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) {
return failure('published_variant_missing', { variant });
}
if (variant <= priorArrived) {
const prior = fs.readFileSync(targetVariantPath, 'utf-8');
if (sha256(prior) !== sha256(content)) {
return failure('published_variant_changed', { variant });
}
}
variantContents.push({ variant, content, targetPath: targetVariantPath });
}
const artifactParamsPath = path.join(artifactComponentPath, 'params.json');
let paramsContent = null;
if (fs.existsSync(artifactParamsPath)) {
if (!regularFileInside(artifactComponentPath, artifactParamsPath)) {
return failure('artifact_params_invalid');
}
paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8');
const params = parseJson(paramsContent);
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return failure('artifact_params_invalid');
}
}
// Components and optional params become reachable before the manifest
// advertises them. Committing the manifest last makes publication atomic
// from the browser's point of view while the source lock excludes Accept.
fs.mkdirSync(target.componentPath, { recursive: true });
for (const variant of variantContents) {
if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content);
}
if (paramsContent !== null) {
atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent);
}
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
if (commitSnapshot?.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: commitSnapshot.phase });
}
if (Number(commitSnapshot?.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 });
}
const publishedManifest = {
...target.manifest,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
};
delete publishedManifest.manifestPath;
const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n';
atomicReplace(target.manifestPath, manifestContent);
const digest = digestComponentPublication(manifestContent, variantContents, paramsContent);
const revision = Number(snapshot.publishedRevision || 0) + 1;
const sourceFile = relative(cwd, sourcePath);
const previewFile = relative(cwd, target.manifestPath);
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
arrivedVariants: delivered,
expectedVariants: expected || delivered,
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
expectedVariants: expected || delivered,
};
}
const COMPONENT_MANIFEST_FIELDS = [
'id',
'mode',
'previewMode',
'sourceFile',
'sourceStartLine',
'sourceEndLine',
'insertLine',
'position',
'anchorStartLine',
'anchorEndLine',
'count',
'propContract',
'originalMarkup',
'anchorMarkup',
'runtimeModule',
'componentModuleBase',
'framework',
'componentExtension',
];
function readComponentPublicationTarget(manifestPath, cwd, id) {
if (path.basename(manifestPath) !== 'manifest.json') return null;
const manifest = readJson(manifestPath);
if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null;
if (manifest.id !== id) return failure('artifact_session_mismatch');
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentPath = resolveInside(cwd, manifest.componentDir);
if (!sourcePath || !componentPath) return failure('path_outside_project');
if (!fs.existsSync(sourcePath)) return failure('source_missing');
if (path.resolve(componentPath) !== path.dirname(manifestPath)) {
return failure('manifest_component_dir_mismatch');
}
return { manifest, manifestPath, sourcePath, componentPath };
}
function componentManifestMismatch(target, artifact) {
for (const field of COMPONENT_MANIFEST_FIELDS) {
if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field;
}
return null;
}
function isComponentPreviewMode(value) {
return value === 'svelte-component' || value === 'vue-component';
}
function copyDirectoryFiles(sourceDir, targetDir) {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile() || entry.isSymbolicLink()) continue;
fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
}
}
function regularFileInside(root, file) {
const rel = path.relative(root, file);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
try {
return fs.lstatSync(file).isFile();
} catch {
return false;
}
}
function isDescendant(root, candidate) {
const rel = path.relative(root, candidate);
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function digestComponentPublication(manifestContent, variants, paramsContent) {
const hash = createHash('sha256');
hash.update(manifestContent);
for (const variant of variants) {
hash.update('\0v' + variant.variant + '\0');
hash.update(variant.content);
}
if (paramsContent !== null) hash.update('\0params\0' + paramsContent);
return hash.digest('hex');
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function optionalPositiveInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
}
function countDeliveredVariants(source) {
const matches = source.match(/<div\b[^>]*\bdata-impeccable-variant=(?:"|')(?!original(?:"|'))[^"']+(?:"|')[^>]*>/g);
return matches?.length || 0;
}
function extractVariantBlock(source, variant) {
const open = /<div\b[^>]*>/gi;
let match;
let start = -1;
const attr = new RegExp("\\bdata-impeccable-variant=(?:\"" + variant + "\"|'" + variant + "')");
while ((match = open.exec(source))) {
if (attr.test(match[0])) {
start = match.index;
break;
}
}
if (start < 0) return null;
const token = /<div\b[^>]*\/\s*>|<div\b[^>]*>|<\/div\s*>/gi;
token.lastIndex = start;
let depth = 0;
while ((match = token.exec(source))) {
if (/^<\/div/i.test(match[0])) {
depth -= 1;
if (depth === 0) return source.slice(start, token.lastIndex);
} else if (!/\/\s*>$/.test(match[0])) {
depth += 1;
}
}
return null;
}
function extractPreviewCss(source, id) {
const escapedId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const open = new RegExp("<style\\b[^>]*\\bdata-impeccable-css=(?:\"" + escapedId + "\"|'" + escapedId + "')[^>]*>", 'i');
const match = open.exec(source);
if (!match) return '';
const start = match.index + match[0].length;
const end = source.indexOf('</style>', start);
if (end < 0) return '';
return source.slice(start, end)
.replace(/^\s*\{\s*`\s*/, '')
.replace(/\s*`\s*\}\s*$/, '')
.trim();
}
function atomicReplace(target, content) {
let mode = 0o666;
try { mode = fs.statSync(target).mode; } catch {}
const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
try {
fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
fs.renameSync(temp, target);
} finally {
try { fs.unlinkSync(temp); } catch {}
}
}
function resolveInside(cwd, value) {
const resolved = path.resolve(cwd, value);
const rel = path.relative(cwd, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
return resolved;
}
function relative(cwd, value) {
return path.relative(cwd, value).split(path.sep).join('/');
}
function failure(error, details = {}) {
return { ok: false, error, ...details };
}
+84 -2
View File
@@ -3,6 +3,13 @@ import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
const GENERATION_FENCED_PHASES = new Set([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
@@ -38,7 +45,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
}
const prior = loadCachedOrRebuild(normalized.id);
// Publisher/complete helpers can append from a separate process while
// the server is alive. Rebuild here so sequence numbers and phase
// fences never come from a stale in-memory cache.
const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
const seq = prior.nextSeq;
const entry = {
seq,
@@ -119,6 +129,14 @@ function baseSnapshot(id) {
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
generationPhase: null,
generationTimings: {},
generationEpoch: 1,
publishedRevision: 0,
deliveredVariants: {},
generationCanceled: false,
generationCanceledAt: null,
cancelReason: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
@@ -158,6 +176,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
generationTimings: { ...(snapshot.generationTimings || {}) },
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
@@ -170,14 +190,66 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variant_published':
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
revision: event.revision ?? null,
});
break;
}
if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
next.diagnostics.push({
error: 'stale_generation_epoch_ignored',
epoch: event.generationEpoch ?? null,
expectedEpoch: next.generationEpoch || 1,
});
break;
}
next.phase = 'variants_progress';
next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.revision) {
next.deliveredVariants[String(event.revision)] = {
digest: event.digest || null,
arrivedVariants: Number(event.arrivedVariants || 0),
publishedAt: event.at || null,
};
}
break;
case 'agent_phase':
next.generationPhase = event.phase ?? next.generationPhase;
if (event.phase) {
next.generationTimings[event.phase] = {
at: event.at ?? (Date.parse(entry.ts || '') || null),
durationMs: event.durationMs ?? null,
};
}
break;
case 'variants_ready':
case 'agent_done':
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
});
break;
}
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
@@ -194,7 +266,7 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
}
break;
case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
@@ -215,6 +287,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'accept';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -243,6 +318,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'discard':
next.phase = 'discard_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'discard';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
@@ -260,6 +338,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEvent = null;
break;
case 'agent_error':
if (next.generationCanceled && event.sourceEventType === 'generate') {
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
break;
}
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
+56
View File
@@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
const STALE_LOCK_MS = 60_000;
export function sourceLockPath(file, cwd = process.cwd()) {
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
}
export function withSourceLockSync(file, owner, fn, {
cwd = process.cwd(),
waitMs = 0,
retryMs = 5,
} = {}) {
const lockPath = sourceLockPath(file, cwd);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
let fd;
while (fd === undefined) {
clearStaleLock(lockPath);
try {
fd = fs.openSync(lockPath, 'wx');
fs.writeFileSync(fd, JSON.stringify({ owner, pid: process.pid, at: Date.now(), file: path.resolve(cwd, file) }) + '\n');
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
if (Date.now() >= deadline) {
const locked = new Error('source_locked');
locked.code = 'SOURCE_LOCKED';
locked.lockPath = lockPath;
throw locked;
}
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
}
}
try {
return fn();
} finally {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
try { fs.unlinkSync(lockPath); } catch {}
}
}
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function clearStaleLock(lockPath) {
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.unlinkSync(lockPath);
} catch {}
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Nuxt/Vue live-mode component previews.
*
* Generation writes real Vue SFCs into a generated app-local module tree.
* Nuxt/Vite compiles those modules without touching the active route; Accept
* is the only operation that writes the user's .vue source.
*/
import fs from 'node:fs';
import path from 'node:path';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtVueProject(cwd = process.cwd()) {
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
if (srcDirMatch) {
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
appDir = candidate === '.' ? '' : candidate;
}
}
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
return { configFile, appDir, componentRoot };
}
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
}
export function vueComponentSessionDir(id, cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
return path.join(cwd, project.componentRoot, id);
}
export function vueManifestPathForSession(id, cwd = process.cwd()) {
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
}
function ensureVueRuntime(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) throw new Error('Nuxt project not found');
const rel = `${project.componentRoot}/__runtime.js`;
const file = path.join(cwd, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
return nuxtViteFsModulePath(file, cwd);
}
/**
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
* Keep the manifest path base-agnostic and let the browser prepend the
* runtime's actual buildAssetsDir. A page-route URL such as
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
*/
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
const relative = path.relative(cwd, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Nuxt live module must stay inside the project root');
}
return '/@fs/' + absolute.replace(/^\/+/, '');
}
export function extractVueExpressions(markup) {
const out = [];
const seen = new Set();
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
let match;
while ((match = re.exec(String(markup || '')))) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
out.push({ expr, token: match[0] });
}
return out;
}
function buildVuePropContract(expressions) {
return expressions.map(({ expr, token }, index) => ({
prop: derivePropName(expr, index),
expr,
placeholder: token,
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
// the inner `{ user.name }` token; preserve its whitespace for the
// browser's source-text → rendered-text map.
previewToken: token.slice(1, -1),
}));
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
return tail?.[1] || `prop${index}`;
}
function substituteVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
return out;
}
function buildVueVariantStub(variant, markup, contract) {
const props = contract.length > 0
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
: '';
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
}
export function scaffoldVueComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
const runtimeModule = ensureVueRuntime(cwd);
const dir = vueComponentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
const manifest = {
id,
previewMode: 'vue-component',
framework: 'vue',
componentExtension: 'vue',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
runtimeModule,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let variant = 1; variant <= count; variant++) {
const file = path.join(dir, `v${variant}.vue`);
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract,
};
}
export function findVueComponentManifest(id, cwd = process.cwd()) {
let direct;
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
if (!fs.existsSync(direct)) return null;
try {
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
return manifest?.id === id && manifest?.previewMode === 'vue-component'
? { ...manifest, manifestPath: direct }
: null;
} catch {
return null;
}
}
function parseVueSfc(source) {
const text = String(source || '');
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
}
function restoreVueExpressions(markup, contract) {
let out = String(markup || '');
for (const entry of contract || []) {
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
}
return out;
}
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentDir = resolveInside(cwd, manifest.componentDir);
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'vue-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
}
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
.split('\n')
.map((line) => line.trim() ? indent + line.trimStart() : '');
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
retireVueComponentSession(manifest.id, cwd);
return { handled: true, ...resultBase };
}
function appendVueStyle(lines, cssLines) {
let close = -1;
for (let index = lines.length - 1; index >= 0; index--) {
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
}
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
}
function mergeOriginalVueAttrs(markup, originalMarkup) {
const variant = matchOpeningTag(markup);
const original = matchOpeningTag(originalMarkup);
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
const variantAttrs = parseStaticAttrs(variant.attrs);
const originalAttrs = parseStaticAttrs(original.attrs);
const additions = [];
let attrs = variant.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const classes = [
...variantClass.value.split(/\s+/),
...originalClass.value.split(/\s+/),
].filter(Boolean);
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
} else if (originalClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class' || variantAttrs.has(name)) continue;
additions.push(attr.raw);
}
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
return match ? {
raw: match[0],
tag: match[1],
attrs: match[2] || '',
close: match[3],
index: match.index || 0,
} : null;
}
function parseStaticAttrs(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
let match;
while ((match = re.exec(attrs))) {
out.set(match[1], {
raw: match[0],
value: match[3],
quote: match[2],
start: match.index,
end: match.index + match[0].length,
});
}
return out;
}
export function removeVueComponentSession(id, cwd = process.cwd()) {
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
}
/**
* Make an accepted/discarded session undiscoverable immediately while keeping
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
* missing module and emit a console error. The generated directory remains
* ignored and removeAllVueComponentSessions removes it on server shutdown.
*/
export function retireVueComponentSession(id, cwd = process.cwd()) {
let dir;
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
for (const name of ['manifest.json', 'params.json']) {
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
}
}
export function removeAllVueComponentSessions(cwd = process.cwd()) {
const project = detectNuxtVueProject(cwd);
if (!project) return;
const root = path.join(cwd, project.componentRoot);
if (!fs.existsSync(root)) return;
fs.rmSync(root, { recursive: true, force: true });
}
export function buildVueComponentCssAuthoring(count) {
return {
mode: 'vue-component',
count,
requirements: [
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
'Do not add data-impeccable-* attributes.',
],
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
};
}
function resolveInside(cwd, value) {
if (!value || path.isAbsolute(value)) return null;
const full = path.resolve(cwd, value);
const rel = path.relative(cwd, full);
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+14
View File
@@ -142,6 +142,15 @@ for (const name of listFixtures()) {
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
return;
}
if (result.adapter === 'nuxt') {
const plugin = result.results[0];
const body = readFileSync(join(tmp, plugin.file), 'utf-8');
assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
assert.match(body, /impeccable-live-nuxt-plugin/);
assert.match(body, /if \(!import\.meta\.dev/);
assert.match(body, /localhost:9999\/live\.js/);
return;
}
for (const r of result.results) {
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
const body = readFileSync(join(tmp, r.file), 'utf-8');
@@ -169,6 +178,11 @@ for (const name of listFixtures()) {
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
return;
}
if (result.adapter === 'nuxt') {
assert.equal(result.results[0].removed, true);
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
return;
}
for (const r of result.results) {
const body = readFileSync(join(tmp, r.file), 'utf-8');
assert.doesNotMatch(body, /impeccable-live-start/);
+1
View File
@@ -112,6 +112,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
@@ -1,10 +0,0 @@
<template>
<html lang="en">
<head>
<title>Nuxt + Vite 7 Fixture</title>
</head>
<body>
<NuxtPage />
</body>
</html>
</template>
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
@@ -1,4 +1,5 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
ssr: false,
});
@@ -1,18 +1,38 @@
{
"name": "Nuxt 4 + Vue 3 (static fixture only — runtime inject unsupported)",
"name": "Nuxt 4 + Vue 3",
"config": {
"files": ["app.vue"],
"insertBefore": "</body>",
"files": ["app/app.vue"],
"insertBefore": "</template>",
"commentSyntax": "html"
},
"sourceFiles": ["app.vue", "pages/index.vue", "nuxt.config.ts"],
"sourceFiles": ["app/app.vue", "app/pages/index.vue", "nuxt.config.ts"],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps hero in pages/index.vue",
"args": { "classes": "hero-title", "tag": "h1" },
"expectedFile": "pages/index.vue"
"expectedFile": "app/.impeccable-live/wraptest0/manifest.json",
"expectedSourceFile": "app/pages/index.vue",
"expectedPreviewMode": "vue-component"
}
],
"_runtimeOmitted": "Nuxt's app.vue is a Vue template that compiles to a render function — a <script> tag inserted there renders as a DOM node but does not execute. Nuxt needs a config-based inject (nuxt.config.ts -> app.head.script), which live-inject.mjs does not currently support. Static checks (is-generated, inject syntax, wrap routing) still validate."
"runtime": {
"styling": "vue-scoped-css",
"install": ["npm", "install", "--no-audit", "--no-fund"],
"devCommand": ["npm", "run", "dev"],
"scheme": "http",
"ignoreHTTPSErrors": false,
"readyPattern": "Local:\\s+http://[^:]+:(\\d+)",
"readyTimeoutMs": 120000,
"pickSelector": "h1.hero-title",
"steer": {
"message": "steer-e2e mark hero",
"sourceFile": "app/pages/index.vue",
"expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]"
},
"probe": {
"expectLiveInit": true,
"expectConsoleClean": true
}
}
}
@@ -6,3 +6,10 @@
<article class="feature-card">Two</article>
</section>
</main>
<style>
.feature-card {
min-height: 64px;
padding: 12px;
}
</style>
+39 -3
View File
@@ -74,7 +74,7 @@ describe('live-browser.js regression guards', () => {
);
});
it('uses a Svelte-gated painted-ancestor crop proxy for shader capture', () => {
it('uses a framework-component-gated painted-ancestor crop proxy for shader capture', () => {
assert.match(
SOURCE,
/function findShaderProxyCaptureRoot\(el\) \{[\s\S]{0,500}?let node = el\.parentElement;[\s\S]{0,700}?containsElement && paintsShaderProxySurface\(node\)[\s\S]{0,120}?return null;/,
@@ -87,8 +87,8 @@ describe('live-browser.js regression guards', () => {
);
assert.match(
SOURCE,
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?currentPreviewMode === 'svelte-component' \|\| svelteComponentSession[\s\S]{0,260}?dataset\?\.impeccablePreview === 'svelte-component';/,
'ancestor crop proxy must be gated to the Svelte adapter / Svelte component previews',
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?isFrameworkComponentPreviewMode\(currentPreviewMode\) \|\| svelteComponentSession[\s\S]{0,260}?isFrameworkComponentPreviewMode\(wrapper\?\.dataset\?\.impeccablePreview\);/,
'ancestor crop proxy must be gated to Svelte/Vue component previews',
);
assert.match(
SOURCE,
@@ -841,6 +841,42 @@ describe('live-browser.js regression guards', () => {
);
});
it('makes every arrived progressive variant immediately actionable', () => {
assert.match(
SOURCE,
/if \(arrivedVariants > 0\) \{[\s\S]{0,180}?setLiveState\('CYCLING'\)/,
'the first arrived variant should leave the generating-only state',
);
assert.doesNotMatch(
SOURCE,
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?accept\.style\.pointerEvents = 'none'/,
'Accept must not wait for variants the user did not choose',
);
assert.doesNotMatch(
SOURCE,
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?discard\.style\.pointerEvents = 'none'/,
'Discard must cancel remaining work immediately',
);
assert.match(
SOURCE,
/const resumedState = arrivedVariants > 0 \? 'CYCLING' : 'GENERATING'/,
'reload recovery should preserve a partially delivered review state',
);
assert.match(
SOURCE,
/arrivedVariants >= expectedVariants && expectedVariants > 0[\s\S]{0,100}?\? 'variants_ready'[\s\S]{0,60}?: 'variants_progress'/,
'checkpoint timing must distinguish partial review from complete delivery by counts',
);
});
it('promotes an early-accepted Svelte preview before releasing the picker', () => {
assert.match(
SOURCE,
/function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,420}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/,
'Svelte early accept must tear down its adapter mount before the next picking session starts',
);
});
it('variant injection resolves the picked anchor before entering recovery', () => {
assert.match(
SOURCE,
+31 -10
View File
@@ -8,8 +8,21 @@ const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
describe('live-browser source contracts', () => {
it('routes Nuxt Vue preview modules through the Vite build-assets base', () => {
assert.match(
SOURCE,
/function resolveComponentModuleUrl\(manifest, modulePath\)[\s\S]*?manifest\?\.previewMode === 'vue-component'[\s\S]*?window\.__NUXT__\?\.config\?\.app\?\.buildAssetsDir[\s\S]*?pathValue\.slice\('\/@fs\/'.length\)/,
'Nuxt must not send app-local preview modules through the page-route fallback',
);
assert.match(
SOURCE,
/const moduleBase = manifest\.componentModuleBase[\s\S]*?resolveComponentModuleUrl\(manifest, modulePath\)/,
'Vue SFC variants should use the manifest Vite module base rather than componentDir as a route URL',
);
});
it('dispatches plain generation before screenshot capture without bypassing annotated evidence', () => {
const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('if (!hasAnnotations) await sendEvent(basePayload);');
const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await sendEvent(basePayload);');
const captureIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await captureElementToBlob');
assert.ok(dispatchIndex >= 0, 'plain generation should dispatch immediately');
assert.ok(captureIndex > dispatchIndex, 'plain generation dispatch must happen before capture begins');
@@ -20,7 +33,7 @@ describe('live-browser source contracts', () => {
);
assert.match(
CAPTURE_AND_EMIT_SOURCE,
/if \(hasAnnotations\) \{\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
'annotated generation should dispatch exactly after capture and upload resolve',
);
});
@@ -303,7 +316,7 @@ describe('live-browser source contracts', () => {
assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/);
});
it('waits for post-carbonize completion before final accepted DOM cleanup', () => {
it('releases the foreground picker after deterministic accept while carbonize finishes', () => {
assert.match(
SOURCE,
/let pendingAcceptedSession = null;/,
@@ -327,8 +340,8 @@ describe('live-browser source contracts', () => {
const agentDoneStart = SOURCE.indexOf("case 'agent_done':");
const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart);
const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart);
assert.match(agentDoneSource, /Carbonize accepts are not terminal/);
assert.match(agentDoneSource, /break;/);
assert.match(agentDoneSource, /must not hold the foreground picker hostage/);
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
assert.match(
SOURCE,
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
@@ -337,15 +350,15 @@ describe('live-browser source contracts', () => {
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart);
assert.doesNotMatch(
assert.match(
handleAcceptSource,
/state = 'CONFIRMED'|cleanupAcceptedSession\(|hideBar\(\)/,
'accept enqueue should not clear or confirm the browser session before source cleanup completes',
/sendEvent\(acceptPayload, \{ throwOnError: true \}\)[\s\S]*?markSessionHandled\(\);[\s\S]*?setLiveState\('CONFIRMED'\);[\s\S]*?scheduleAcceptCleanup\(pending\);/,
'durable accept intent should release the foreground picker before background source cleanup completes',
);
assert.match(
SOURCE,
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?acceptedDomAlreadyClean\(accepted\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?\}, 1800\);/,
'post-cleanup fallback should give HMR a second chance before mutating React-owned DOM',
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?\}, 1200\);/,
'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred',
);
assert.match(
SOURCE,
@@ -411,4 +424,12 @@ describe('live-browser source contracts', () => {
'source fallback should translate simple JSX style objects such as display:none',
);
});
it('loads progressive source checkpoints through the no-HMR fallback', () => {
assert.match(
SOURCE,
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
);
});
});
+11 -1
View File
@@ -1,8 +1,18 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { htmlToJsx, normalizeVariantOutput } from './live-e2e/agent.mjs';
import {
htmlToJsx,
isExpectedGenerationCancellation,
normalizeVariantOutput,
} from './live-e2e/agent.mjs';
describe('live-e2e agent output translation', () => {
it('treats a fenced late generation as expected cancellation only', () => {
assert.equal(isExpectedGenerationCancellation(new Error('Source publication prepare failed: stale_generation_epoch')), true);
assert.equal(isExpectedGenerationCancellation(new Error('Source publication failed: stale_source_revision')), false);
assert.equal(isExpectedGenerationCancellation(new Error('provider unavailable')), false);
});
it('converts HTML class and inline style attributes to JSX syntax', () => {
const jsx = htmlToJsx(
'<h1 class="hero-title" style="--p-scale:1; font-size:2.25rem; font-weight:700">Title</h1>',
+63
View File
@@ -9,10 +9,13 @@ import {
createLlmAgent,
parseManualEditResponse,
parseVariantResponse,
progressiveVariantGuidance,
resolveLlmAgentConfig,
validateManualEditCoverage,
validateManualEditPlanningCoverage,
validateVariantMaterialChange,
validateVariantCount,
validateProgressiveVariantOutput,
validateVariantVisibleCopy,
} from './live-e2e/agents/llm-agent.mjs';
@@ -1459,6 +1462,19 @@ describe('live-e2e LLM agent manual edit coverage validation', () => {
});
describe('live-e2e LLM agent variant prompt', () => {
it('makes progressive phase boundaries and lazy parameters explicit', () => {
const first = progressiveVariantGuidance({ count: 1, progressive: { phase: 'first' } });
const remaining = progressiveVariantGuidance({
count: 3,
progressive: { phase: 'remaining', omitFirstVariantCss: true },
});
assert.match(first, /params: \[\]/);
assert.match(first, /materially different/);
assert.match(remaining, /complete final set of exactly 3 variants/);
assert.match(remaining, /Keep its innerHtml exactly unchanged/);
assert.match(remaining, /Do not repeat or modify any scopedCss rule/);
});
it('tells the model not to nest duplicate picked containers', () => {
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /replacement root itself/);
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /do not wrap a duplicate/);
@@ -1484,6 +1500,53 @@ describe('live-e2e LLM agent variant prompt', () => {
});
describe('live-e2e LLM agent variant copy validation', () => {
it('enforces the exact requested variant count', () => {
const parsed = { scopedCss: '', variants: [{ innerHtml: '<h1>One</h1>', params: [] }] };
assert.match(validateVariantCount(parsed, { count: 2 }), /expected exactly 2 variants, received 1/);
assert.equal(validateVariantCount(parsed, { count: 1 }), null);
});
it('defers progressive params and preserves the visible first variant', () => {
const firstHtml = '<h1 class="hero-title"><span>One</span></h1>';
assert.match(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: firstHtml, params: [{ id: 'weight' }] }] },
{ progressive: { phase: 'first' } },
),
/defer params/,
);
assert.equal(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: firstHtml, params: [] }] },
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
),
null,
);
assert.match(
validateProgressiveVariantOutput(
{ variants: [{ innerHtml: '<h1>Changed</h1>', params: [] }] },
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
),
/preserve variant 1/,
);
assert.match(
validateProgressiveVariantOutput(
{
scopedCss: '@scope ([data-impeccable-variant="1"]) { .hero-title { color: red; } }',
variants: [{ innerHtml: firstHtml, params: [] }],
},
{
progressive: {
phase: 'remaining',
firstVariant: { innerHtml: firstHtml },
omitFirstVariantCss: true,
},
},
),
/omit already-published variant 1 CSS/,
);
});
it('allows variants that preserve the picked element text', () => {
const result = validateVariantVisibleCopy(
{
+320 -11
View File
@@ -22,7 +22,7 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -45,6 +45,7 @@ import {
editTextLeaf,
drawAnnotationPinAndStroke,
getVisibleVariant,
installLiveQueryHelpers,
pickElement,
runLiveChromeBottomBarSmoke,
waitForApplyDockHidden,
@@ -220,7 +221,7 @@ for (const { name, fixture } of fixtures) {
const domSelector = isInsert
? insertDomSelector
: pickSelector;
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture);
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
const variantContentSelector = isInsert
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
: usesSvelteComponentPreview
@@ -314,10 +315,11 @@ for (const { name, fixture } of fixtures) {
const after = readFileSync(sourceFile, 'utf-8');
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
if (svelteComponentSession) {
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte');
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
const variantBody = readFileSync(variantFile, 'utf-8');
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted');
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
if (isInsert) {
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
if (agentMode === 'fake') {
@@ -328,9 +330,9 @@ for (const { name, fixture } of fixtures) {
assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element');
}
} else {
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element');
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'component variant contains target element');
}
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation');
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'route source is not edited during component preview');
} else {
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
}
@@ -349,7 +351,8 @@ for (const { name, fixture } of fixtures) {
}
}
if (svelteComponentSession) {
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /<style>/, 'Svelte component variant has scoped style block');
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
} else if (sourceFile.endsWith('.astro')) {
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
assert.match(
@@ -649,6 +652,226 @@ for (const { name, fixture } of fixtures) {
}
});
if (['vite8-react-plain', 'astro-vite7', 'nextjs-app-router', 'vite8-sveltekit', 'nuxt-vite7'].includes(name) && shouldRunScenario('progressive')) {
it('reveals variant 1 safely while the remaining variants and params are pending', liveE2eTestOptions, async (t) => {
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
t.skip('manual scenario filter is active');
return;
}
const traceEvents = [];
const session = await bootFixtureSession({
name,
fixture,
browser,
agent: createFakeAgent(),
wrapTarget: wrapTargetFromPickedElement,
progressive: true,
progressiveDelayMs: 2500,
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
log: (m) => t.diagnostic(m),
});
const { page, tmp, consoleErrors, teardown } = session;
let sourceFile = null;
try {
await waitForHandshake(page);
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
const originalCopy = await page.locator(pickSelector).innerText();
await pickElement(page, pickSelector);
await clickGo(page);
const partial = await waitForProgressiveReviewState(page, 3);
assert.equal(partial.arrived, 1, 'exactly variant 1 is present during the progressive interval');
assert.equal(partial.visible, 1, 'variant 1 is the visible review target');
assert.equal(partial.copy, originalCopy, 'variant 1 preserves the picked copy');
assert.notEqual(partial.acceptPointerEvents, 'none', 'Accept is available for the first reviewable variant');
assert.notEqual(partial.discardPointerEvents, 'none', 'Discard can cancel unfinished generation');
assert.equal(partial.hasParams, false, 'variant 1 has no eager parameter manifest');
assert.equal(partial.paramsPanelVisible, false, 'Tune UI stays hidden until parameter delivery');
sourceFile = await locateSessionFile(tmp);
const isComponentPreview = sourceFile.endsWith('manifest.json');
if (isComponentPreview) {
const manifest = JSON.parse(readFileSync(sourceFile, 'utf-8'));
sourceFile = join(tmp, manifest.sourceFile);
const extension = manifest.componentExtension || 'svelte';
assert.equal(existsSync(join(tmp, manifest.componentDir, `v1.${extension}`)), true, 'partial component preview contains variant 1');
assert.equal(existsSync(join(tmp, manifest.componentDir, 'params.json')), false, 'partial component preview defers parameter manifests');
} else {
const partialSource = readFileSync(sourceFile, 'utf-8');
assert.equal(countSourceVariants(partialSource), 1, 'partial source contains one reviewable variant');
assert.doesNotMatch(partialSource, /data-impeccable-params=/, 'partial source defers parameter manifests');
}
// Keyboard Accept must durably fence the worker before its delayed
// second publication, then return the browser to picking without
// waiting for variants the user no longer wants.
const acceptClickedAt = Date.now();
await clickAccept(page, { expectedVariant: 1 });
await waitForBarHidden(page);
await page.waitForFunction(
() => document.documentElement.dataset.impeccableLiveState === 'PICKING',
{ timeout: 2_000 },
);
const automationAcceptToPickingMs = Date.now() - acceptClickedAt;
const browserAcceptToPickingMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
const acceptToPickingMs = Number.isFinite(browserAcceptToPickingMs) && browserAcceptToPickingMs > 0
? browserAcceptToPickingMs
: automationAcceptToPickingMs;
t.diagnostic(`Accept dispatch → picker ready: ${acceptToPickingMs}ms (${automationAcceptToPickingMs}ms including Playwright actionability)`);
assert.ok(acceptToPickingMs < 500, `Accept should release the picker within 500ms of dispatch; got ${acceptToPickingMs}ms`);
const finalSource = await waitForSourceClean(sourceFile, 20_000);
assert.match(finalSource, new RegExp(escapeRegExp(originalCopy)), 'early accepted source preserves the original copy');
assert.doesNotMatch(finalSource, /data-impeccable-variant=/, 'early accepted source is free of preview scaffolding');
assert.equal(countSourceVariants(finalSource), 0, 'the delayed worker cannot reinsert later variants');
const firstGenerateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
// Give framework HMR one paint to settle the newly committed tree;
// this stays inside the 1.5s next-pick budget and avoids selecting a
// node instance React is replacing in the same frame.
if (name === 'nextjs-app-router' || name === 'vite8-sveltekit' || name === 'nuxt-vite7') await waitForHandshake(page);
await page.waitForTimeout(250);
await page.mouse.move(1, 1);
const nextPickSelector = name === 'nextjs-app-router'
? 'main.page'
: name === 'vite8-sveltekit'
? 'article.feature-card'
: name === 'nuxt-vite7'
? 'main.page'
: '.hero-hook';
await pickElement(page, nextPickSelector, {
resetPickMode: name === 'nextjs-app-router' || name === 'nuxt-vite7',
position: name === 'nuxt-vite7' ? { x: 12, y: 12 } : undefined,
});
const nextGoAt = Date.now();
await clickGo(page);
let nextGenerateTrace = null;
const pickupDeadline = Date.now() + 1_500;
while (Date.now() < pickupDeadline) {
nextGenerateTrace = traceEvents.find((event) => (
event.name === 'agent.event.received'
&& event.type === 'generate'
&& event.id !== firstGenerateId
));
if (nextGenerateTrace) break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.ok(nextGenerateTrace, 'the poll supervisor picks up the next generation while the canceled worker unwinds');
const nextDispatchToPickupMs = nextGenerateTrace.at - nextGenerateTrace.clientSentAt;
assert.ok(
nextDispatchToPickupMs < 1_500,
`next generation pickup should stay below 1.5s from dispatch; got ${nextDispatchToPickupMs}ms`,
);
t.diagnostic(`Next Go dispatch → generation pickup: ${nextDispatchToPickupMs}ms (${nextGenerateTrace.at - nextGoAt}ms including Playwright actionability)`);
if (process.env.IMPECCABLE_E2E_METRICS_FILE) {
appendFileSync(process.env.IMPECCABLE_E2E_METRICS_FILE, JSON.stringify({
acceptToPickingMs,
nextGoToPickupMs: nextDispatchToPickupMs,
automationAcceptToPickingMs,
automationNextGoToPickupMs: nextGenerateTrace.at - nextGoAt,
fixture: name,
at: new Date().toISOString(),
}) + '\n');
}
assert.ok(
traceEvents.some((event) => event.name === 'agent.scaffold.reused'),
'agent reuses the server preflight scaffold',
);
assert.equal(
traceEvents.some((event) => event.name === 'agent.scaffold.start'),
false,
'agent does not repeat deterministic source discovery after preflight',
);
const generateTrace = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate');
assert.ok(generateTrace?.id, 'generate trace exposes the durable session id');
const generationTimings = await waitForGenerationTimings(tmp, generateTrace.id, { requireAllVariants: false });
assert.ok(generationTimings.generation_ready?.at, 'durable timing records when generation work can start');
assert.ok(generationTimings.first_reviewable?.at, 'durable timing records the first reviewable variant');
assert.equal(generationTimings.all_variants_ready, undefined, 'canceled work never records all variants ready');
const realErrors = consoleErrors.filter((error) =>
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
);
if (fixture.runtime.probe?.expectConsoleClean) {
assert.deepEqual(realErrors, [], 'progressive HMR and early-action guards produce no browser errors');
} else if (realErrors.length > 0) {
t.diagnostic(`Known framework HMR console noise during progressive source rewrites: ${realErrors.length} error(s)`);
for (const error of realErrors) t.diagnostic(error.split('\n')[0]);
}
} finally {
await teardownAndResetBrowser(teardown);
}
});
}
if (name === 'vite8-react-plain' && shouldRunScenario('progressive')) {
it('accepts variant 2 while variant 3 is still pending', liveE2eTestOptions, async (t) => {
const traceEvents = [];
const session = await bootFixtureSession({
name,
fixture,
browser,
agent: createFakeAgent(),
wrapTarget: wrapTargetFromPickedElement,
progressive: true,
progressiveInitialCount: 2,
progressiveDelayMs: 2500,
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
log: (m) => t.diagnostic(m),
});
const { page, tmp, consoleErrors, teardown } = session;
try {
await waitForHandshake(page);
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
const originalCopy = await page.locator(pickSelector).innerText();
await pickElement(page, pickSelector);
await clickGo(page);
const partial = await waitForProgressiveReviewState(page, 3, { arrived: 2, visible: 1 });
assert.equal(partial.arrived, 2, 'variants 1 and 2 arrive before variant 3');
assert.equal(partial.visible, 1, 'variant 1 remains visible until the user advances');
assert.notEqual(partial.acceptPointerEvents, 'none', 'arrived variants remain actionable while the tail is pending');
assert.equal(partial.hasParams, false, 'the partial two-variant revision still defers parameter manifests');
await clickNext(page);
const second = await readProgressiveReviewState(page);
assert.equal(second.visible, 2, 'variant 2 is reviewable before variant 3 exists');
assert.equal(second.copy, originalCopy, 'variant 2 preserves the picked copy');
const wrappedSource = await locateSessionFile(tmp);
const acceptStartedAt = Date.now();
await clickAccept(page, { expectedVariant: 2 });
await waitForBarHidden(page);
await page.waitForFunction(
() => document.documentElement.dataset.impeccableLiveState === 'PICKING',
{ timeout: 2_000 },
);
const browserAcceptMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
const acceptToPickingMs = Number.isFinite(browserAcceptMs) && browserAcceptMs > 0
? browserAcceptMs
: Date.now() - acceptStartedAt;
assert.ok(acceptToPickingMs < 500, `variant 2 Accept should release the picker within 500ms; got ${acceptToPickingMs}ms`);
const cleanSource = await waitForSourceClean(wrappedSource, 20_000);
assert.match(cleanSource, new RegExp(escapeRegExp(originalCopy)), 'accepted variant 2 preserves source copy');
assert.doesNotMatch(cleanSource, /data-impeccable-variant=/, 'accepted variant 2 leaves no preview scaffolding');
await page.waitForTimeout(2750);
assert.doesNotMatch(readFileSync(wrappedSource, 'utf-8'), /data-impeccable-variant=/, 'the delayed variant 3 write stays fenced');
const generateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
const timings = await waitForGenerationTimings(tmp, generateId, { requireAllVariants: false });
assert.equal(timings.all_variants_ready, undefined, 'accepting variant 2 cancels the unfinished third variant');
const realErrors = consoleErrors.filter((error) =>
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
);
assert.deepEqual(realErrors, [], 'variant 2 early Accept stays console-clean');
} finally {
await teardownAndResetBrowser(teardown);
}
});
}
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
for (const scenario of fixture.runtime.manualEditScenarios) {
@@ -798,6 +1021,90 @@ function recordGenerateEvents(agent, events) {
};
}
async function waitForProgressiveReviewState(page, expected, { arrived: targetArrived = 1, visible: targetVisible = 1 } = {}) {
await installLiveQueryHelpers(page);
await page.waitForFunction(({ variantCount, targetArrived, targetVisible }) => {
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
const wrapper = query('[data-impeccable-variants]');
const variants = wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
? Number(debugState?.arrivedVariants || 0)
: variants?.length;
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const bar = root.querySelector('#impeccable-live-bar');
return arrived === targetArrived
&& new RegExp(`${targetVisible}\\s*\\/\\s*${variantCount}`).test(bar?.textContent || '')
&& /more arriving/.test(bar?.textContent || '');
}, { variantCount: expected, targetArrived, targetVisible }, { timeout: 15_000 });
return readProgressiveReviewState(page);
}
async function readProgressiveReviewState(page) {
await installLiveQueryHelpers(page);
return page.evaluate(() => {
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
const wrapper = query('[data-impeccable-variants]');
const variants = [...(wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])') || [])];
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const isSveltePreview = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '');
const visibleVariant = variants.find((variant) => getComputedStyle(variant).display !== 'none');
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const buttons = [...root.querySelectorAll('#impeccable-live-bar button')];
const accept = buttons.find((button) => /Accept/.test(button.textContent || ''));
const discard = buttons.find((button) => (button.textContent || '').includes('✕'));
const paramsPanel = root.querySelector('#impeccable-live-params-panel');
return {
arrived: isSveltePreview ? Number(debugState?.arrivedVariants || 0) : variants.length,
visible: isSveltePreview ? Number(debugState?.visibleVariant || 0) : Number(visibleVariant?.dataset.impeccableVariant || 0),
copy: isSveltePreview ? (wrapper?.innerText || '') : (visibleVariant?.innerText || ''),
acceptPointerEvents: accept ? getComputedStyle(accept).pointerEvents : null,
discardPointerEvents: discard ? getComputedStyle(discard).pointerEvents : null,
hasParams: variants.some((variant) => variant.hasAttribute('data-impeccable-params')),
paramsPanelVisible: !!paramsPanel
&& getComputedStyle(paramsPanel).pointerEvents !== 'none'
&& getComputedStyle(paramsPanel).clipPath === 'inset(0px)',
};
});
}
function countSourceVariants(source) {
return (String(source).match(/<div\s+data-impeccable-variant="(?!original")/g) || []).length;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function waitForGenerationTimings(tmp, id, { timeoutMs = 5_000, requireAllVariants = true } = {}) {
const snapshotPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.snapshot.json`);
const journalPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.jsonl`);
const deadline = Date.now() + timeoutMs;
let lastTimings = null;
while (Date.now() < deadline) {
if (existsSync(snapshotPath)) {
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
const timings = snapshot.generationTimings || {};
lastTimings = timings;
if (timings.generation_ready && timings.first_reviewable && (!requireAllVariants || timings.all_variants_ready)) return timings;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const checkpointReasons = existsSync(journalPath)
? readFileSync(journalPath, 'utf-8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)?.event)
.filter((event) => event?.type === 'checkpoint')
.map((event) => ({ reason: event.reason, arrivedVariants: event.arrivedVariants, expectedVariants: event.expectedVariants }))
: [];
throw new Error(`generation timings did not complete for ${id}: timings=${JSON.stringify(lastTimings)} checkpoints=${JSON.stringify(checkpointReasons)}`);
}
async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error, log = () => {} }) {
const root = process.env.IMPECCABLE_E2E_ARTIFACT_DIR;
if (!root || !session?.tmp) return;
@@ -1453,11 +1760,12 @@ function svelteComponentTargetFor(filePath) {
if (!filePath.endsWith('/manifest.json') && !filePath.endsWith('\\manifest.json')) return null;
let manifest;
try { manifest = JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
if (!['svelte-component', 'vue-component'].includes(manifest.previewMode) || !manifest.sourceFile || !manifest.componentDir) return null;
const sep = pathSepFor(filePath);
const markers = [
`${sep}node_modules${sep}.impeccable-live${sep}`,
`${sep}src${sep}lib${sep}impeccable${sep}`,
`${sep}app${sep}.impeccable-live${sep}`,
];
const marker = markers.find((candidate) => filePath.includes(candidate));
const idx = marker ? filePath.indexOf(marker) : -1;
@@ -1547,18 +1855,19 @@ async function locateSessionFile(tmp) {
return f;
}
}
for (const f of walkSvelteComponentManifests(tmp)) {
for (const f of walkComponentManifests(tmp)) {
const body = readFileSync(f, 'utf-8');
if (body.includes('"previewMode": "svelte-component"')) return f;
if (/"previewMode": "(?:svelte|vue)-component"/.test(body)) return f;
}
throw new Error('Could not locate session source file under ' + tmp);
}
function walkSvelteComponentManifests(root) {
function walkComponentManifests(root) {
const results = [];
const stack = [
join(root, 'node_modules/.impeccable-live'),
join(root, 'src/lib/impeccable'),
join(root, 'app/.impeccable-live'),
];
while (stack.length) {
const dir = stack.pop();
+300 -11
View File
@@ -27,6 +27,10 @@ import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { completionTypeForAcceptResult } from '../../skill/scripts/live/completion.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from '../../skill/scripts/live/generation-publisher.mjs';
const execFileP = promisify(execFile);
@@ -1325,15 +1329,25 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
styleMode: wrapInfo.styleMode,
});
const endMarkerIdx = lines.findIndex((line, index) =>
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
);
if (endMarkerIdx === -1) {
throw new Error('end marker not found in ' + wrapInfo.file);
}
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
? endMarkerIdx
: endMarkerIdx - 1;
const next = [
...lines.slice(0, markerIdx + 1),
block,
...lines.slice(markerIdx + 1),
...lines.slice(tailIdx),
];
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
@@ -1373,7 +1387,157 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
if (writeParams) {
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
manifest.arrivedVariants = output.variants.length;
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
async function publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const prepared = prepareGenerationArtifact({
id: event.id,
sourceFile: wrapInfo.file,
cwd: tmp,
});
if (!prepared.ok) throw new Error(`Svelte publication prepare failed: ${prepared.error}`);
await writeSvelteComponentVariants({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
event,
output,
writeParams,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Svelte publication failed: ${published.error}`);
return published;
}
async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
const textValues = extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
const paramsByVariant = {};
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
for (const entry of contract) {
markup = markup.replaceAll(`{${entry.prop}}`, `{{ ${entry.prop} }}`);
}
const css = svelteCssForVariant(output.scopedCss || '', variantId, firstTagName(markup) || 'div');
const propsScript = contract.length > 0
? ['<script setup>', 'defineProps({', ...contract.map((entry) => ` ${entry.prop}: { default: '' },`), '});', '</script>', '']
: [];
const component = [
...propsScript,
'<template>',
markup || '<div></div>',
'</template>',
'',
'<style scoped>',
css || ':where(*) {}',
'</style>',
'',
].join('\n');
await fs.writeFile(path.join(componentDir, `v${variantId}.vue`), component, 'utf-8');
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
if (writeParams) {
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
manifest.arrivedVariants = output.variants.length;
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
async function publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const prepared = prepareGenerationArtifact({ id: event.id, sourceFile: wrapInfo.file, cwd: tmp });
if (!prepared.ok) throw new Error(`Vue publication prepare failed: ${prepared.error}`);
await writeVueComponentVariants({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
event,
output,
writeParams,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Vue publication failed: ${published.error}`);
return published;
}
async function publishSourceVariants({ tmp, wrapInfo, event, output }) {
const prepared = prepareGenerationArtifact({
id: event.id,
sourceFile: wrapInfo.file,
cwd: tmp,
});
if (!prepared.ok) throw new Error(`Source publication prepare failed: ${prepared.error}`);
await spliceVariantsIntoWrapper({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
sessionId: event.id,
output,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Source publication failed: ${published.error}`);
return published;
}
async function publishVariantProgress({ base, token, event, wrapInfo, arrivedVariants, signal }) {
const previewMode = wrapInfo.previewMode || 'source';
await fetch(`${base}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
type: 'checkpoint',
id: event.id,
revision: 1,
phase: 'cycling',
reason: 'variants_progress',
arrivedVariants,
expectedVariants: event.count,
sourceFile: wrapInfo.sourceFile || wrapInfo.file,
previewFile: wrapInfo.file,
previewMode,
}),
signal,
});
}
function variantMarkupHasVisibleContent(markup) {
@@ -1507,6 +1671,11 @@ export async function runAgentLoop({
agent,
signal,
log = () => {},
trace = () => {},
progressive = false,
progressiveDelayMs = 0,
progressiveInitialCount = 1,
atomicDelayMs = 0,
wrapTarget = { classes: 'hero-title', tag: 'h1' },
steerSourceFile,
steerTarget,
@@ -1530,6 +1699,8 @@ export async function runAgentLoop({
if (event.type === 'prefetch') continue;
if (event.type === 'connected') continue;
trace('agent.event.received', { id: event.id, type: event.type, clientSentAt: event.clientSentAt ?? null });
if (event.type === 'steer') {
log(`steer id=${event.id} message=${JSON.stringify(event.message)}`);
try {
@@ -1578,7 +1749,16 @@ export async function runAgentLoop({
log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`);
try {
let wrapInfo;
if (isInsert) {
if (event.scaffold) {
wrapInfo = event.scaffold;
trace('agent.scaffold.reused', {
id: event.id,
file: wrapInfo.file,
previewMode: wrapInfo.previewMode || 'source',
durationMs: event.scaffoldDurationMs ?? null,
});
} else if (isInsert) {
trace('agent.scaffold.start', { id: event.id, mode: 'insert' });
const insertTarget = insertTargetFromEvent(event);
wrapInfo = await runInsert({
tmp,
@@ -1587,7 +1767,9 @@ export async function runAgentLoop({
count: event.count,
...insertTarget,
});
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
} else {
trace('agent.scaffold.start', { id: event.id, mode: 'replace' });
// 1. Wrap the original element in the variant scaffold (deterministic CLI)
// wrapTarget can be a static {classes, tag, elementId} (test fixtures
// know what they pick) or a function (event) => target (real-use
@@ -1606,41 +1788,142 @@ export async function runAgentLoop({
...target,
text,
});
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
}
log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
// 2. Agent generates variant content (LLM-pluggable seam)
let output = await agent.generateVariants(event, { wrapTarget, wrapInfo });
output = normalizeVariantOutput(output, wrapInfo);
// 2. Agent generates variant content (LLM-pluggable seam).
// Providers may expose a true split path so variant 1 is written before
// the request for the remaining variants completes.
trace('agent.generate.start', { id: event.id, count: event.count });
const splitProgressive = progressive
&& typeof agent.generateFirstVariant === 'function'
&& typeof agent.generateRemainingVariants === 'function'
&& event.count > 1;
let output;
let firstOutput;
if (splitProgressive) {
firstOutput = normalizeVariantOutput(
await agent.generateFirstVariant(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
firstOutput = {
...firstOutput,
variants: firstOutput.variants.slice(0, 1).map((variant) => ({ ...variant, params: [] })),
};
trace('agent.generate.first_ready', { id: event.id, count: firstOutput.variants.length });
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else {
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
}
await publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants: firstOutput.variants.length,
signal,
});
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
output = normalizeVariantOutput(
await agent.generateRemainingVariants(event, { wrapTarget, wrapInfo, firstOutput }),
wrapInfo,
);
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
} else {
output = normalizeVariantOutput(
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
if (!progressive && atomicDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
}
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
if (!progressive || output.variants.length <= 1) {
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
}
if (progressive && output.variants.length > 1) {
const initialCount = Math.max(1, Math.min(
Number(progressiveInitialCount) || 1,
output.variants.length - 1,
));
firstOutput = {
...output,
variants: output.variants
.slice(0, initialCount)
.map((variant) => ({ ...variant, params: [] })),
};
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else {
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
}
await publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants: firstOutput.variants.length,
signal,
});
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
if (progressiveDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, progressiveDelayMs));
}
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
}
}
if (output.variants.length !== event.count) {
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
}
// 3. Write variants into the deterministic preview target.
// 3. Write the complete set into the deterministic preview target.
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output });
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (progressive) {
await publishSourceVariants({ tmp, wrapInfo, event, output });
} else {
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
}
trace('agent.write.end', { id: event.id, file: wrapInfo.file });
if (process.env.IMPECCABLE_E2E_DEBUG) {
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
log(`--- post-splice (variants written) ---\n${post}`);
}
// 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING)
trace('agent.reply.start', { id: event.id });
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'done', id: event.id, file: wrapInfo.file }),
body: JSON.stringify({ token, type: 'done', sourceEventType: 'generate', id: event.id, file: wrapInfo.file }),
signal,
});
trace('agent.reply.end', { id: event.id });
} catch (err) {
if (signal.aborted) return;
if (isExpectedGenerationCancellation(err)) {
trace('agent.generate.canceled', { id: event.id, reason: 'stale_generation_epoch' });
log('generate canceled after Accept/Discard: ' + err.message);
continue;
}
trace('agent.generate.error', { id: event.id, message: err.message });
log('generate failed: ' + err.message);
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }),
body: JSON.stringify({ token, type: 'error', sourceEventType: 'generate', id: event.id, message: err.message }),
signal,
}).catch(() => {});
}
@@ -1740,6 +2023,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
sourceEventType: 'accept',
id: event.id,
file: acceptResult.file,
message: acceptResult.error,
@@ -1769,6 +2053,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
sourceEventType: 'discard',
id: event.id,
file: discardResult.file,
message: discardResult.error,
@@ -1787,6 +2072,10 @@ export async function runAgentLoop({
}
}
export function isExpectedGenerationCancellation(error) {
return /(?:^|\b)stale_generation_epoch(?:\b|$)/.test(String(error?.message || error || ''));
}
async function runPollReply({ tmp, scriptsDir, id, status, message, data }) {
const args = [path.join(scriptsDir, 'live-poll.mjs'), '--reply', id, status];
if (data !== undefined) args.push('--data', JSON.stringify(data));
+76 -25
View File
@@ -192,6 +192,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [
* @property {string=} model Override the selected provider's default model.
* @property {string=} baseURL Override the provider API base URL.
* @property {object=} config Pre-resolved provider config from resolveLlmAgentConfig().
* @property {boolean=} includeLiveSpec Attach the full live.md reference. Defaults to true; latency benchmarks disable it to export only the synthetic element contract.
* @property {(msg: string) => void=} log Optional logger for debug output.
*/
@@ -240,14 +241,22 @@ export async function createLlmAgent(opts = {}) {
const { apiKey, baseURL, model, provider } = config;
const log = opts.log || (() => {});
const liveMd = await fs.readFile(LIVE_MD_PATH, 'utf-8');
const liveMd = opts.includeLiveSpec === false ? null : await fs.readFile(LIVE_MD_PATH, 'utf-8');
const client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
const systemBlocks = (instructions) => [
{
type: 'text',
text: liveMd ? instructions : instructions.replace(/\n\nCONTEXT —[^\n]+$/, ''),
},
...(liveMd ? [{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } }] : []),
];
return {
async generateVariants(event, context = {}) {
const isInsert = event.mode === 'insert';
const baseUserMessage = [
`Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`,
progressiveVariantGuidance(event),
'',
'```json',
JSON.stringify(buildVariantRequestPayload(event, context), null, 2),
@@ -256,6 +265,7 @@ export async function createLlmAgent(opts = {}) {
let userMessage = baseUserMessage;
for (let attempt = 0; attempt < MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS; attempt += 1) {
const lastAttempt = attempt + 1 >= MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS;
let response;
try {
response = await client.messages.create(
@@ -263,15 +273,10 @@ export async function createLlmAgent(opts = {}) {
model,
temperature: 0,
max_tokens: 16000,
system: [
{ type: 'text', text: VARIANT_SYSTEM_INSTRUCTIONS },
// Cacheable: the entire stable prefix (instructions + spec) is
// cached up to this breakpoint. The user message holds all the
// per-call volatile content. DeepSeek compatibility support is
// provider-reported and best-effort; the usage log below tells us
// whether cache reads/writes actually happened.
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
// When present, live.md is the final cacheable stable prefix.
// Benchmarks omit it so external payloads contain only the
// synthetic element contract and per-run event.
system: systemBlocks(VARIANT_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
},
{
@@ -280,7 +285,7 @@ export async function createLlmAgent(opts = {}) {
},
);
} catch (err) {
if (attempt === 1) throw err;
if (lastAttempt) throw err;
log(`variant request failed; retrying: ${err.message}`);
userMessage = [
baseUserMessage,
@@ -300,7 +305,7 @@ export async function createLlmAgent(opts = {}) {
`provider=${provider} model=${model} attempt=${attempt + 1} input=${inputTokens} output=${outputTokens} cache_read=${cacheRead} cache_write=${cacheWrite}`,
);
if (!response || !Array.isArray(response.content)) {
if (attempt === 1) throw new Error('LLM agent: provider returned an empty variant response');
if (lastAttempt) throw new Error('LLM agent: provider returned an empty variant response');
log('variant response validation failed; retrying: provider returned an empty response');
userMessage = [
baseUserMessage,
@@ -320,7 +325,7 @@ export async function createLlmAgent(opts = {}) {
try {
parsed = parseVariantResponse(text);
} catch (err) {
if (attempt === 1) throw err;
if (lastAttempt) throw err;
log(`variant response validation failed; retrying: ${err.message.split('\n')[0]}`);
userMessage = [
baseUserMessage,
@@ -332,11 +337,13 @@ export async function createLlmAgent(opts = {}) {
continue;
}
const validationError = isInsert
? validateInsertVariantOutput(parsed, event)
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element));
const validationError = validateVariantCount(parsed, event)
|| validateProgressiveVariantOutput(parsed, event)
|| (isInsert
? validateInsertVariantOutput(parsed, event)
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element)));
if (!validationError) return parsed;
if (attempt === 1) throw new Error(`LLM agent: ${validationError}`);
if (lastAttempt) throw new Error(`LLM agent: ${validationError}`);
log(`variant validation failed; retrying: ${validationError}`);
if (isInsert) {
@@ -411,10 +418,7 @@ export async function createLlmAgent(opts = {}) {
model,
temperature: 0,
max_tokens: 16000,
system: [
{ type: 'text', text: MANUAL_EDIT_SYSTEM_INSTRUCTIONS },
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
system: systemBlocks(MANUAL_EDIT_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
},
{
@@ -542,10 +546,7 @@ export async function createLlmAgent(opts = {}) {
const response = await client.messages.create({
model,
max_tokens: 4096,
system: [
{ type: 'text', text: STEER_SYSTEM_INSTRUCTIONS },
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
],
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
messages: [{ role: 'user', content: userMessage }],
});
@@ -672,6 +673,7 @@ export function buildVariantRequestPayload(event, context = {}) {
action: event?.action,
freeformPrompt: event?.freeformPrompt,
count: event?.count,
progressive: event?.progressive,
element: isInsert ? null : {
outerHTML: event?.element?.outerHTML,
tagName: event?.element?.tagName,
@@ -691,6 +693,31 @@ export function buildVariantRequestPayload(event, context = {}) {
};
}
export function progressiveVariantGuidance(event = {}) {
if (event.progressive?.phase === 'first') {
return [
'PROGRESSIVE FIRST DELIVERY:',
`- Return exactly ${event.count} variant now.`,
'- Return params: [] for this variant; tunable parameters are generated in the final phase.',
'- The innerHtml must be materially different from the picked source, not merely paired with different CSS.',
'- For a bare-text element, preserve the full exact copy in one child span inside the unchanged root tag/class.',
].join('\n');
}
if (event.progressive?.phase === 'remaining') {
return [
'PROGRESSIVE FINAL DELIVERY:',
`- Return the complete final set of exactly ${event.count} variants, including variant 1.`,
'- progressive.firstVariant is the already-visible variant 1. Keep its innerHtml exactly unchanged and add its deferred params now.',
...(event.progressive.omitFirstVariantCss ? [
'- Variant 1 CSS is already published and immutable. Do not repeat or modify any scopedCss rule for data-impeccable-variant="1"; return scopedCss rules for variants 2+ only.',
] : []),
'- Generate the remaining distinct variants and their params in the other array positions.',
'- Every remaining variant innerHtml must be materially changed too; for bare text, wrap the full exact copy in one child span with a distinct class instead of relying on CSS alone.',
].join('\n');
}
return '';
}
/**
* Parse and validate a model response into the variant-output schema. Throws
* with a `Parsed (first 500 chars): ...` echo on every schema failure so the
@@ -850,6 +877,30 @@ export function validateInsertVariantOutput(parsed, event = {}) {
return null;
}
export function validateVariantCount(parsed, event = {}) {
const expected = Number(event.count);
if (!Number.isInteger(expected) || expected < 1) return 'event count must be a positive integer';
const actual = Array.isArray(parsed?.variants) ? parsed.variants.length : 0;
return actual === expected ? null : `expected exactly ${expected} variants, received ${actual}`;
}
export function validateProgressiveVariantOutput(parsed, event = {}) {
if (event.progressive?.phase === 'first') {
const hasEarlyParams = (parsed.variants || []).some((variant) => Array.isArray(variant.params) && variant.params.length > 0);
return hasEarlyParams ? 'progressive first delivery must defer params with an empty params array' : null;
}
if (event.progressive?.phase === 'remaining' && event.progressive.firstVariant?.innerHtml) {
const expected = String(event.progressive.firstVariant.innerHtml).trim();
const actual = String(parsed.variants?.[0]?.innerHtml || '').trim();
if (actual !== expected) return 'progressive final delivery must preserve variant 1 innerHtml exactly';
if (event.progressive.omitFirstVariantCss && /\[data-impeccable-variant\s*=\s*["']1["'][^\]]*\]/.test(parsed.scopedCss || '')) {
return 'progressive final delivery must omit already-published variant 1 CSS';
}
return null;
}
return null;
}
export function validateVariantMaterialChange(parsed, element) {
const originalHtml = normalizeVariantHtml(element?.outerHTML || '');
if (!originalHtml) return null;
+53 -6
View File
@@ -14,7 +14,7 @@
*/
import { execFileSync, spawn } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -56,6 +56,7 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
const installArgs = addNpmInstallDefaults(cmd, args);
try {
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
repairMissingRollupOptionalBinary(tmp, { timeoutMs });
} catch (err) {
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
@@ -64,11 +65,26 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
}
}
function repairMissingRollupOptionalBinary(tmp, { timeoutMs }) {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return;
const rollupPackage = join(tmp, 'node_modules', 'rollup', 'package.json');
const nativePackage = join(tmp, 'node_modules', '@rollup', 'rollup-darwin-arm64', 'package.json');
if (!existsSync(rollupPackage) || existsSync(nativePackage)) return;
const version = JSON.parse(readFileSync(rollupPackage, 'utf-8')).version;
execFileSync('npm', [
'install', '--no-save', '--no-audit', '--no-fund', '--no-progress',
`@rollup/rollup-darwin-arm64@${version}`,
], { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
}
function addNpmInstallDefaults(cmd, args) {
if (cmd !== 'npm') return args;
if (!['install', 'ci'].includes(args[0])) return args;
const out = [...args];
for (const flag of ['--prefer-offline', '--no-progress']) {
// npm can omit platform-specific Rollup binaries unless optional
// dependencies are requested explicitly (npm/cli#4828). Astro/Vite then
// fail before Live starts on fresh staged fixtures.
for (const flag of ['--no-progress', '--include=optional']) {
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
}
return out;
@@ -205,7 +221,19 @@ export async function stopDevServer(child) {
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
* @param {(msg: string) => void} [opts.log]
*/
export async function bootFixtureSession({ name, fixture, browser, agent, wrapTarget, log = () => {} }) {
export async function bootFixtureSession({
name,
fixture,
browser,
agent,
wrapTarget,
log = () => {},
trace = () => {},
progressive = false,
progressiveDelayMs = 0,
progressiveInitialCount = 1,
atomicDelayMs = 0,
}) {
const runtime = fixture.runtime;
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
@@ -233,30 +261,38 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
try {
const startedAt = Date.now();
trace('setup.install.start', { fixture: name });
log(`installing deps`);
runInstall(tmp, runtime.install);
trace('setup.install.end', { fixture: name });
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
const liveStartedAt = Date.now();
trace('setup.live_server.start', { fixture: name });
log(`starting live-server`);
live = startLiveServer(tmp);
trace('setup.live_server.end', { fixture: name, port: live.port });
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
const injectStartedAt = Date.now();
trace('setup.inject.start', { fixture: name });
log(`live-inject --port ${live.port}`);
const injectResult = runInject(tmp, live.port);
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
const devStartedAt = Date.now();
trace('setup.dev_server.start', { fixture: name });
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
dev = startDevServer(tmp, runtime);
const { port: devPort } = await dev.ready;
trace('setup.dev_server.end', { fixture: name, port: devPort });
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
// Agent loop runs concurrently — abort on teardown.
agentAbort = new AbortController();
agentDone = runAgentLoop({
const loopOptions = {
tmp,
scriptsDir: SCRIPTS_DIR,
port: live.port,
@@ -264,10 +300,19 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
agent,
wrapTarget,
signal: agentAbort.signal,
log: (m) => log('[agent] ' + m),
trace,
progressive,
progressiveDelayMs,
progressiveInitialCount,
atomicDelayMs,
steerSourceFile: runtime.steer?.sourceFile,
steerTarget: runtime.steer?.target,
});
};
const loops = [runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })];
if (progressive) {
loops.push(runAgentLoop({ ...loopOptions, log: (m) => log('[supervisor] ' + m) }));
}
agentDone = Promise.all(loops);
const scheme = runtime.scheme || 'http';
ctx = await browser.newContext({
@@ -283,10 +328,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
});
const pageStartedAt = Date.now();
trace('setup.page_load.start', { fixture: name });
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
trace('setup.page_load.end', { fixture: name });
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
return {
+26 -3
View File
@@ -424,7 +424,23 @@ export async function pickElement(page, selector, opts = {}) {
if (visible) break;
await resetPickMode(page);
if (attempt === 2) {
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
const snapshot = await page.evaluate(({ selector, barSel, pickSel }) => {
const target = document.querySelector(selector);
const rect = target?.getBoundingClientRect();
const hit = rect ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null;
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
const bar = query(barSel);
const pick = query(pickSel);
return {
liveState: document.documentElement.dataset.impeccableLiveState || null,
target: target ? { tag: target.tagName, classes: target.className, rect: rect?.toJSON?.() || null } : null,
hit: hit ? { tag: hit.tagName, classes: hit.className, text: (hit.textContent || '').slice(0, 80) } : null,
pickActive: pick?.dataset.active || null,
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
};
}, { selector, barSel: BAR_ID, pickSel: PICK_TOGGLE_ID }).catch((error) => ({ error: error.message }));
throw new Error(`pick did not open configure bar for ${selector}: ${JSON.stringify(snapshot)}`);
}
}
// Wait specifically for the Configure-row submit button to be in the bar.
@@ -578,7 +594,14 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
const m = text.match(/(\d+)\s*\/\s*(\d+)/);
if (!m) return false;
return parseInt(m[2], 10) === expected;
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
? Number(debugState?.arrivedVariants || 0)
: wrapper
? wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length
: 0;
return parseInt(m[2], 10) === expected && arrived >= expected;
},
{ barSel: BAR_ID, expected: expectedCount },
{ timeout },
@@ -590,7 +613,7 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
const bar = query(barSel);
const toast = query('#impeccable-live-toast');
const wrapper = document.querySelector('[data-impeccable-variants]');
const wrapper = query('[data-impeccable-variants]');
return {
liveInit: window.__IMPECCABLE_LIVE_INIT__,
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import {
buildGenerationPreflight,
runGenerationPreflight,
} from '../skill/scripts/live/generation-preflight.mjs';
const SCRIPTS_DIR = path.resolve('skill/scripts');
test('builds a replace preflight from the picker locator', () => {
const command = buildGenerationPreflight({
type: 'generate',
id: 'session-1',
count: 3,
pageUrl: '/pricing',
element: {
id: 'hero',
classes: ['hero', 'hero--dark'],
tagName: 'SECTION',
textContent: 'A faster way to ship',
},
}, SCRIPTS_DIR);
assert.equal(command.mode, 'replace');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-1', '--count', '3',
'--element-id', 'hero',
'--classes', 'hero hero--dark',
'--tag', 'SECTION',
'--text', 'A faster way to ship',
'--page-url', '/pricing',
]);
});
test('builds an insert preflight from the anchor locator', () => {
const command = buildGenerationPreflight({
type: 'generate',
id: 'session-2',
count: 2,
mode: 'insert',
insert: {
position: 'before',
anchor: { classes: ['card'], tagName: 'ARTICLE', textContent: 'Plan' },
},
}, SCRIPTS_DIR);
assert.equal(command.mode, 'insert');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-2', '--count', '2', '--position', 'before',
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
]);
});
test('returns scaffold metadata without exposing child-process details', () => {
const calls = [];
const result = runGenerationPreflight({
type: 'generate',
id: 'session-3',
count: 1,
element: { classes: ['hero'] },
}, {
scriptsDir: SCRIPTS_DIR,
cwd: '/tmp/example',
execFileSyncImpl(file, args, options) {
calls.push({ file, args, options });
return '{"file":"src/App.jsx","insertLine":12}\n';
},
});
assert.equal(result.ok, true);
assert.deepEqual(result.scaffold, { file: 'src/App.jsx', insertLine: 12 });
assert.equal(calls[0].file, process.execPath);
assert.equal(calls[0].options.cwd, '/tmp/example');
});
test('skips preflight when the picker has no source locator', () => {
const result = runGenerationPreflight({
type: 'generate',
id: 'session-4',
count: 3,
element: { tagName: 'DIV' },
}, { scriptsDir: SCRIPTS_DIR });
assert.deepEqual(result, { ok: false, skipped: true, reason: 'insufficient_locator' });
});
+364
View File
@@ -0,0 +1,364 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
sha256,
} from '../skill/scripts/live/generation-publisher.mjs';
describe('transactional generation publisher', () => {
let tmp;
let source;
let artifact;
let store;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-publisher-'));
source = join(tmp, 'page.html');
artifact = join(tmp, 'variant.html');
writeFileSync(source, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div></div></main>');
store = createLiveSessionStore({ cwd: tmp, sessionId: 'abc12345' });
store.appendEvent({
type: 'generate',
id: 'abc12345',
generationEpoch: 1,
action: 'polish',
count: 3,
element: { outerHTML: '<main>Original</main>' },
});
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('atomically publishes an artifact that matches the fenced source revision', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1">Variant</div></div></main>');
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, true);
assert.equal(result.arrivedVariants, 1);
assert.equal(readFileSync(source, 'utf-8'), readFileSync(artifact, 'utf-8'));
const snapshot = store.getSnapshot('abc12345');
assert.equal(snapshot.phase, 'variants_progress');
assert.equal(snapshot.publishedRevision, 1);
assert.equal(snapshot.deliveredVariants['1'].digest, result.digest);
});
it('prepares a revision artifact with the current epoch and source fence', () => {
const result = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
assert.equal(result.ok, true);
assert.equal(result.epoch, 1);
assert.equal(result.revision, 1);
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
assert.equal(readFileSync(join(tmp, result.artifactFile), 'utf-8'), readFileSync(source, 'utf-8'));
});
it('rejects a late publication after early accept without touching source', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Late</div></div></main>');
store.appendEvent({ type: 'accept', id: 'abc12345', variantId: '1' });
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
cwd: tmp,
});
assert.deepEqual(result, {
ok: false,
error: 'stale_generation_epoch',
canceled: true,
phase: 'accept_requested',
});
assert.equal(readFileSync(source, 'utf-8'), before);
});
it('rejects a stale artifact when source changed after the worker snapshot', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Variant</div></div></main>');
writeFileSync(source, before.replace('Original', 'Changed'));
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'source_hash_mismatch');
assert.match(readFileSync(source, 'utf-8'), /Changed/);
});
it('keeps an already reviewable source variant immutable across revisions', () => {
const firstSource = '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><section><div>First</div></section></div></div></main>';
writeFileSync(artifact, firstSource);
const first = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(first.ok, true);
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
const changed = firstSource.replace('First', 'Silently changed')
.replace('</div></div></main>', '</div><div data-impeccable-variant="2">Second</div></div></main>');
writeFileSync(join(tmp, prepared.artifactFile), changed);
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: prepared.epoch,
sourceFile: source,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 2,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_changed');
assert.equal(result.variant, 1);
assert.equal(readFileSync(source, 'utf-8'), firstSource);
});
it('rejects later source revisions that restyle an already reviewable variant', () => {
const firstSource = '<main><div data-impeccable-variants="abc12345"><style data-impeccable-css="abc12345">@scope ([data-impeccable-variant="1"]) { :scope > h1 { color: red; } }</style><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><h1>First</h1></div></div></main>';
writeFileSync(artifact, firstSource);
const first = publishGenerationArtifact({
id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
});
assert.equal(first.ok, true);
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
const changed = firstSource.replace('color: red', 'color: blue');
writeFileSync(join(tmp, prepared.artifactFile), changed);
const result = publishGenerationArtifact({
id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_css_changed', JSON.stringify(result));
assert.equal(readFileSync(source, 'utf-8'), firstSource);
});
});
describe('transactional Svelte component publisher', () => {
let tmp;
let source;
let manifestPath;
let componentDir;
let store;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-svelte-publisher-'));
source = join(tmp, 'src', 'routes', '+page.svelte');
componentDir = join(tmp, 'node_modules', '.impeccable-live', 'svelte123');
manifestPath = join(componentDir, 'manifest.json');
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
mkdirSync(componentDir, { recursive: true });
writeFileSync(source, '<main><h1>{title}</h1></main>\n');
writeFileSync(manifestPath, JSON.stringify({
id: 'svelte123',
previewMode: 'svelte-component',
sourceFile: 'src/routes/+page.svelte',
sourceStartLine: 1,
sourceEndLine: 1,
count: 3,
propContract: [{ prop: 'title', expr: 'title', placeholder: '{title}' }],
originalMarkup: '<main><h1>{title}</h1></main>',
componentDir: 'node_modules/.impeccable-live/svelte123',
runtimeModule: '/node_modules/.impeccable-live/__runtime.js',
}, null, 2) + '\n');
for (let variant = 1; variant <= 3; variant++) {
writeFileSync(join(componentDir, `v${variant}.svelte`), `<main>Stub ${variant}</main>\n`);
}
store = createLiveSessionStore({ cwd: tmp, sessionId: 'svelte123' });
store.appendEvent({
type: 'generate',
id: 'svelte123',
generationEpoch: 1,
action: 'polish',
count: 3,
element: { outerHTML: '<main><h1>Original</h1></main>' },
});
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('prepares an isolated component directory fenced against the real route', () => {
const result = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
assert.equal(result.ok, true);
assert.equal(result.previewMode, 'svelte-component');
assert.equal(result.sourceFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
assert.equal(result.targetSourceFile, 'src/routes/+page.svelte');
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
const artifactManifest = JSON.parse(readFileSync(join(tmp, result.artifactFile), 'utf-8'));
assert.equal(artifactManifest.componentDir, result.componentDir);
assert.equal(readFileSync(join(tmp, result.componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
writeFileSync(join(tmp, result.componentDir, 'v1.svelte'), '<main>Prepared only</main>\n');
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
});
it('publishes components before committing the arrived manifest and journals preview metadata', () => {
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const artifactManifestPath = join(tmp, prepared.artifactFile);
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
artifactManifest.arrivedVariants = 1;
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
writeFileSync(join(tmp, prepared.componentDir, 'v1.svelte'), '<main>First live variant</main>\n');
const result = publishGenerationArtifact({
id: 'svelte123',
epoch: prepared.epoch,
sourceFile: manifestPath,
artifactFile: artifactManifestPath,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, true);
assert.equal(result.previewMode, 'svelte-component');
assert.equal(result.sourceFile, 'src/routes/+page.svelte');
assert.equal(result.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
assert.equal(readFileSync(source, 'utf-8'), '<main><h1>{title}</h1></main>\n');
const liveManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
assert.equal(liveManifest.arrivedVariants, 1);
assert.equal(liveManifest.componentDir, 'node_modules/.impeccable-live/svelte123');
const snapshot = store.getSnapshot('svelte123');
assert.equal(snapshot.arrivedVariants, 1);
assert.equal(snapshot.previewMode, 'svelte-component');
assert.equal(snapshot.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
});
it('keeps published variants immutable across later revisions', () => {
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const before = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
const result = publishSveltePrepared(second, {
arrived: 2,
edits: {
1: '<main>Silently changed first variant</main>\n',
2: '<main>Second live variant</main>\n',
},
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_changed');
assert.equal(result.variant, 1);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), before);
assert.equal(JSON.parse(readFileSync(manifestPath, 'utf-8')).arrivedVariants, 1);
});
it('publishes later variants and params without rewriting an already reviewable variant', () => {
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
writeFileSync(join(tmp, second.componentDir, 'params.json'), '{"2":[{"id":"density"}]}\n');
const result = publishSveltePrepared(second, {
arrived: 3,
edits: {
2: '<main>Second live variant</main>\n',
3: '<main>Third live variant</main>\n',
},
});
assert.equal(result.ok, true);
assert.equal(result.arrivedVariants, 3);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
assert.equal(readFileSync(join(componentDir, 'v2.svelte'), 'utf-8'), '<main>Second live variant</main>\n');
assert.equal(existsSync(join(componentDir, 'params.json')), true);
assert.deepEqual(JSON.parse(readFileSync(join(componentDir, 'params.json'), 'utf-8')), {
2: [{ id: 'density' }],
});
});
it('rejects a prepared Svelte publication after Accept without touching live artifacts', () => {
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const beforeManifest = readFileSync(manifestPath, 'utf-8');
const beforeVariant = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
store.appendEvent({ type: 'accept', id: 'svelte123', variantId: '1' });
const result = publishSveltePrepared(prepared, {
arrived: 1,
edits: { 1: '<main>Too late</main>\n' },
});
assert.equal(result.ok, false);
assert.equal(result.error, 'stale_generation_epoch');
assert.equal(readFileSync(manifestPath, 'utf-8'), beforeManifest);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), beforeVariant);
});
it('rejects a live component directory masquerading as a staged artifact', () => {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
manifest.arrivedVariants = 1;
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
const result = publishGenerationArtifact({
id: 'svelte123',
epoch: 1,
sourceFile: manifestPath,
artifactFile: manifestPath,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'artifact_not_staged');
});
function publishSveltePrepared(prepared, { arrived, edits }) {
const artifactManifestPath = join(tmp, prepared.artifactFile);
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
artifactManifest.arrivedVariants = arrived;
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
for (const [variant, content] of Object.entries(edits)) {
writeFileSync(join(tmp, prepared.componentDir, `v${variant}.svelte`), content);
}
return publishGenerationArtifact({
id: 'svelte123',
epoch: prepared.epoch,
sourceFile: manifestPath,
artifactFile: artifactManifestPath,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: arrived,
expectedVariants: 3,
cwd: tmp,
});
}
});
+68 -1
View File
@@ -5,7 +5,7 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
@@ -389,4 +389,71 @@ const title = 'Test';
const afterRemove = readFileSync(file, 'utf-8');
assert.equal(afterRemove, original, 'CRLF file should round-trip cleanly after remove');
});
it('uses an idempotent dev-only client plugin for a Nuxt 4 app directory', () => {
const configSource = `export default defineNuxtConfig({\n devtools: { enabled: false },\n});\n`;
const appSource = `<template>\n <NuxtPage />\n</template>\n`;
writeFileSync(join(tmp, 'nuxt.config.ts'), configSource);
mkdirSync(join(tmp, 'app'), { recursive: true });
writeFileSync(join(tmp, 'app', 'app.vue'), appSource);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['app/app.vue'],
insertBefore: '</template>',
commentSyntax: 'html',
}));
const first = runInject(tmp, cfgPath, ['--port', '8400']);
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
const firstPlugin = readFileSync(pluginPath, 'utf-8');
assert.equal(first.ok, true);
assert.equal(first.adapter, 'nuxt');
assert.equal(first.results[0].file, 'app/plugins/impeccable-live.client.ts');
assert.equal(first.results[0].changed, true);
assert.match(firstPlugin, /if \(!import\.meta\.dev/);
assert.match(firstPlugin, /data-impeccable-live-nuxt/);
assert.match(firstPlugin, /localhost:8400\/live\.js/);
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource, 'Nuxt config remains user-owned');
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource, 'app.vue remains user-owned');
const second = runInject(tmp, cfgPath, ['--port', '8400']);
assert.equal(second.ok, true);
assert.equal(second.results[0].changed, false, 'same-port reinjection is byte-idempotent');
assert.equal(readFileSync(pluginPath, 'utf-8'), firstPlugin);
const moved = runInject(tmp, cfgPath, ['--port', '8401']);
assert.equal(moved.ok, true);
assert.equal(moved.results[0].changed, true);
assert.match(readFileSync(pluginPath, 'utf-8'), /localhost:8401\/live\.js/);
assert.doesNotMatch(readFileSync(pluginPath, 'utf-8'), /localhost:8400\/live\.js/);
const removed = runInject(tmp, cfgPath, ['--remove']);
assert.equal(removed.ok, true);
assert.equal(removed.adapter, 'nuxt');
assert.equal(removed.results[0].removed, true);
assert.equal(existsSync(pluginPath), false);
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource);
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource);
});
it('respects a literal Nuxt srcDir and never overwrites a user plugin', () => {
writeFileSync(join(tmp, 'nuxt.config.ts'), `export default defineNuxtConfig({ srcDir: 'client/' });\n`);
mkdirSync(join(tmp, 'client', 'plugins'), { recursive: true });
const pluginPath = join(tmp, 'client', 'plugins', 'impeccable-live.client.ts');
const userPlugin = `export default defineNuxtPlugin(() => {});\n`;
writeFileSync(pluginPath, userPlugin);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['client/app.vue'],
insertBefore: '</template>',
commentSyntax: 'html',
}));
const result = runInject(tmp, cfgPath, ['--port', '8400']);
assert.equal(result.ok, false);
assert.equal(result.adapter, 'nuxt');
assert.equal(result.results[0].error, 'nuxt_plugin_conflict');
assert.equal(readFileSync(pluginPath, 'utf-8'), userPlugin);
});
});
+9
View File
@@ -25,6 +25,15 @@ describe('live-poll reply payloads', () => {
'event=live_poll.reply_data actor=agent operation=completion_ack risk=carbonize_flag_dropped_before_server_journal expected={"carbonize":true} actual=' + JSON.stringify(payload.data),
);
});
it('preserves the leased source event type when concurrent work shares a session id', () => {
const payload = buildPollReplyPayload('token-1', {
id: 'abc12345',
type: 'agent_done',
sourceEventType: 'accept',
});
assert.equal(payload.sourceEventType, 'accept');
});
});
describe('live-poll accept handling', () => {
+10
View File
@@ -129,6 +129,16 @@ describe('live reference authoring contract', () => {
/sandbox_permissions: "require_escalated"/,
'Codex-only sandbox guidance should not appear in Claude live reference',
);
assert.match(
codexLiveMd,
/Codex progressive override/,
'Codex live reference should progressively deliver the first reviewable variant',
);
assert.doesNotMatch(
claudeLiveMd,
/Codex progressive override|first-reviewable milestone/,
'Claude live reference should retain the atomic path without Codex-specific delivery instructions',
);
});
it('keeps live preview CSS guidance capability-mode driven', () => {
+155
View File
@@ -111,6 +111,31 @@ it('gitignores local Impeccable runtime artifacts', () => {
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
});
it('Stop Live removes Nuxt Vue preview modules and their generated root', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-live-nuxt-stop-'));
const generatedRoot = join(cwd, 'app/.impeccable-live');
mkdirSync(join(generatedRoot, 'session123'), { recursive: true });
writeFileSync(join(cwd, 'nuxt.config.ts'), 'export default defineNuxtConfig({});\n');
writeFileSync(join(generatedRoot, '__runtime.js'), 'export const runtime = true;\n');
writeFileSync(join(generatedRoot, 'session123', 'v1.vue'), '<template><h1>Preview</h1></template>\n');
let live;
try {
live = await startServer(8498, { cwd });
const exited = new Promise((resolve) => live.proc.once('exit', resolve));
await stopServer(live.port, live.token);
await Promise.race([
exited,
new Promise((_, reject) => setTimeout(() => reject(new Error('live server did not stop')), 2_000)),
]);
assert.equal(existsSync(join(generatedRoot, '__runtime.js')), false);
assert.equal(existsSync(generatedRoot), false);
} finally {
live?.proc?.kill();
rmSync(cwd, { recursive: true, force: true });
}
});
async function readSseUntil(reader, decoder, needle, maxReads = 12) {
let text = '';
for (let i = 0; i < maxReads; i++) {
@@ -2142,6 +2167,9 @@ colors: {}
assert.equal(event.id, 'a1b2c3d4');
assert.equal(event.action, 'bolder');
assert.equal(event.count, 2);
assert.equal(event.scaffoldAttempted, true);
assert.equal(event.scaffoldError, 'insufficient_locator');
assert.equal(Number.isFinite(event.generationReadyAt), true);
await fetch(`http://localhost:${server.port}/poll`, {
method: 'POST',
@@ -2187,6 +2215,24 @@ colors: {}
it('accepts checkpoint events without exposing them as agent poll work', async () => {
await drainPolls(server);
const partialRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
reason: 'browser_resumed',
revision: 1,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
}),
});
assert.equal(partialRes.status, 200);
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2195,8 +2241,10 @@ colors: {}
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
reason: 'variants_ready',
revision: 2,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 3,
visibleVariant: 2,
paramValues: { density: 'packed' },
@@ -2214,6 +2262,113 @@ colors: {}
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d7.snapshot.json'), 'utf-8'));
assert.equal(snapshot.visibleVariant, 2);
assert.deepEqual(snapshot.paramValues, { density: 'packed' });
assert.ok(snapshot.generationTimings.first_reviewable?.at);
assert.ok(snapshot.generationTimings.all_variants_ready?.at);
assert.ok(snapshot.generationTimings.first_reviewable.at <= snapshot.generationTimings.all_variants_ready.at);
const atomicRes = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3da',
phase: 'cycling',
reason: 'variants_ready',
revision: 1,
owner: 'browser-a',
expectedVariants: 3,
arrivedVariants: 3,
visibleVariant: 1,
}),
});
assert.equal(atomicRes.status, 200);
const atomicSnapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3da.snapshot.json'), 'utf-8'));
assert.ok(atomicSnapshot.generationTimings.first_reviewable?.at);
assert.equal(
atomicSnapshot.generationTimings.first_reviewable.at,
atomicSnapshot.generationTimings.all_variants_ready?.at,
'atomic delivery makes the first variant and full set reviewable together',
);
});
it('streams Svelte component checkpoints as progressive preview updates', async () => {
const controller = new AbortController();
const sseRes = await fetch(
`http://localhost:${server.port}/events?token=${server.token}`,
{ signal: controller.signal },
);
const reader = sseRes.body.getReader();
const decoder = new TextDecoder();
await reader.read(); // connected
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3de',
phase: 'cycling',
reason: 'variants_progress',
revision: 1,
owner: 'svelte-worker',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
previewMode: 'svelte-component',
previewFile: 'node_modules/.impeccable-live/a1b2c3de/manifest.json',
sourceFile: 'src/routes/+page.svelte',
}),
});
assert.equal(res.status, 200);
const { value } = await reader.read();
const message = decoder.decode(value);
assert.match(message, /"type":"variant_progress"/);
assert.match(message, /"arrivedVariants":1/);
assert.match(message, /"previewMode":"svelte-component"/);
controller.abort();
});
it('streams source checkpoints so no-HMR frameworks can review variant 1', async () => {
const controller = new AbortController();
const sseRes = await fetch(
`http://localhost:${server.port}/events?token=${server.token}`,
{ signal: controller.signal },
);
const reader = sseRes.body.getReader();
const decoder = new TextDecoder();
await reader.read(); // connected
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'checkpoint',
id: 'a1b2c3df',
phase: 'cycling',
reason: 'variants_progress',
revision: 1,
owner: 'source-worker',
expectedVariants: 3,
arrivedVariants: 1,
visibleVariant: 1,
previewMode: 'source',
previewFile: 'app/pages/index.vue',
sourceFile: 'app/pages/index.vue',
}),
});
assert.equal(res.status, 200);
const { value } = await reader.read();
const message = decoder.decode(value);
assert.match(message, /"type":"variant_progress"/);
assert.match(message, /"arrivedVariants":1/);
assert.match(message, /"previewMode":"source"/);
assert.match(message, /"previewFile":"app\/pages\/index.vue"/);
controller.abort();
});
it('redelivers an unacknowledged browser event after helper server restart', async () => {
+67
View File
@@ -62,6 +62,51 @@ describe('live-session-store', () => {
assert.equal(active[0].id, 'session-a');
});
it('tombstones generation on early accept and ignores late generation writes', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
store.appendEvent({
type: 'generate',
id: 'early-accept',
action: 'polish',
count: 3,
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
});
store.appendEvent({
type: 'checkpoint',
id: 'early-accept',
revision: 1,
phase: 'cycling',
arrivedVariants: 1,
visibleVariant: 1,
});
store.appendEvent({ type: 'accept', id: 'early-accept', variantId: '1' });
store.appendEvent({
type: 'checkpoint',
id: 'early-accept',
revision: 2,
phase: 'variants_ready',
arrivedVariants: 3,
visibleVariant: 3,
});
store.appendEvent({
type: 'agent_done',
id: 'early-accept',
file: 'src/App.jsx',
arrivedVariants: 3,
});
const snapshot = store.getSnapshot('early-accept');
assert.equal(snapshot.phase, 'accept_requested');
assert.equal(snapshot.generationCanceled, true);
assert.equal(snapshot.cancelReason, 'accept');
assert.equal(snapshot.arrivedVariants, 1);
assert.equal(snapshot.visibleVariant, 1);
assert.equal(
snapshot.diagnostics.some((entry) => entry.error === 'late_generation_event_ignored'),
true,
);
});
it('reports corrupted journal lines while preserving valid prior events', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
store.appendEvent({
@@ -284,4 +329,26 @@ describe('live-session-store', () => {
assert.equal(migratedSnapshot.expectedVariants, 2);
assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx');
});
it('records generation phase timings without replacing the workflow phase', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'phase-session' });
store.appendEvent({
type: 'generate',
id: 'phase-session',
count: 3,
element: { classes: ['hero'] },
});
store.appendEvent({
type: 'agent_phase',
id: 'phase-session',
phase: 'source_ready',
at: 1234,
durationMs: 42,
});
const snapshot = store.getSnapshot('phase-session');
assert.equal(snapshot.phase, 'generate_requested');
assert.equal(snapshot.generationPhase, 'source_ready');
assert.deepEqual(snapshot.generationTimings.source_ready, { at: 1234, durationMs: 42 });
});
});
+212
View File
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from '../skill/scripts/live/generation-publisher.mjs';
import {
inlineVueComponentAccept,
nuxtViteFsModulePath,
removeAllVueComponentSessions,
scaffoldVueComponentSession,
} from '../skill/scripts/live/vue-component.mjs';
describe('Nuxt Vue component preview', () => {
let tmp;
let source;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-vue-component-'));
source = join(tmp, 'app', 'pages', 'index.vue');
mkdirSync(join(tmp, 'app', 'pages'), { recursive: true });
writeFileSync(join(tmp, 'nuxt.config.ts'), 'export default defineNuxtConfig({ ssr: false });\n');
writeFileSync(source, [
'<template>',
' <main>',
' <h1 class="hero-title">Hello {{ user.name }}</h1>',
' </main>',
'</template>',
'',
'<style scoped>',
'.hero-title { font-size: 2rem; }',
'</style>',
'',
].join('\n'));
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('stages real Vue SFCs without rewriting the active route', () => {
const before = readFileSync(source, 'utf-8');
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
assert.equal(readFileSync(source, 'utf-8'), before);
assert.equal(result.manifest.previewMode, 'vue-component');
assert.equal(result.manifest.componentExtension, 'vue');
assert.match(result.manifestFile, /^app\/\.impeccable-live\/vue12345\/manifest\.json$/);
const variant = readFileSync(join(tmp, result.componentDir, 'v1.vue'), 'utf-8');
assert.match(variant, /<template>/);
assert.match(variant, /Hello \{\{ name \}\}/);
assert.equal(existsSync(join(tmp, 'app/.impeccable-live/__runtime.js')), true);
assert.equal(
result.manifest.runtimeModule,
nuxtViteFsModulePath(join(tmp, 'app/.impeccable-live/__runtime.js'), tmp),
);
assert.equal(
result.manifest.componentModuleBase,
nuxtViteFsModulePath(join(tmp, result.componentDir), tmp),
);
assert.match(result.manifest.runtimeModule, /^\/@fs\//);
assert.doesNotMatch(result.manifest.runtimeModule, /^\/app\//);
assert.match(result.manifest.componentModuleBase, /^\/@fs\//);
});
it('keeps Vite module URLs valid for literal Nuxt srcDir projects', () => {
writeFileSync(join(tmp, 'nuxt.config.ts'), "export default defineNuxtConfig({ srcDir: 'client/' });\n");
const clientSource = join(tmp, 'client', 'pages', 'index.vue');
mkdirSync(join(tmp, 'client', 'pages'), { recursive: true });
writeFileSync(clientSource, '<template><h1>Client app</h1></template>\n');
const result = scaffoldVueComponentSession({
id: 'clientsrc',
count: 1,
sourceFile: 'client/pages/index.vue',
sourceStartLine: 1,
sourceEndLine: 1,
originalLines: ['<h1>Client app</h1>'],
cwd: tmp,
});
assert.match(result.manifestFile, /^client\/\.impeccable-live\/clientsrc\/manifest\.json$/);
assert.match(result.manifest.runtimeModule, /^\/@fs\/.*\/client\/\.impeccable-live\/__runtime\.js$/);
assert.match(result.manifest.componentModuleBase, /^\/@fs\/.*\/client\/\.impeccable-live\/clientsrc$/);
});
it('accepts one generated SFC into clean Vue source and restores route expressions', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
'<script setup>',
"defineProps({ name: { default: '' } });",
'</script>',
'<template>',
' <h1 class="hero-title variant-one">Welcome {{ name }}</h1>',
'</template>',
'<style scoped>',
'.variant-one { letter-spacing: 0.02em; }',
'</style>',
'',
].join('\n'));
const accepted = inlineVueComponentAccept(result.manifest, 1, tmp);
assert.equal(accepted.handled, true);
const next = readFileSync(source, 'utf-8');
assert.match(next, /Welcome \{\{ user\.name \}\}/);
assert.match(next, /class="hero-title variant-one"|class="variant-one hero-title"/);
assert.match(next, /\.variant-one \{ letter-spacing: 0\.02em; \}/);
assert.doesNotMatch(next, /data-impeccable/);
assert.equal(existsSync(join(tmp, result.componentDir, 'manifest.json')), false);
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true, 'imported SFC remains until Live shutdown');
});
it('removes deferred SFCs, the shared runtime, and the generated root on Live shutdown', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 1,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
inlineVueComponentAccept(result.manifest, 1, tmp);
const root = join(tmp, 'app/.impeccable-live');
assert.equal(existsSync(join(root, '__runtime.js')), true);
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true);
removeAllVueComponentSessions(tmp);
assert.equal(existsSync(join(root, '__runtime.js')), false);
assert.equal(existsSync(root), false);
});
it('publishes manifest-last, preserves the route, and rejects late work after Accept', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'vue12345' });
store.appendEvent({
type: 'generate',
id: 'vue12345',
generationEpoch: 1,
count: 3,
action: 'polish',
element: { outerHTML: '<h1>Hello Paul</h1>' },
});
const routeBefore = readFileSync(source, 'utf-8');
const prepared = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
assert.equal(prepared.ok, true);
assert.equal(prepared.previewMode, 'vue-component');
const artifactManifest = JSON.parse(readFileSync(join(tmp, prepared.artifactFile), 'utf-8'));
artifactManifest.arrivedVariants = 1;
writeFileSync(join(tmp, prepared.artifactFile), JSON.stringify(artifactManifest, null, 2) + '\n');
writeFileSync(join(tmp, prepared.componentDir, 'v1.vue'), '<template><h1>First</h1></template>\n');
const published = publishGenerationArtifact({
id: 'vue12345',
epoch: prepared.epoch,
sourceFile: result.manifestFile,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(published.ok, true);
assert.equal(published.previewMode, 'vue-component');
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1);
const late = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
store.appendEvent({ type: 'accept', id: 'vue12345', variantId: '1' });
const rejected = publishGenerationArtifact({
id: 'vue12345',
epoch: late.epoch,
sourceFile: result.manifestFile,
artifactFile: late.artifactFile,
expectedSourceHash: late.expectedSourceHash,
arrivedVariants: 2,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(rejected.ok, false);
assert.equal(rejected.error, 'stale_generation_epoch');
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
});
});
+28
View File
@@ -780,6 +780,34 @@ export default function App() {
assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
});
it('refuses multiple dynamic source branches when rendered text cannot identify one', () => {
const astro = `---
const results = [{ title: 'Result 01' }, { title: 'Result 02' }];
---
<main>
<article class="result-card"><h2>{results[0].title}</h2></article>
<article class="result-card"><h2>{results[1].title}</h2></article>
</main>`;
const file = join(tmp, 'Results.astro');
writeFileSync(file, astro);
let errPayload;
try {
execSync(
`node skill/scripts/live-wrap.mjs --id dyn2 --count 3 --classes "result-card" --tag "article" --text "Result 02 rendered body" --file "${file}"`,
{ cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' },
);
assert.fail('Should have refused an unsafe first-match fallback');
} catch (err) {
errPayload = JSON.parse(err.stderr.toString().trim());
}
assert.equal(errPayload.error, 'element_ambiguous');
assert.equal(errPayload.reason, 'rendered_text_not_in_source');
assert.equal(errPayload.candidates.length, 2);
assert.doesNotMatch(readFileSync(file, 'utf-8'), /impeccable-variants-start/);
});
it('errors with element_ambiguous when --text matches multiple identical branches', () => {
// Two <aside className="card"> with truly identical body text. --text
// can't pick a winner — wrap should refuse rather than silently land.