chore: update vendored skills and record provenance
humanizer 2.2.0 -> 2.9.1 (blader/humanizer, MIT): adds a Voice Calibration section and a passive-voice pattern, reworks negative parallelisms and em dashes. Version moved to metadata.version upstream. impeccable 3.6.0 -> 4.0.4 (pbakaus/impeccable, Apache-2.0): the repo tags the skill and the npm CLI separately, so 3.6.0 was a real release and npm's 3.5.0 was never the comparison. Adds native-platform reference briefs. Each now carries an UPSTREAM file; bin/check-vendored.sh reports drift.
This commit is contained in:
@@ -21,21 +21,27 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
readLiveBrowserScriptParts,
|
||||
resolveLiveBrowserScriptParts,
|
||||
} from './live/browser-script-parts.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { createLiveSessionStore, GENERATION_FENCED_PHASES } from './live/session-store.mjs';
|
||||
import { runGenerationPreflight } from './live/generation-preflight.mjs';
|
||||
import { validateEvent } from './live/event-validation.mjs';
|
||||
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
LIVE_COMMANDS,
|
||||
VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
|
||||
} from './live/vocabulary.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -48,17 +54,54 @@ import {
|
||||
} from './live/manual-apply.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
bumpSvelteComponentPreviewRevision,
|
||||
compileCheckVariants,
|
||||
removeAllSvelteComponentSessions,
|
||||
sweepInactiveSvelteComponentSessions,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
// Anchor the whole process on the live roots manifest before anything derives
|
||||
// a path from cwd. A server started from the wrong directory re-roots itself
|
||||
// onto the appRoot the boot decided on instead of minting a second project.
|
||||
const LIVE_ROOTS = enterLiveRoot(process.cwd());
|
||||
|
||||
// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
|
||||
// that outlives an `impeccable document` run (or a context file created after
|
||||
// boot) reports current truth instead of a boot-time snapshot. The roots
|
||||
// manifest wins when the ambient resolution misses (nested app inheriting
|
||||
// repo-level context files).
|
||||
function resolveProjectContext() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
const designPath = ctx.designPath
|
||||
? path.resolve(process.cwd(), ctx.designPath)
|
||||
: (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
|
||||
const hasProduct = ctx.hasProduct
|
||||
|| !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
|
||||
return {
|
||||
...ctx,
|
||||
hasProduct,
|
||||
hasDesign: !!designPath,
|
||||
resolvedDesignPath: designPath,
|
||||
contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
|
||||
designContextDir: ctx.designContextDir
|
||||
|| (designPath ? path.dirname(designPath) : null),
|
||||
};
|
||||
}
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
// The browser events allowed to mint a NEW session journal. `generate` starts
|
||||
// a variant session at Go; `steer` mints its own request id. Every other
|
||||
// id-carrying event must land on an existing session (see the unknown_session
|
||||
// gate in the /events handler).
|
||||
const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']);
|
||||
// The browser checkpoints for several unrelated reasons (see checkpointPayload
|
||||
// in live-browser.js). Only these two report that variant availability changed,
|
||||
// and only they may drive variant_progress / the *_reviewable phases.
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,7 +182,16 @@ function chatAgentLikelyActive() {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
if (!event) return;
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
const duplicate = event.id && state.pendingEvents.some((entry) => (
|
||||
entry.event?.id === event.id
|
||||
&& entry.event?.type === event.type
|
||||
&& (event.type !== 'variant_mount_failed' || entry.event?.variant === event.variant)
|
||||
));
|
||||
if (duplicate) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
|
||||
flushPendingPolls();
|
||||
}
|
||||
@@ -151,29 +203,207 @@ function restorePendingEventsFromStore() {
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
for (const entry of state.pendingEvents) {
|
||||
if (entry.leaseUntil && entry.leaseUntil > now) continue;
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
function findAvailablePendingEvent(now = Date.now(), types = null) {
|
||||
return selectAvailablePendingEvent(state.pendingEvents, { now, types });
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
async function leaseEvent(entry, leaseMs) {
|
||||
// Claim the entry before awaiting anything. prepareGenerateEventForLease
|
||||
// yields to the event loop, and selectAvailablePendingEvent only skips
|
||||
// entries whose lease is in the future — an unclaimed entry would be handed
|
||||
// to a second poll in that window and generated twice.
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
await prepareGenerateEventForLease(entry);
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
// Re-stamp so the lease window starts when the agent actually receives the
|
||||
// work, not when scaffolding began.
|
||||
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 });
|
||||
}
|
||||
|
||||
async 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 = await 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
* caller can re-broadcast it; the browser's done handler is idempotent and
|
||||
* falls back to injecting variants from source.
|
||||
*
|
||||
* Keys on the store's monotone `generationCompletedAt`, not `phase` — the
|
||||
* behind checkpoint itself regresses `phase` to `generating`, and a browser
|
||||
* that misses the redelivered `done` too (another reload) must still trigger
|
||||
* redelivery from its next checkpoint.
|
||||
*/
|
||||
function detectMissedGenerationCompletion(event) {
|
||||
if (!event?.id || event.type !== 'checkpoint') return null;
|
||||
if (event.phase !== 'generating') return null;
|
||||
if (!variantCountLooksBehind(event.arrivedVariants, event.expectedVariants)) return null;
|
||||
if (!state.sessionStore) return null;
|
||||
let snapshot = null;
|
||||
try {
|
||||
snapshot = state.sessionStore.getSnapshot(event.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return missedCompletionFromSnapshot(snapshot);
|
||||
}
|
||||
|
||||
function variantCountLooksBehind(arrivedValue, expectedValue) {
|
||||
const arrived = Number(arrivedValue) || 0;
|
||||
const expected = Number(expectedValue) || 0;
|
||||
return arrived <= 0 || (expected > 0 && arrived < expected);
|
||||
}
|
||||
|
||||
function missedCompletionFromSnapshot(snapshot) {
|
||||
if (!snapshot?.id || !snapshot.generationCompletedAt) return null;
|
||||
if (snapshot.generationCanceled) return null;
|
||||
// Accept/discard already underway: the browser is no longer waiting on
|
||||
// generation, and a late `done` there would collide with teardown.
|
||||
if (GENERATION_FENCED_PHASES.has(snapshot.phase)) return null;
|
||||
const file = snapshot.sourceFile || snapshot.previewFile;
|
||||
if (!file) return null;
|
||||
return {
|
||||
type: 'done',
|
||||
id: snapshot.id,
|
||||
file,
|
||||
sourceFile: snapshot.sourceFile || undefined,
|
||||
previewFile: snapshot.previewFile || undefined,
|
||||
previewMode: snapshot.previewMode || undefined,
|
||||
redelivered: true,
|
||||
};
|
||||
}
|
||||
|
||||
function recordGenerationCheckpoint(event) {
|
||||
if (!event?.id || event.type !== 'checkpoint') return;
|
||||
if (generationIsFenced(event.id)) return;
|
||||
// Only checkpoints that report a change in variant availability are
|
||||
// generation progress. The browser also checkpoints for durability on Tune
|
||||
// slider drags, resumes, and anchor recovery; treating those as progress
|
||||
// echoed `variant_progress` straight back to the browser that sent it, which
|
||||
// remounts the component preview mid-drag (reverting the user's live param
|
||||
// edit and detaching the popover's element), and permanently latched the
|
||||
// *_reviewable phases from the wrong trigger, corrupting generation timings.
|
||||
if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) 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,
|
||||
publicationKind: event.publicationKind || 'variants',
|
||||
});
|
||||
}
|
||||
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 >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
|
||||
recordAgentPhase(event.id, 'second_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);
|
||||
@@ -182,9 +412,39 @@ function acknowledgePendingEvent(id) {
|
||||
return acknowledged;
|
||||
}
|
||||
|
||||
function findPendingEventById(id) {
|
||||
function releasePendingEvent(id, sourceEventType) {
|
||||
const entry = state.pendingEvents.find((item) => (
|
||||
item.event?.id === id
|
||||
&& (!sourceEventType || item.event?.type === sourceEventType)
|
||||
));
|
||||
if (!entry) return null;
|
||||
entry.leaseUntil = 0;
|
||||
scheduleLeaseFlush();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function retirePendingGeneration(id) {
|
||||
if (!id) return 0;
|
||||
let retired = 0;
|
||||
for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
|
||||
const event = state.pendingEvents[index]?.event;
|
||||
if (event?.id !== id || event.type !== 'generate') continue;
|
||||
state.pendingEvents.splice(index, 1);
|
||||
retired += 1;
|
||||
}
|
||||
if (retired > 0) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -193,7 +453,7 @@ function summarizePendingEventForStatus(entry) {
|
||||
const summary = {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leased: isLeased(entry),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
};
|
||||
if (event.type === 'manual_edit_apply') {
|
||||
@@ -219,7 +479,18 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
arrivedVariants: snapshot.arrivedVariants ?? 0,
|
||||
visibleVariant: snapshot.visibleVariant ?? null,
|
||||
checkpointRevision: snapshot.checkpointRevision ?? 0,
|
||||
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
|
||||
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
|
||||
paramValues: snapshot.paramValues || {},
|
||||
generationPhase: snapshot.generationPhase ?? null,
|
||||
generationCompletedAt: snapshot.generationCompletedAt ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
// Render truth, so a browser with no localStorage can rehydrate to the
|
||||
// same comparison the server already knows about.
|
||||
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
|
||||
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
|
||||
renderState: snapshot.renderState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,24 +535,46 @@ function scheduleLeaseFlush() {
|
||||
function flushPendingPolls() {
|
||||
let changed = false;
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
let pollIndex = -1;
|
||||
let entry = null;
|
||||
for (let index = 0; index < state.pendingPolls.length; index += 1) {
|
||||
const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
|
||||
if (!candidate) continue;
|
||||
pollIndex = index;
|
||||
entry = candidate;
|
||||
break;
|
||||
}
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
const [poll] = state.pendingPolls.splice(pollIndex, 1);
|
||||
// leaseEvent is async (it may scaffold source), but it claims the entry
|
||||
// synchronously, so the next loop iteration will not re-select it. Resolve
|
||||
// the poll when the lease settles rather than awaiting here, so one slow
|
||||
// scaffold never delays the other parked polls. On the exceptional failure
|
||||
// path, answer `timeout` so the agent re-polls; the claim stays until the
|
||||
// lease expires, which keeps a deterministic failure from hot-looping.
|
||||
leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
|
||||
console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
poll.resolve({ type: 'timeout' });
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
if (changed) broadcastAgentPollingIfChanged();
|
||||
}
|
||||
|
||||
function isLeased(entry) {
|
||||
return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
|
||||
}
|
||||
|
||||
function agentPollingConnected() {
|
||||
const now = Date.now();
|
||||
return state.pendingPolls.length > 0
|
||||
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
|
||||
// A leased event only proves that a poll returned once. The foreground task
|
||||
// may have ended immediately afterward, so only an actively waiting poll is
|
||||
// evidence that steering can wake the task right now.
|
||||
return state.pendingPolls.length > 0;
|
||||
}
|
||||
|
||||
function broadcastAgentPollingIfChanged() {
|
||||
@@ -371,23 +664,44 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!resolveProjectContext().hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
try { return fs.statSync(filePath); } catch { return null; }
|
||||
}
|
||||
|
||||
// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a
|
||||
// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com`
|
||||
// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback.
|
||||
function isLoopbackOrigin(origin) {
|
||||
if (typeof origin !== 'string' || origin.length === 0) return false;
|
||||
let parsed;
|
||||
try { parsed = new URL(origin); } catch { return false; }
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
}
|
||||
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
|
||||
// loopback origin, always paired with `Vary: Origin` so an intermediary
|
||||
// cache never serves a response authorized for one origin to another. A
|
||||
// remote page (e.g. https://evil.example probing the port from a tab open
|
||||
// on the same machine) gets no Access-Control-Allow-Origin, so its
|
||||
// JS-initiated fetch cannot read any response. Requests with no Origin
|
||||
// header (script tags, curl, the agent's own fetches) are not subject to
|
||||
// CORS and keep working; no ACAO header is needed for them.
|
||||
const origin = req.headers.origin;
|
||||
if (origin && isLoopbackOrigin(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
@@ -396,6 +710,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
|
||||
// --- Scripts ---
|
||||
if (p === '/live.js') {
|
||||
// Token-gated: the script body embeds state.token, which unlocks every
|
||||
// token-guarded route. Serving it unauthenticated let any local page read
|
||||
// the token and drive the session. The injected <script src> carries
|
||||
// `?token=...` (see live-inject.mjs). A missing/wrong token → 401.
|
||||
if (url.searchParams.get('token') !== state.token) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
||||
res.end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
// Re-read from disk each request so edits to live-browser.js land on
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
@@ -412,6 +735,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
appRoot: process.cwd(),
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
@@ -549,8 +874,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const projectContext = resolveProjectContext();
|
||||
const mdPath = projectContext.resolvedDesignPath;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -602,7 +928,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
@@ -686,6 +1018,30 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
owner: typeof msg.owner === 'string' ? msg.owner : undefined,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
// Only the events that START a session may create its journal.
|
||||
// Everything else (checkpoints, mount acks, accept/discard) must
|
||||
// reference a session THIS store already knows: appendEvent creates a
|
||||
// journal for any id it is handed, so without this gate a browser
|
||||
// resuming another project's session from per-origin storage (two
|
||||
// apps sharing a localhost port) materializes a ghost session here
|
||||
// that keeps reattaching after every discard.
|
||||
if (msg.id && state.sessionStore
|
||||
&& !SESSION_CREATING_EVENT_TYPES.has(msg.type)
|
||||
&& !state.sessionStore.has(msg.id)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'unknown_session', id: msg.id }));
|
||||
return;
|
||||
}
|
||||
const missedCompletion = detectMissedGenerationCompletion(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
@@ -695,10 +1051,33 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'accept' || msg.type === 'discard') {
|
||||
retirePendingGeneration(msg.id);
|
||||
}
|
||||
recordGenerationCheckpoint(msg);
|
||||
if (missedCompletion) broadcast(missedCompletion);
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
if (msg.type !== 'checkpoint') {
|
||||
// An ORPHANED discard is the browser reporting that the session's
|
||||
// wrapper no longer exists in source (edited or regenerated away).
|
||||
// There is no cleanup for an agent to perform, and asking one to run
|
||||
// the normal discard flow would just fail against the missing
|
||||
// scaffolding, so the server terminalizes the session itself and the
|
||||
// event stays out of the poll queue.
|
||||
const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true;
|
||||
if (orphanedDiscard && state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true });
|
||||
} catch { /* the discard_requested phase already left the resumable set */ }
|
||||
}
|
||||
// `variant_mounted` is the happy path: it is journaled above so the
|
||||
// snapshot carries render truth, but there is nothing for the agent to
|
||||
// do about it, so it stays out of the poll queue and off the SSE bus.
|
||||
// `variant_mount_failed` is the opposite: the agent published something
|
||||
// the browser could not render, and only the agent can fix it, so it
|
||||
// goes to the queue as a first-class event.
|
||||
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted' && !orphanedDiscard) {
|
||||
enqueueEvent(msg);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -735,6 +1114,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
// Agent poll endpoints (unchanged from WS version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePollTypes(value) {
|
||||
if (!value) return null;
|
||||
const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
|
||||
return types.length > 0 ? new Set(types) : null;
|
||||
}
|
||||
|
||||
function handlePollGet(req, res, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) {
|
||||
@@ -745,13 +1130,25 @@ function handlePollGet(req, res, url) {
|
||||
state.lastPollAt = Date.now();
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
const types = parsePollTypes(url.searchParams.get('types'));
|
||||
const available = findAvailablePendingEvent(Date.now(), types);
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
// Do not await inline: leaseEvent may scaffold source, and this handler runs
|
||||
// on the server's only thread. The client can disconnect during that window,
|
||||
// so check the socket before replying.
|
||||
leaseEvent(available, leaseMs).then((event) => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}, (error) => {
|
||||
console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const poll = { resolve, leaseMs, types };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
@@ -780,12 +1177,16 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
if (!file || typeof file !== 'string') return { 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;
|
||||
const metadataFile = normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('.impeccable/live/previews/')
|
||||
&& !metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')) return base;
|
||||
|
||||
let full;
|
||||
try {
|
||||
full = path.resolve(process.cwd(), normalized);
|
||||
full = path.resolve(process.cwd(), metadataFile);
|
||||
const rel = path.relative(process.cwd(), full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
|
||||
} catch {
|
||||
@@ -794,18 +1195,54 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
|
||||
if (manifest?.previewMode !== 'svelte-component'
|
||||
|| !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 entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
|
||||
const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
|
||||
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
|
||||
if (msg.type === 'complete') {
|
||||
if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
|
||||
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.
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') {
|
||||
// A `done` reply to a mount failure is the republish that unblocks the
|
||||
// browser. Without this the ack would look for a `generate` that was
|
||||
// already retired, the mount-failure event would stay queued, and the next
|
||||
// poll would hand the same failure back to the agent forever.
|
||||
if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
|
||||
return 'generate';
|
||||
}
|
||||
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
|
||||
// never sets sourceEventType on it (the poller is a fresh process that cannot
|
||||
// know what it leased). Returning undefined here makes acknowledgePendingEvent
|
||||
// match *any* event for this id: a stale generate worker's failure silently
|
||||
// consumed the user's queued Accept, which was then never delivered to any
|
||||
// agent and left the browser in SAVING forever. Attribute the failure to the
|
||||
// event this agent actually holds a lease on, and otherwise to `generate` —
|
||||
// never to a wildcard. If that generate was already retired by an Accept, the
|
||||
// ack simply finds no match, which is the correct outcome for a stale reply.
|
||||
if (msg.type === 'error') {
|
||||
return entriesForId.find(isLeased)?.event?.type || 'generate';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
@@ -866,7 +1303,23 @@ 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);
|
||||
if (msg.type === 'retry') {
|
||||
const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
|
||||
if (!releasedEvent) {
|
||||
res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
|
||||
id: msg.id,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
flushPendingPolls();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, released: true }));
|
||||
return;
|
||||
}
|
||||
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' });
|
||||
@@ -876,7 +1329,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) {
|
||||
@@ -899,6 +1352,30 @@ function handlePollPost(req, res) {
|
||||
return;
|
||||
}
|
||||
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
|
||||
// A publish (done reply carrying a component manifest) snapshots the
|
||||
// variant files into a fresh revision dir before the browser is told:
|
||||
// the import path changes every publish, so no transform cache can pin a
|
||||
// stale compile of a republished module (node_modules is unwatched).
|
||||
// Broken variants are bounced HERE, before the browser imports anything:
|
||||
// a compile error that reaches the page is a red overlay in the user's
|
||||
// face; bounced at publish it is a private fix with file and line.
|
||||
if (replyFileMeta.previewMode === 'svelte-component'
|
||||
&& msg.id
|
||||
&& (msg.type === 'done' || !msg.type)) {
|
||||
let compileCheck = { ok: true, failures: [] };
|
||||
try { compileCheck = compileCheckVariants(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
if (!compileCheck.ok) {
|
||||
res.writeHead(422, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: 'variant_compile_failed',
|
||||
id: msg.id,
|
||||
failures: compileCheck.failures,
|
||||
_instructions: 'The publish was NOT delivered: the listed variant file(s) do not compile, so the browser never saw them. Fix each failure at the given file and line (the most common cause is a second top-level <style> element; Svelte allows exactly one, so merge all rules into the existing block), then send the same --reply done again.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
}
|
||||
if (state.sessionStore && msg.id && !skipJournalReply) {
|
||||
try {
|
||||
const eventType = msg.type === 'steer_done'
|
||||
@@ -970,6 +1447,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A previous run that died without its shutdown hook leaves preview component
|
||||
* dirs behind. Drop the ones whose session the store no longer considers
|
||||
* active; anything still active is mid-generation and must survive a restart.
|
||||
*/
|
||||
function sweepOrphanSvelteComponentSessionsOnStartup() {
|
||||
try {
|
||||
const activeIds = (state.sessionStore?.listActiveSessions() || [])
|
||||
.map((snapshot) => snapshot?.id)
|
||||
.filter(Boolean);
|
||||
const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
|
||||
if (result.removed.length > 0 || result.removedRoot) {
|
||||
console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept receipts are a short-lived idempotency record for a single accept.
|
||||
// Nothing reads one after the session that wrote it is gone, so they only need
|
||||
// to outlive a crash-and-retry window.
|
||||
const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function sweepStaleAcceptReceiptsOnStartup() {
|
||||
try {
|
||||
const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
|
||||
let removed = 0;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(file).mtimeMs >= cutoff) continue;
|
||||
fs.rmSync(file, { force: true });
|
||||
removed++;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
try {
|
||||
const result = applyDeferredSvelteComponentAccepts(process.cwd());
|
||||
@@ -1080,7 +1602,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);
|
||||
@@ -1106,6 +1631,8 @@ manualApply.rollbackTransaction({
|
||||
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
|
||||
});
|
||||
applyLegacyDeferredAcceptsOnStartup();
|
||||
sweepOrphanSvelteComponentSessionsOnStartup();
|
||||
sweepStaleAcceptReceiptsOnStartup();
|
||||
restorePendingEventsFromStore();
|
||||
manualApply.pruneStaleEvidence();
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
|
||||
Reference in New Issue
Block a user