mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
Improve Live polling responsiveness and reliability
Restore foreground/background polling as the primary harness architecture, add progressive publication and framework-safe previews, and harden quality and regression coverage. The experimental app-server runtime is intentionally excluded.\n\nPrepared with AI assistance under maintainer direction.
This commit is contained in:
+247
-21
@@ -16,15 +16,27 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { getLiveDir } from './lib/impeccable-paths.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';
|
||||
import {
|
||||
findSourceArtifactManifest,
|
||||
removeSourceArtifactSession,
|
||||
} from './live/source-artifact.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
@@ -65,6 +77,32 @@ Output (JSON):
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
|
||||
const requestedOperation = isDiscard ? 'discard' : 'accept';
|
||||
const priorReceipt = readAcceptReceipt(process.cwd(), id);
|
||||
if (priorReceipt) {
|
||||
const sameOperation = priorReceipt.operation === requestedOperation
|
||||
&& (isDiscard || String(priorReceipt.variantId) === String(variantNum));
|
||||
console.log(JSON.stringify(sameOperation
|
||||
? { ...priorReceipt.result, handled: true, alreadyApplied: true }
|
||||
: {
|
||||
handled: false,
|
||||
error: 'accept_receipt_conflict',
|
||||
priorOperation: priorReceipt.operation,
|
||||
priorVariantId: priorReceipt.variantId ?? null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const emitResult = (result) => {
|
||||
if (result?.handled !== false) {
|
||||
writeAcceptReceipt(process.cwd(), id, {
|
||||
operation: requestedOperation,
|
||||
variantId: isDiscard ? null : String(variantNum),
|
||||
result,
|
||||
});
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
};
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
try { paramValues = JSON.parse(paramValuesRaw); }
|
||||
@@ -72,34 +110,147 @@ Output (JSON):
|
||||
}
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
const sourceArtifactManifest = findSourceArtifactManifest(id, process.cwd());
|
||||
const found = sourceArtifactManifest ? null : 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 && !sourceArtifactManifest && !svelteComponentManifest && !vueComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (sourceArtifactManifest) {
|
||||
if (isDiscard) {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
console.log(JSON.stringify({
|
||||
removeSourceArtifactSession(id, process.cwd());
|
||||
emitResult({
|
||||
handled: true,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
file: sourceArtifactManifest.sourceFile,
|
||||
sourceFile: sourceArtifactManifest.sourceFile,
|
||||
previewMode: sourceArtifactManifest.previewMode,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
result = withSourceLockSync(
|
||||
sourceArtifactManifest.sourcePath,
|
||||
'accept:' + id,
|
||||
() => acceptSourceArtifact(sourceArtifactManifest, variantNum, paramValues),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = { handled: false, error: err.message };
|
||||
}
|
||||
if (result.handled !== false) {
|
||||
removeSourceArtifactSession(id, process.cwd());
|
||||
try {
|
||||
scrubManualEditsAgainstOriginalBlock(result.acceptedOriginalText || '', process.cwd(), pageUrl);
|
||||
} catch {}
|
||||
}
|
||||
delete result.acceptedOriginalText;
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + sourceArtifactManifest.sourceFile + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
emitResult({
|
||||
handled: result.handled !== false,
|
||||
file: sourceArtifactManifest.sourceFile,
|
||||
sourceFile: sourceArtifactManifest.sourceFile,
|
||||
previewMode: sourceArtifactManifest.previewMode,
|
||||
...result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
emitResult({
|
||||
...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,
|
||||
};
|
||||
}
|
||||
emitResult(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
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 };
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = {
|
||||
@@ -114,7 +265,7 @@ Output (JSON):
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
|
||||
emitResult({ handled: result.handled !== false, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +297,7 @@ Output (JSON):
|
||||
|
||||
if (isDiscard) {
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
emitResult({ handled: true, file: relFile, carbonize: false, ...result });
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
const acceptedOriginalText = result.acceptedOriginalText || '';
|
||||
@@ -167,7 +318,7 @@ Output (JSON):
|
||||
// Non-fatal; the buffer stays as-is and the user can discard later.
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
|
||||
emitResult({ handled: true, file: relFile, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +386,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 +488,24 @@ 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 built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
|
||||
if (built.handled === false) return built;
|
||||
fs.writeFileSync(targetFile, built.content, 'utf-8');
|
||||
return {
|
||||
carbonize: built.carbonize,
|
||||
acceptedOriginalText: built.acceptedOriginalText,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -375,9 +550,38 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
...replacement,
|
||||
...lines.slice(replaceRange.end + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
return {
|
||||
content: newLines.join('\n'),
|
||||
carbonize: needsCarbonize,
|
||||
acceptedOriginalText: originalContent.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
|
||||
function acceptSourceArtifact(manifest, variantNum, paramValues) {
|
||||
const source = fs.readFileSync(manifest.sourcePath, 'utf-8');
|
||||
const preview = fs.readFileSync(manifest.previewPath, 'utf-8');
|
||||
const original = String(manifest.originalSource || '');
|
||||
if (!original) return { handled: false, error: 'source_artifact_original_missing' };
|
||||
const first = source.indexOf(original);
|
||||
if (first < 0) return { handled: false, error: 'source_artifact_original_changed' };
|
||||
if (source.indexOf(original, first + original.length) >= 0) {
|
||||
return { handled: false, error: 'source_artifact_original_ambiguous' };
|
||||
}
|
||||
const wrapped = source.slice(0, first) + preview + source.slice(first + original.length);
|
||||
const built = buildAcceptedWrappedSource(
|
||||
manifest.id,
|
||||
variantNum,
|
||||
wrapped.split('\n'),
|
||||
manifest.sourcePath,
|
||||
paramValues,
|
||||
);
|
||||
if (built.handled === false) return built;
|
||||
fs.writeFileSync(manifest.sourcePath, built.content, 'utf-8');
|
||||
return {
|
||||
handled: true,
|
||||
carbonize: built.carbonize,
|
||||
acceptedOriginalText: built.acceptedOriginalText,
|
||||
};
|
||||
}
|
||||
|
||||
function readSourceShadowPreviewMeta(content, id) {
|
||||
@@ -798,6 +1002,28 @@ function searchDir(dir, query, seen, depth) {
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function acceptReceiptPath(cwd, id) {
|
||||
return path.join(getLiveDir(cwd), 'accept-receipts', `${id}.json`);
|
||||
}
|
||||
|
||||
function readAcceptReceipt(cwd, id) {
|
||||
try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeAcceptReceipt(cwd, id, receipt) {
|
||||
const file = acceptReceiptPath(cwd, id);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const value = {
|
||||
id,
|
||||
...receipt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(temporary, file);
|
||||
return value;
|
||||
}
|
||||
|
||||
function argVal(args, flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
||||
|
||||
+391
-101
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -38,6 +40,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/artifacts/',
|
||||
'.impeccable/live/accept-receipts/',
|
||||
'.impeccable/live/locks/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
@@ -46,10 +51,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 +123,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 +131,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 +162,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 +207,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 +234,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) {
|
||||
|
||||
+51
-15
@@ -27,7 +27,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
|
||||
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
@@ -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 = {}) {
|
||||
@@ -152,7 +152,14 @@ export async function waitForEventAck(base, token, eventId, {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
export async function fetchNextEvent(base, token, {
|
||||
totalDeadline,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs = PER_REQUEST_TIMEOUT_MS,
|
||||
leaseMs = DEFAULT_EVENT_LEASE_MS,
|
||||
signal,
|
||||
} = {}) {
|
||||
while (true) {
|
||||
if (totalDeadline && Date.now() >= totalDeadline) {
|
||||
return { type: 'timeout' };
|
||||
@@ -161,8 +168,15 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
const remaining = totalDeadline
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
|
||||
const slice = Math.min(Math.max(remaining, 1000), perRequestTimeoutMs);
|
||||
const query = new URLSearchParams({
|
||||
token,
|
||||
timeout: String(slice),
|
||||
leaseMs: String(leaseMs),
|
||||
});
|
||||
const normalizedTypes = normalizePollTypes(resolveTypes ? await resolveTypes() : types);
|
||||
if (normalizedTypes.length > 0) query.set('types', normalizedTypes.join(','));
|
||||
const res = await fetch(`${base}/poll?${query}`, { signal });
|
||||
|
||||
if (res.status === 401) {
|
||||
const err = new Error('Authentication failed. The server token may have changed.');
|
||||
@@ -184,7 +198,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
export async function augmentEventWithAcceptHandling(event, base, token, { deferReply = false } = {}) {
|
||||
if (event.type !== 'accept' && event.type !== 'discard') return event;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -202,11 +216,21 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
if (deferReply) {
|
||||
event._completionAck = { ok: false, deferred: true };
|
||||
return event;
|
||||
}
|
||||
await completeAcceptHandling(event, base, token);
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function completeAcceptHandling(event, base, token) {
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
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,
|
||||
@@ -217,7 +241,6 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -245,9 +268,9 @@ export function printPollEvent(event) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000, types, resolveTypes, perRequestTimeoutMs } = {}) {
|
||||
const deadline = Date.now() + totalTimeout;
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -258,11 +281,14 @@ export async function runPollStream(base, token, {
|
||||
ackTimeoutMs = 600_000,
|
||||
ackPollIntervalMs = 400,
|
||||
shouldContinue = () => true,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs,
|
||||
} = {}) {
|
||||
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
|
||||
|
||||
while (shouldContinue()) {
|
||||
const event = await fetchNextEvent(base, token);
|
||||
const event = await fetchNextEvent(base, token, { types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -322,14 +348,17 @@ Modes:
|
||||
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--types=A,B Lease only these event types
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
--help Show this help message
|
||||
|
||||
Harness note:
|
||||
Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
|
||||
--stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
|
||||
Default one-shot mode is the primary contract, including Codex foreground polling.
|
||||
Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.
|
||||
--stream is retained for harnesses with measured, reliable incremental stdout.
|
||||
Do not use --stream on Cursor.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -360,23 +389,30 @@ Harness note:
|
||||
}
|
||||
|
||||
const streamMode = args.includes('--stream');
|
||||
const typesArg = args.find((a) => a.startsWith('--types='));
|
||||
const types = normalizePollTypes(typesArg ? typesArg.slice('--types='.length) : null);
|
||||
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
|
||||
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
|
||||
|
||||
try {
|
||||
if (streamMode) {
|
||||
await runPollStream(base, info.token, { ackTimeoutMs });
|
||||
await runPollStream(base, info.token, { ackTimeoutMs, types });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
|
||||
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
|
||||
await runPollOnce(base, info.token, { totalTimeout });
|
||||
await runPollOnce(base, info.token, { totalTimeout, types });
|
||||
} catch (err) {
|
||||
handlePollError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePollTypes(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || '').split(',');
|
||||
return [...new Set(values.map((type) => String(type).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/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')),
|
||||
publicationKind: arg(args, '--kind'),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
+252
-26
@@ -29,7 +29,9 @@ 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 { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
@@ -51,6 +53,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
|
||||
@@ -156,29 +159,133 @@ 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) {
|
||||
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,
|
||||
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);
|
||||
@@ -187,9 +294,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;
|
||||
}
|
||||
|
||||
@@ -224,7 +361,13 @@ 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 || {},
|
||||
paramsPublished: snapshot.paramsPublished === true,
|
||||
generationPhase: snapshot.generationPhase ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,13 +412,21 @@ 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();
|
||||
const [poll] = state.pendingPolls.splice(pollIndex, 1);
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
changed = true;
|
||||
}
|
||||
@@ -284,9 +435,10 @@ function flushPendingPolls() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -689,6 +841,15 @@ 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;
|
||||
}
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
@@ -698,6 +859,10 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'accept' || msg.type === 'discard') {
|
||||
retirePendingGeneration(msg.id);
|
||||
}
|
||||
recordGenerationCheckpoint(msg);
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
@@ -738,6 +903,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) {
|
||||
@@ -748,13 +919,14 @@ 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)));
|
||||
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);
|
||||
@@ -783,12 +955,20 @@ 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 sourceArtifactPreview = normalized.includes('.impeccable/live/previews/')
|
||||
&& !normalized.endsWith('/manifest.json');
|
||||
const metadataFile = sourceArtifactPreview
|
||||
? normalized.slice(0, normalized.lastIndexOf('/') + 1) + 'manifest.json'
|
||||
: normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')
|
||||
&& !metadataFile.includes('.impeccable/live/previews/')) 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 {
|
||||
@@ -797,18 +977,40 @@ 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', 'source-artifact'].includes(manifest?.previewMode)
|
||||
|| !manifest.sourceFile) return base;
|
||||
const previewFile = manifest.previewMode === 'source-artifact'
|
||||
? String(manifest.previewFile || normalized).split(path.sep).join('/')
|
||||
: normalized;
|
||||
return {
|
||||
file: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
previewFile: normalized,
|
||||
previewMode: 'svelte-component',
|
||||
previewFile,
|
||||
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') {
|
||||
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.
|
||||
return msg.type === 'agent_done' || msg.type === 'done' ? 'generate' : undefined;
|
||||
}
|
||||
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
@@ -869,7 +1071,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' });
|
||||
@@ -879,7 +1097,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 +1189,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 +1306,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);
|
||||
|
||||
@@ -37,15 +37,19 @@ export async function statusCli() {
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: manualApply
|
||||
? manualApplyResumeHint(manualApply)
|
||||
: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
recoveryHint: recoveryHint({ server, manualApply }),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function recoveryHint({ server, manualApply }) {
|
||||
if (manualApply) return manualApplyResumeHint(manualApply);
|
||||
if (server) {
|
||||
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
|
||||
}
|
||||
return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
|
||||
}
|
||||
|
||||
function findPendingManualApply(server, activeSessions) {
|
||||
const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
|
||||
if (fromServer) return fromServer;
|
||||
|
||||
+93
-17
@@ -20,6 +20,15 @@ import {
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import {
|
||||
buildVueComponentCssAuthoring,
|
||||
scaffoldVueComponentSession,
|
||||
shouldUseVueComponentInjection,
|
||||
} from './live/vue-component.mjs';
|
||||
import {
|
||||
SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
scaffoldSourceArtifactSession,
|
||||
} from './live/source-artifact.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -50,6 +59,8 @@ Optional:
|
||||
--page-url URL Current page URL. Required when pending manual edits may
|
||||
affect the picked source block. Pending edits are filtered
|
||||
to this page so an edit on /a doesn't bleed into /b.
|
||||
--isolated Keep ordinary HTML/JSX/Astro source untouched during
|
||||
preview; write the wrapper to an isolated Live artifact.
|
||||
--help Show this help message
|
||||
|
||||
Output (JSON):
|
||||
@@ -68,6 +79,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
const isolated = args.includes('--isolated');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -160,11 +172,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
|
||||
@@ -207,6 +237,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// Strip only the COMMON minimum leading whitespace across the picked lines;
|
||||
// `deindentContent` on the accept side already mirrors this convention.
|
||||
let originalLines = lines.slice(startLine, endLine + 1);
|
||||
const sourceOriginalLines = [...originalLines];
|
||||
|
||||
// Buffer-aware "original" content: if the user has pending manual edits for
|
||||
// this page whose originalText appears in the picked source range, apply
|
||||
@@ -269,6 +300,9 @@ 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;
|
||||
const useSourceArtifact = isolated && !useFrameworkComponent;
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -287,8 +321,11 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// tuck both marker comments INSIDE it. accept/discard then expands its
|
||||
// replacement range to include the wrapper's `<div>` open / close lines
|
||||
// so the entire scaffold gets removed cleanly.
|
||||
const sourceArtifactAttr = useSourceArtifact
|
||||
? ' data-impeccable-preview="' + SOURCE_ARTIFACT_PREVIEW_MODE + '"'
|
||||
: '';
|
||||
const wrapperLines = isJsx ? [
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + sourceArtifactAttr + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
@@ -299,7 +336,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
indent + '</div>',
|
||||
] : [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + sourceArtifactAttr + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -315,6 +352,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let vueSession = null;
|
||||
let sourceArtifactSession = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -334,6 +373,38 @@ 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 if (useSourceArtifact) {
|
||||
sourceArtifactSession = scaffoldSourceArtifactSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
sourceStartLine: startLine + 1,
|
||||
sourceEndLine: endLine + 1,
|
||||
originalSource: sourceOriginalLines.join('\n'),
|
||||
previewContent: wrapperLines.join('\n'),
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), sourceArtifactSession.previewFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = wrapperLines.length + (originalLines.length - 1);
|
||||
insertLine = 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -356,15 +427,20 @@ 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;
|
||||
const previewMode = componentPreviewMode || (useSourceArtifact ? SOURCE_ARTIFACT_PREVIEW_MODE : 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 || useSourceArtifact ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
previewManifest: sourceArtifactSession?.manifestFile,
|
||||
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 +450,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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
* - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
|
||||
* - Enter the poll loop: `node live-poll.mjs`
|
||||
* - Enter the harness-native poll loop: `node live-poll.mjs`
|
||||
*
|
||||
* Usage:
|
||||
* node live.mjs # Prepare everything, print JSON, exit
|
||||
@@ -40,6 +40,7 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- Prepares the harness-native foreground/background poll loop
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
|
||||
@@ -118,6 +118,15 @@ export function validateEvent(msg) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'agent_phase':
|
||||
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
|
||||
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
|
||||
return 'agent_phase: missing or malformed phase';
|
||||
}
|
||||
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
|
||||
return 'agent_phase: durationMs must be a non-negative number';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -131,6 +140,12 @@ export function validateEvent(msg) {
|
||||
if (msg.message.length > 4000) return 'steer: message too long';
|
||||
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
|
||||
return null;
|
||||
case 'carbonize_cleanup':
|
||||
if (!isValidId(msg.id)) return 'carbonize_cleanup: missing or malformed id';
|
||||
if (!isValidId(msg.sessionId)) return 'carbonize_cleanup: missing or malformed sessionId';
|
||||
if (!msg.file || typeof msg.file !== 'string') return 'carbonize_cleanup: missing file';
|
||||
if (!isValidVariantId(String(msg.variantId))) return 'carbonize_cleanup: missing or malformed variantId';
|
||||
return null;
|
||||
default:
|
||||
return 'Unknown event type: ' + msg.type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { isolated = false } = {}) {
|
||||
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 && isolated) args.push('--isolated');
|
||||
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,
|
||||
isolated = false,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { isolated });
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
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';
|
||||
import {
|
||||
SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
findSourceArtifactManifest,
|
||||
} from './source-artifact.mjs';
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function reconcilePublishedSourceVariants({ current, candidate, priorArrived = 0 } = {}) {
|
||||
let reconciled = String(candidate || '');
|
||||
const stable = String(current || '');
|
||||
for (let variant = 1; variant <= Number(priorArrived || 0); variant += 1) {
|
||||
const stableBlock = extractVariantBlock(stable, variant);
|
||||
const candidateBlock = extractVariantBlock(reconciled, variant);
|
||||
if (!stableBlock || !candidateBlock) {
|
||||
return failure('published_variant_missing', { variant });
|
||||
}
|
||||
const offset = reconciled.indexOf(candidateBlock);
|
||||
reconciled = reconciled.slice(0, offset) + stableBlock + reconciled.slice(offset + candidateBlock.length);
|
||||
}
|
||||
return { ok: true, content: reconciled };
|
||||
}
|
||||
|
||||
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 sourceArtifactTarget = componentTarget ? null : readSourceArtifactPublicationTarget(requestedPath, cwd, id);
|
||||
if (sourceArtifactTarget?.error) return sourceArtifactTarget;
|
||||
const sourcePath = componentTarget?.sourcePath || sourceArtifactTarget?.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 artifactBase = sourceArtifactTarget
|
||||
? fs.readFileSync(sourceArtifactTarget.previewPath, 'utf-8')
|
||||
: source;
|
||||
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, artifactBase, 'utf-8');
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch: Number(snapshot.generationEpoch || 1),
|
||||
revision,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
...(sourceArtifactTarget ? {
|
||||
previewFile: relative(cwd, sourceArtifactTarget.previewPath),
|
||||
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
} : {}),
|
||||
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,
|
||||
publicationKind,
|
||||
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');
|
||||
if (publicationKind && !['variants', 'params'].includes(publicationKind)) {
|
||||
return failure('invalid_publication_kind');
|
||||
}
|
||||
|
||||
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 sourceArtifactTarget = componentTarget ? null : readSourceArtifactPublicationTarget(requestedPath, cwd, id);
|
||||
if (sourceArtifactTarget?.error) return sourceArtifactTarget;
|
||||
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 || sourceArtifactTarget?.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,
|
||||
publicationKind,
|
||||
store,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const stablePreview = sourceArtifactTarget
|
||||
? fs.readFileSync(sourceArtifactTarget.previewPath, 'utf-8')
|
||||
: current;
|
||||
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(stablePreview, variant);
|
||||
const artifactVariant = extractVariantBlock(artifact, variant);
|
||||
if (!currentVariant || !artifactVariant) {
|
||||
return failure('published_variant_missing', { variant });
|
||||
}
|
||||
if (sha256(withoutVariantParams(currentVariant)) !== sha256(withoutVariantParams(artifactVariant))) {
|
||||
return failure('published_variant_changed', { variant });
|
||||
}
|
||||
}
|
||||
const currentPreviewCss = extractPreviewCss(stablePreview, 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);
|
||||
const publishPath = sourceArtifactTarget?.previewPath || sourcePath;
|
||||
atomicReplace(publishPath, artifact);
|
||||
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
|
||||
store.appendEvent({
|
||||
type: 'variant_published',
|
||||
id,
|
||||
generationEpoch: epoch,
|
||||
revision,
|
||||
digest: artifactHash,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
...(sourceArtifactTarget ? {
|
||||
previewFile: relative(cwd, publishPath),
|
||||
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
} : {}),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
||||
publicationKind: publicationKind || 'variants',
|
||||
at: Date.now(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch,
|
||||
revision,
|
||||
digest: artifactHash,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
...(sourceArtifactTarget ? {
|
||||
previewFile: relative(cwd, publishPath),
|
||||
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
} : {}),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
||||
publicationKind: publicationKind || 'variants',
|
||||
};
|
||||
}, { 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,
|
||||
publicationKind,
|
||||
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,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
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,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
};
|
||||
}
|
||||
|
||||
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 readSourceArtifactPublicationTarget(requestedPath, cwd, id) {
|
||||
const manifest = findSourceArtifactManifest(id, cwd);
|
||||
if (!manifest) return null;
|
||||
if (path.resolve(requestedPath) !== path.resolve(manifest.previewPath)) {
|
||||
return failure('source_artifact_preview_mismatch');
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
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 withoutVariantParams(block) {
|
||||
return String(block || '').replace(
|
||||
/\sdata-impeccable-params=(?:"[^"]*"|'[^']*')/i,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function eventPriority(event = {}) {
|
||||
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
|
||||
if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
|
||||
if (event.type === 'generate') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
|
||||
const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
|
||||
return entries
|
||||
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
|
||||
.filter((entry) => !allowed || allowed.has(entry.event?.type))
|
||||
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -116,9 +126,21 @@ function baseSnapshot(id) {
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
browserCheckpointRevision: 0,
|
||||
publicationCheckpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
generationPhase: null,
|
||||
generationTimings: {},
|
||||
generationEpoch: 1,
|
||||
publishedRevision: 0,
|
||||
deliveredVariants: {},
|
||||
variantPlan: null,
|
||||
paramsPublished: false,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
@@ -158,6 +180,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
@@ -170,14 +195,81 @@ 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);
|
||||
next.variantPlan = null;
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variant_plan':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.variantPlan = event.plan ?? next.variantPlan;
|
||||
}
|
||||
break;
|
||||
case 'detector_waivers':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.detectorWaivers = [
|
||||
...(next.detectorWaivers || []),
|
||||
...(Array.isArray(event.waivers) ? event.waivers : []),
|
||||
];
|
||||
}
|
||||
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);
|
||||
if (event.publicationKind === 'params') next.paramsPublished = true;
|
||||
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,27 +286,45 @@ 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;
|
||||
}
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
{
|
||||
const revisionDomain = event.revisionDomain === 'publication'
|
||||
|| (event.reason === 'variants_progress' && !event.owner)
|
||||
? 'publication'
|
||||
: 'browser';
|
||||
const revisionField = revisionDomain === 'publication'
|
||||
? 'publicationCheckpointRevision'
|
||||
: 'browserCheckpointRevision';
|
||||
const currentRevision = next[revisionField]
|
||||
?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
|
||||
?? 0;
|
||||
if ((event.revision ?? 0) >= currentRevision) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next[revisionField] = event.revision ?? currentRevision;
|
||||
if (revisionDomain === 'browser') {
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
}
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
|
||||
}
|
||||
}
|
||||
break;
|
||||
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;
|
||||
@@ -232,6 +342,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'carbonize_cleanup':
|
||||
next.phase = 'carbonize_cleanup_requested';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'steer_done':
|
||||
next.phase = 'steer_done';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
@@ -243,6 +359,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 +379,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;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getLiveDir } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
export const SOURCE_ARTIFACT_PREVIEW_MODE = 'source-artifact';
|
||||
|
||||
export function scaffoldSourceArtifactSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
originalSource,
|
||||
previewContent,
|
||||
cwd = process.cwd(),
|
||||
} = {}) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) {
|
||||
throw new Error('invalid source artifact session id');
|
||||
}
|
||||
const sourcePath = resolveInside(cwd, sourceFile);
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) throw new Error('source artifact target missing');
|
||||
|
||||
const sessionDir = path.join(getLiveDir(cwd), 'previews', id);
|
||||
const extension = path.extname(sourcePath) || '.html';
|
||||
const previewPath = path.join(sessionDir, 'preview' + extension);
|
||||
const manifestPath = path.join(sessionDir, 'manifest.json');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
count: Number(count || 1),
|
||||
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
previewFile: relative(cwd, previewPath),
|
||||
sourceStartLine: Number(sourceStartLine),
|
||||
sourceEndLine: Number(sourceEndLine),
|
||||
originalSource: String(originalSource || ''),
|
||||
};
|
||||
fs.writeFileSync(previewPath, String(previewContent || ''), 'utf-8');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
return { ...manifest, manifestFile: relative(cwd, manifestPath), sessionDir: relative(cwd, sessionDir) };
|
||||
}
|
||||
|
||||
export function findSourceArtifactManifest(id, cwd = process.cwd()) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) return null;
|
||||
const manifestPath = path.join(getLiveDir(cwd), 'previews', id, 'manifest.json');
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { return null; }
|
||||
if (manifest?.id !== id || manifest?.previewMode !== SOURCE_ARTIFACT_PREVIEW_MODE) return null;
|
||||
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
||||
const previewPath = resolveInside(cwd, manifest.previewFile);
|
||||
if (!sourcePath || !previewPath || !fs.existsSync(sourcePath) || !fs.existsSync(previewPath)) return null;
|
||||
return { ...manifest, manifestPath, sourcePath, previewPath };
|
||||
}
|
||||
|
||||
export function removeSourceArtifactSession(id, cwd = process.cwd()) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) return false;
|
||||
const sessionDir = path.join(getLiveDir(cwd), 'previews', id);
|
||||
if (!fs.existsSync(sessionDir)) return false;
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveInside(cwd, value) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const root = path.resolve(cwd);
|
||||
const resolved = path.resolve(root, value);
|
||||
const rel = path.relative(root, resolved);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function relative(cwd, value) {
|
||||
return path.relative(cwd, value).split(path.sep).join('/');
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user