mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Preserve experimental Live app-server workstream
Snapshot the current app-server implementation, shared Live optimizations, generated harness output, and in-progress site work before restoring polling as the primary runtime path. Prepared with Codex assistance under maintainer direction.
This commit is contained in:
@@ -30,6 +30,10 @@ import {
|
||||
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;
|
||||
@@ -106,15 +110,60 @@ 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 && !vueComponentManifest) {
|
||||
if (!found && !sourceArtifactManifest && !svelteComponentManifest && !vueComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sourceArtifactManifest) {
|
||||
if (isDiscard) {
|
||||
removeSourceArtifactSession(id, process.cwd());
|
||||
emitResult({
|
||||
handled: true,
|
||||
file: sourceArtifactManifest.sourceFile,
|
||||
sourceFile: sourceArtifactManifest.sourceFile,
|
||||
previewMode: sourceArtifactManifest.previewMode,
|
||||
carbonize: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
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;
|
||||
@@ -447,6 +496,16 @@ function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
|
||||
}
|
||||
|
||||
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' };
|
||||
|
||||
@@ -491,9 +550,38 @@ function handleAcceptUnlocked(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) {
|
||||
|
||||
+188
-44
@@ -127,6 +127,8 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
let svelteRuntimePromise = null;
|
||||
let pendingSvelteComponentRetryObserver = null;
|
||||
@@ -135,6 +137,7 @@
|
||||
let currentPreviewMode = null;
|
||||
let recoveryWaitingForAnchor = false;
|
||||
let pickedAnchorSnapshot = null;
|
||||
let pickedAnchorViewportTop = null;
|
||||
let pendingVariantAnchorRetryObserver = null;
|
||||
let pendingAcceptedSession = null;
|
||||
let variantObserver = null;
|
||||
@@ -152,10 +155,12 @@
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockAnchorTop = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
|
||||
const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -1122,14 +1127,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function hideBar() {
|
||||
function hideBar(instant) {
|
||||
if (!barEl) return;
|
||||
const hideSeq = ++barHideSeq;
|
||||
stopVoice({ suppressSubmit: true });
|
||||
if (configureKind === 'insert') clearInsertPicking();
|
||||
barEl.style.opacity = '0';
|
||||
barEl.style.transform = 'translateY(6px)';
|
||||
setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250);
|
||||
barEl.style.transform = instant ? 'translateY(0)' : 'translateY(6px)';
|
||||
if (instant) barEl.style.display = 'none';
|
||||
else setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250);
|
||||
hideActionPicker();
|
||||
closeTunePopover();
|
||||
hideConfigureBarTooltip();
|
||||
@@ -1974,7 +1980,7 @@
|
||||
*/
|
||||
function setLiveState(next) {
|
||||
state = next;
|
||||
document.documentElement.dataset.impeccableLiveState = next;
|
||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
@@ -2585,10 +2591,12 @@
|
||||
if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
|
||||
row.appendChild(next);
|
||||
|
||||
// Tune chip - only when the visible variant exposes params
|
||||
// Tune chip stays visible while the deferred parameter phase is running,
|
||||
// then becomes interactive as soon as this variant exposes controls.
|
||||
const visParams = parseVariantParams(getVisibleVariantEl());
|
||||
const hasParams = visParams.length > 0;
|
||||
if (hasParams) {
|
||||
const paramsPending = !hasParams && (parameterGenerationState === 'pending' || parameterGenerationState === 'loading');
|
||||
if (hasParams || paramsPending) {
|
||||
const tune = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '4px 10px', borderRadius: '5px',
|
||||
@@ -2596,35 +2604,54 @@
|
||||
background: tuneOpen ? BP.accentSoft : 'transparent',
|
||||
color: tuneOpen ? BP.accent : BP.text,
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
cursor: paramsPending ? 'wait' : 'pointer',
|
||||
transition: 'color 0.12s ease, background 0.12s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
if (paramsPending) {
|
||||
const spinner = el('span', {
|
||||
width: '11px', height: '11px', borderRadius: '50%',
|
||||
border: '1.5px solid ' + BP.hairline,
|
||||
borderTopColor: BP.accent,
|
||||
animation: 'impeccable-spin 0.6s linear infinite',
|
||||
boxSizing: 'border-box', flexShrink: '0',
|
||||
});
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
tune.appendChild(spinner);
|
||||
} else {
|
||||
tune.innerHTML = TUNE_ICON_SVG;
|
||||
}
|
||||
const tuneLabel = document.createElement('span');
|
||||
tuneLabel.textContent = 'Tune';
|
||||
tune.appendChild(tuneLabel);
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
if (hasParams) {
|
||||
const tuneBadge = document.createElement('span');
|
||||
Object.assign(tuneBadge.style, {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: '16px', height: '16px', padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: tuneOpen ? C.brand : BP.hairline,
|
||||
color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
|
||||
fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
|
||||
lineHeight: '1',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
tuneBadge.textContent = String(visParams.length);
|
||||
tune.appendChild(tuneBadge);
|
||||
tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
|
||||
tune.addEventListener('mouseenter', () => {
|
||||
if (!tuneOpen) tune.style.background = BP.accentSoft;
|
||||
});
|
||||
tune.addEventListener('mouseleave', () => {
|
||||
if (!tuneOpen) tune.style.background = 'transparent';
|
||||
});
|
||||
tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
|
||||
} else {
|
||||
tune.disabled = true;
|
||||
tune.setAttribute('aria-label', 'Tune controls are still being prepared');
|
||||
tune.title = 'Tune controls are still being prepared';
|
||||
tune.style.opacity = '0.72';
|
||||
}
|
||||
tune.dataset.iceqTune = '1';
|
||||
row.appendChild(tune);
|
||||
}
|
||||
@@ -4774,6 +4801,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function mountedParameterCount() {
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) {
|
||||
return Object.values(svelteComponentSession.paramsByVariant || {})
|
||||
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
|
||||
}
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
|
||||
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
|
||||
}
|
||||
|
||||
function completeParameterPublication() {
|
||||
if (!currentSessionId) return;
|
||||
const ready = mountedParameterCount() > 0;
|
||||
parameterGenerationState = ready ? 'ready' : 'none';
|
||||
if (ready && parameterReadyAnnouncedSession !== currentSessionId) {
|
||||
parameterReadyAnnouncedSession = currentSessionId;
|
||||
showToast('Tune controls are ready.', 3000);
|
||||
}
|
||||
if (state === 'CYCLING') {
|
||||
refreshParamsPanel();
|
||||
showOrUpdateCyclingBar();
|
||||
}
|
||||
saveSession();
|
||||
}
|
||||
|
||||
function toggleTunePopover() {
|
||||
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
|
||||
if (tuneOpen) { closeTunePopover(); return; }
|
||||
@@ -4870,6 +4923,10 @@
|
||||
return mode === 'svelte-component' || mode === 'vue-component';
|
||||
}
|
||||
|
||||
function isSourceArtifactPreviewMode(mode) {
|
||||
return mode === 'source-artifact';
|
||||
}
|
||||
|
||||
function parseOriginalMarkupElement(originalMarkup) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString('<div id="impeccable-anchor">' + originalMarkup + '</div>', 'text/html');
|
||||
@@ -5417,6 +5474,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5502,6 +5560,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -5550,6 +5609,8 @@
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
currentSessionId = null;
|
||||
parameterGenerationState = 'idle';
|
||||
parameterReadyAnnouncedSession = null;
|
||||
expectedVariants = 0;
|
||||
arrivedVariants = 0;
|
||||
visibleVariant = 0;
|
||||
@@ -5569,7 +5630,9 @@
|
||||
injectSvelteComponentsFromManifest(filePath, sessionId);
|
||||
return;
|
||||
}
|
||||
rememberSessionFileMeta({ file: filePath });
|
||||
rememberSessionFileMeta(isSourceArtifactPreviewMode(currentPreviewMode)
|
||||
? { previewFile: filePath, previewMode: currentPreviewMode }
|
||||
: { file: filePath });
|
||||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||||
fetch(url)
|
||||
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
@@ -5651,6 +5714,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -5922,13 +5986,36 @@
|
||||
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
function showOriginalDuringDiscard(sessionId) {
|
||||
if (!sessionId) return;
|
||||
let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = DISCARD_STATE_STYLE_ID;
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
}
|
||||
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
|
||||
styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n'
|
||||
+ wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }';
|
||||
}
|
||||
|
||||
function resolveScrollLockAnchorTop() {
|
||||
const anchor = resolveBarAnchor();
|
||||
if (!anchor?.isConnected) return null;
|
||||
const top = anchor.getBoundingClientRect().top;
|
||||
return Number.isFinite(top) ? top : null;
|
||||
}
|
||||
|
||||
// Hold window.scrollY at a fixed value across DOM mutations inside the
|
||||
// session's wrapper (HMR patches, variant inserts, cycle swaps).
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
function startScrollLock(sessionId, initialTargetY, initialAnchorTop) {
|
||||
stopScrollLock();
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
scrollLockAnchorTop = typeof initialAnchorTop === 'number' && isFinite(initialAnchorTop)
|
||||
? initialAnchorTop
|
||||
: resolveScrollLockAnchorTop();
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
@@ -5952,6 +6039,17 @@
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetY == null) return;
|
||||
const anchor = resolveBarAnchor();
|
||||
if (anchor?.isConnected && typeof scrollLockAnchorTop === 'number' && isFinite(scrollLockAnchorTop)) {
|
||||
const anchorTop = anchor.getBoundingClientRect().top;
|
||||
const anchorDelta = anchorTop - scrollLockAnchorTop;
|
||||
if (Math.abs(anchorDelta) >= 0.5) {
|
||||
window.scrollTo({ top: window.scrollY + anchorDelta, left: window.scrollX, behavior: 'instant' });
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const before = window.scrollY;
|
||||
const delta = before - scrollLockTargetY;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
@@ -5996,6 +6094,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
scrollLockAnchorTop = resolveScrollLockAnchorTop();
|
||||
writeScrollY(scrollLockTargetY);
|
||||
};
|
||||
const markGesture = (why) => {
|
||||
@@ -6033,6 +6132,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
scrollLockAnchorTop = null;
|
||||
// NOTE: do NOT clear the persistent scroll key here. startScrollLock
|
||||
// calls us as a reset, and clearing the key would nuke the Go-time
|
||||
// scrollY that the next resume needs to read.
|
||||
@@ -6222,16 +6322,26 @@
|
||||
syncAgentPollingUi(!!msg.connected);
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && state === 'GENERATING') {
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
updateBarContent('generating');
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
if (msg.phase === 'parameters_ready' && parameterGenerationState !== 'ready') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
updateBarContent(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
saveSession();
|
||||
}
|
||||
break;
|
||||
case 'variant_progress':
|
||||
if (msg.id === currentSessionId) {
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if (isSourceArtifactPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
injectVariantsFromSource(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
@@ -6243,7 +6353,7 @@
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (arrivedVariants >= targetArrived) return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
@@ -6402,6 +6512,7 @@
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
revisionDomain: 'browser',
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
@@ -6427,6 +6538,7 @@
|
||||
type: 'checkpoint',
|
||||
id,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
revisionDomain: 'browser',
|
||||
owner: browserOwner,
|
||||
phase: 'steer',
|
||||
reason,
|
||||
@@ -6781,6 +6893,8 @@
|
||||
arrivedVariants = 0;
|
||||
visibleVariant = 0;
|
||||
generationPhase = 'queued';
|
||||
parameterGenerationState = 'pending';
|
||||
parameterReadyAnnouncedSession = null;
|
||||
resetSessionFileMeta();
|
||||
|
||||
// Flip to GENERATING immediately so the bar morphs without waiting on
|
||||
@@ -6790,6 +6904,7 @@
|
||||
const elForCapture = selectedElement;
|
||||
pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture);
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
pickedAnchorViewportTop = captureRect.top;
|
||||
const snapshot = {
|
||||
comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
|
||||
strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
|
||||
@@ -6821,7 +6936,7 @@
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
startScrollLock(currentSessionId);
|
||||
startScrollLock(currentSessionId, window.scrollY, pickedAnchorViewportTop);
|
||||
|
||||
captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
|
||||
}
|
||||
@@ -6857,12 +6972,15 @@
|
||||
arrivedVariants = 0;
|
||||
visibleVariant = 0;
|
||||
generationPhase = 'queued';
|
||||
parameterGenerationState = 'pending';
|
||||
parameterReadyAnnouncedSession = null;
|
||||
resetSessionFileMeta();
|
||||
selectedElement = placeholderElement;
|
||||
insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement);
|
||||
|
||||
const elForCapture = placeholderElement;
|
||||
const captureRect = elForCapture.getBoundingClientRect();
|
||||
pickedAnchorViewportTop = captureRect.top;
|
||||
const basePayload = {
|
||||
type: 'generate',
|
||||
mode: 'insert',
|
||||
@@ -6893,7 +7011,7 @@
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
startScrollLock(currentSessionId);
|
||||
startScrollLock(currentSessionId, window.scrollY, pickedAnchorViewportTop);
|
||||
captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
|
||||
}
|
||||
|
||||
@@ -7836,6 +7954,8 @@ void main() {
|
||||
hoveredElement = null;
|
||||
pagePickSkipClick = false;
|
||||
currentSessionId = null;
|
||||
parameterGenerationState = 'idle';
|
||||
parameterReadyAnnouncedSession = null;
|
||||
selectedAction = 'impeccable';
|
||||
pendingAcceptedSession = null;
|
||||
renderEditBadge('hidden');
|
||||
@@ -7870,7 +7990,7 @@ void main() {
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
cleanup({ restoreOriginal: true, instantChrome: true });
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
@@ -7892,6 +8012,7 @@ void main() {
|
||||
currentPreviewMode = null;
|
||||
recoveryWaitingForAnchor = false;
|
||||
pickedAnchorSnapshot = null;
|
||||
pickedAnchorViewportTop = null;
|
||||
}
|
||||
|
||||
function rememberSessionFileMeta(meta = {}) {
|
||||
@@ -7900,6 +8021,13 @@ void main() {
|
||||
const previewFile = normalizeSessionPath(meta.previewFile);
|
||||
const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null);
|
||||
|
||||
if (isSourceArtifactPreviewMode(previewMode)) {
|
||||
currentPreviewMode = previewMode;
|
||||
currentPreviewFile = previewFile || file || currentPreviewFile;
|
||||
currentSourceFile = sourceFile || currentSourceFile;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFrameworkComponentPreviewMode(previewMode) || isSvelteComponentManifestPath(file)) {
|
||||
currentPreviewMode = isFrameworkComponentPreviewMode(previewMode) ? previewMode : 'svelte-component';
|
||||
currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile);
|
||||
@@ -7917,12 +8045,16 @@ void main() {
|
||||
rememberSessionFileMeta(saved);
|
||||
if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder;
|
||||
if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor;
|
||||
if (Number.isFinite(saved.pickedAnchorViewportTop)) pickedAnchorViewportTop = saved.pickedAnchorViewportTop;
|
||||
if (saved.action) selectedAction = saved.action;
|
||||
if (saved.count) selectedCount = saved.count;
|
||||
if (saved.previewMode) currentPreviewMode = saved.previewMode;
|
||||
if (saved.paramValues && typeof saved.paramValues === 'object') {
|
||||
paramsCurrentValues = { ...saved.paramValues };
|
||||
}
|
||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||
else if (saved.paramsPublished === true && parameterGenerationState !== 'ready') parameterGenerationState = 'loading';
|
||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||
}
|
||||
|
||||
function normalizePagePath(value) {
|
||||
@@ -7994,7 +8126,7 @@ void main() {
|
||||
saveSession();
|
||||
queueCheckpoint(reason || 'browser_restore_without_wrapper');
|
||||
|
||||
const restoreFile = isFrameworkComponentPreviewMode(currentPreviewMode)
|
||||
const restoreFile = isFrameworkComponentPreviewMode(currentPreviewMode) || isSourceArtifactPreviewMode(currentPreviewMode)
|
||||
? currentPreviewFile
|
||||
: (currentSourceFile || currentPreviewFile);
|
||||
if (restoreFile) {
|
||||
@@ -8029,8 +8161,12 @@ void main() {
|
||||
previewMode: currentPreviewMode || undefined,
|
||||
pageUrl: location.pathname,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
parameterState: parameterGenerationState,
|
||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||
pageHash: location.hash || undefined,
|
||||
pageSearch: location.search || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8058,20 +8194,26 @@ void main() {
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
function cleanup(options) {
|
||||
const restoreOriginal = options?.restoreOriginal === true;
|
||||
const instantChrome = options?.instantChrome === true;
|
||||
const cleanupSessionId = currentSessionId;
|
||||
if (svelteComponentSession?.sessionId === cleanupSessionId) {
|
||||
teardownSvelteComponentSession(true);
|
||||
} else if (cleanupSessionId) {
|
||||
// Hide the wrapper immediately so variants disappear. DON'T structurally
|
||||
// mutate the DOM yet - HMR from the agent's source rewrite is on its way,
|
||||
// Switch visibility immediately without structurally mutating the DOM.
|
||||
// HMR from the agent's source rewrite may still be on its way,
|
||||
// and a manual replaceChild under React causes NotFoundError when the
|
||||
// reconciler later tries to remove a wrapper we already removed.
|
||||
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
|
||||
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (wrapper) wrapper.style.display = 'none';
|
||||
if (wrapper) {
|
||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||
else wrapper.style.display = 'none';
|
||||
}
|
||||
setTimeout(function() {
|
||||
document.getElementById(DISCARD_STATE_STYLE_ID)?.remove();
|
||||
if (!cleanupSessionId) return;
|
||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
||||
if (!lateWrapper) return;
|
||||
@@ -8086,7 +8228,7 @@ void main() {
|
||||
lateWrapper.remove();
|
||||
}, 2000);
|
||||
}
|
||||
hideBar();
|
||||
hideBar(instantChrome);
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
@@ -8101,6 +8243,8 @@ void main() {
|
||||
hoveredElement = null;
|
||||
pagePickSkipClick = false;
|
||||
currentSessionId = null;
|
||||
parameterGenerationState = 'idle';
|
||||
parameterReadyAnnouncedSession = null;
|
||||
selectedAction = 'impeccable';
|
||||
renderEditBadge('hidden');
|
||||
setLiveState('PICKING');
|
||||
@@ -8277,7 +8421,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
startScrollLock(currentSessionId, readScrollY(), pickedAnchorViewportTop);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -37,9 +37,14 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/codex-worker.json',
|
||||
'.impeccable/live/codex-worker.log',
|
||||
'.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',
|
||||
|
||||
@@ -359,8 +359,9 @@ Options:
|
||||
--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 portable contract for Claude Code, Cursor, and foreground fallback.
|
||||
Codex uses --stream only for the dedicated worker's narrow foreground control lane.
|
||||
Do not use --stream on Cursor.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const result = args.includes('--prepare')
|
||||
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));
|
||||
|
||||
@@ -193,9 +193,11 @@ function prepareGenerateEventForLease(entry) {
|
||||
|
||||
recordAgentPhase(event.id, 'picked_up');
|
||||
recordAgentPhase(event.id, 'scaffolding');
|
||||
const worker = getCodexWorkerStatus();
|
||||
const result = runGenerationPreflight(event, {
|
||||
cwd: process.cwd(),
|
||||
scriptsDir: __dirname,
|
||||
isolated: worker?.mode === 'dedicated-app-server' && worker?.reachable === true,
|
||||
});
|
||||
entry.event = {
|
||||
...event,
|
||||
@@ -241,6 +243,7 @@ function recordGenerationCheckpoint(event) {
|
||||
previewMode,
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: expected,
|
||||
publicationKind: event.publicationKind || 'variants',
|
||||
});
|
||||
}
|
||||
const details = {
|
||||
@@ -361,7 +364,11 @@ 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,
|
||||
};
|
||||
@@ -431,9 +438,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() {
|
||||
@@ -1004,14 +1012,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/')
|
||||
&& !normalized.includes('/.impeccable-live/')) 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 {
|
||||
@@ -1020,11 +1034,15 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode) || !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,
|
||||
previewFile,
|
||||
previewMode: manifest.previewMode,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
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'];
|
||||
|
||||
@@ -55,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):
|
||||
@@ -73,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) {
|
||||
@@ -230,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
|
||||
@@ -294,6 +302,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
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
|
||||
@@ -312,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">',
|
||||
@@ -324,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,
|
||||
@@ -341,6 +353,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let vueSession = null;
|
||||
let sourceArtifactSession = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -377,6 +390,21 @@ The agent should insert variant HTML at insertLine.`);
|
||||
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 = [
|
||||
@@ -402,11 +430,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
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: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode: componentPreviewMode,
|
||||
sourceFile: useFrameworkComponent || useSourceArtifact ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
previewManifest: sourceArtifactSession?.manifestFile,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -333,7 +333,7 @@ function ensureCodexWorker(cwd, liveConfig) {
|
||||
profile: result.profile,
|
||||
delivery: result.delivery,
|
||||
foregroundTypes: ['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
|
||||
foregroundPoll: 'live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit --codex-worker-fallback',
|
||||
foregroundPoll: 'live-poll.mjs --stream --types=steer,manual_edit_apply,carbonize_cleanup,exit --codex-worker-fallback',
|
||||
logPath: result.logPath || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
selectFastCodexModel,
|
||||
selectLowestReasoningEffort,
|
||||
selectQualityCodexModel,
|
||||
} from './codex-app-server-client.mjs';
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
buildCodexWorkerInstructions,
|
||||
buildCodexWorkerTurnInputs,
|
||||
buildGenerationTurnInput,
|
||||
codexWorkerDetectorRepairSchema,
|
||||
codexWorkerOutputSchemaForPhase,
|
||||
codexWorkerStateIsOwned,
|
||||
generationIsCanceled,
|
||||
isCodexComponentPreviewMode,
|
||||
prepareCodexWorkerPhase,
|
||||
publishCodexWorkerPhase,
|
||||
readPreparedArtifact,
|
||||
@@ -36,6 +38,7 @@ import { createLiveSessionStore } from './session-store.mjs';
|
||||
|
||||
export const CODEX_WORKER_EVENT_TYPES = Object.freeze(['generate', 'accept', 'discard', 'prefetch']);
|
||||
export const CODEX_WORKER_EVENT_LEASE_MS = 15_000;
|
||||
const LOCAL_SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export class CodexLiveWorkerSupervisor {
|
||||
constructor({
|
||||
@@ -53,6 +56,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
publishCheckpoint = postVariantCheckpoint,
|
||||
publishPhase = postAgentPhase,
|
||||
postCleanup = postCarbonizeCleanup,
|
||||
detectCandidate = detectPreparedArtifact,
|
||||
sessionStore = null,
|
||||
log = () => {},
|
||||
}) {
|
||||
@@ -70,6 +74,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
this.publishCheckpoint = publishCheckpoint;
|
||||
this.publishPhase = publishPhase;
|
||||
this.postCleanup = postCleanup;
|
||||
this.detectCandidate = detectCandidate;
|
||||
this.sessionStore = sessionStore || createLiveSessionStore({ cwd: this.cwd });
|
||||
this.log = log;
|
||||
this.running = false;
|
||||
@@ -84,10 +89,11 @@ export class CodexLiveWorkerSupervisor {
|
||||
this.threadReady = Promise.resolve(null);
|
||||
this.model = null;
|
||||
this.liveSpec = '';
|
||||
this.threadPrimed = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.liveSpec = readOptional(path.join(this.scriptsDir, '..', 'reference', 'live.md'));
|
||||
this.liveSpec = readOptional(path.join(this.scriptsDir, '..', 'reference', 'live-generation.md'));
|
||||
await this.client.connect();
|
||||
const models = await this.client.listModels();
|
||||
this.model = this.config.model
|
||||
@@ -107,6 +113,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
sandbox: 'read-only',
|
||||
baseInstructions: buildCodexWorkerInstructions(this.liveSpec),
|
||||
});
|
||||
this.threadPrimed = prior.threadPrimed === true;
|
||||
} catch (error) {
|
||||
this.log(`resume failed; creating replacement worker thread: ${error.message}`);
|
||||
}
|
||||
@@ -212,19 +219,22 @@ export class CodexLiveWorkerSupervisor {
|
||||
const snapshot = this.sessionStore.getSnapshot(event.id, { includeCompleted: true });
|
||||
const sameEpoch = Number(snapshot?.generationEpoch || 1) === Number(event.generationEpoch || 1);
|
||||
let arrivedVariants = sameEpoch ? Number(snapshot?.arrivedVariants || 0) : 0;
|
||||
let completedRemainder = false;
|
||||
if (this.config.delivery === 'progressive' && expectedVariants > 1) {
|
||||
if (arrivedVariants < 1) {
|
||||
await this.runGenerationPhase(event, 'first', 1);
|
||||
arrivedVariants = 1;
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
if (expectedVariants > 2 && arrivedVariants < 2) {
|
||||
await this.runGenerationPhase(event, 'second', 2);
|
||||
arrivedVariants = 2;
|
||||
if (arrivedVariants < expectedVariants) {
|
||||
await this.runGenerationPhase(event, 'remainder', expectedVariants);
|
||||
arrivedVariants = expectedVariants;
|
||||
completedRemainder = true;
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
if (arrivedVariants < expectedVariants) {
|
||||
await this.runGenerationPhase(event, 'final', expectedVariants);
|
||||
const latest = this.sessionStore.getSnapshot(event.id, { includeCompleted: true });
|
||||
if (!completedRemainder && arrivedVariants >= expectedVariants && latest?.paramsPublished !== true) {
|
||||
await this.runGenerationPhase(event, 'params', expectedVariants);
|
||||
}
|
||||
} else if (arrivedVariants < expectedVariants) {
|
||||
await this.runGenerationPhase(event, 'atomic', expectedVariants);
|
||||
@@ -245,6 +255,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
}
|
||||
|
||||
startWorkerThread() {
|
||||
this.threadPrimed = false;
|
||||
return this.client.startDedicatedThread({
|
||||
model: this.model.model || this.model.id,
|
||||
cwd: this.cwd,
|
||||
@@ -311,7 +322,9 @@ export class CodexLiveWorkerSupervisor {
|
||||
cwd: this.cwd,
|
||||
maxBytes: this.config.maxArtifactBytes,
|
||||
});
|
||||
const contexts = readGenerationContexts(this.cwd, this.scriptsDir, event);
|
||||
const contexts = readGenerationContexts(this.cwd, this.scriptsDir, event, {
|
||||
includeStable: !this.threadPrimed,
|
||||
});
|
||||
const prompt = buildGenerationTurnInput({
|
||||
event,
|
||||
phase,
|
||||
@@ -322,108 +335,146 @@ export class CodexLiveWorkerSupervisor {
|
||||
});
|
||||
const input = buildCodexWorkerTurnInputs({
|
||||
prompt,
|
||||
skillPath: resolveCodexWorkerSkillPath(this.scriptsDir),
|
||||
skillPath: this.threadPrimed ? null : resolveCodexWorkerSkillPath(this.scriptsDir),
|
||||
screenshotPath: event.screenshotPath,
|
||||
cwd: this.cwd,
|
||||
});
|
||||
let publishedFromMessage = false;
|
||||
let publicationPromise = null;
|
||||
let earlyCandidateError = null;
|
||||
let durableCandidate = null;
|
||||
const publishCandidate = async (answer) => {
|
||||
if (publishedFromMessage || this.isCanceled(event.id)) return;
|
||||
if (!publicationPromise) {
|
||||
publicationPromise = (async () => {
|
||||
await this.publishPhase(this.base, this.token, {
|
||||
eventId: event.id,
|
||||
phase: generationPhaseName(phase, 'validating'),
|
||||
durationMs: Date.now() - phaseStartedAt,
|
||||
});
|
||||
if (!durableCandidate) {
|
||||
const candidatePath = path.resolve(this.cwd, prepared.artifactFile);
|
||||
if (!prepared.previewMode && (phase === 'first' || phase === 'second' || phase === 'final')) {
|
||||
// A structured agent message and the final turn result can contain
|
||||
// the same delta. Always apply against the immutable phase input so
|
||||
// a failed publication/checkpoint retry cannot double-insert it.
|
||||
fs.writeFileSync(candidatePath, artifact.content, 'utf-8');
|
||||
}
|
||||
const applied = applyCodexWorkerOutput({
|
||||
output: answer,
|
||||
prepared,
|
||||
phase,
|
||||
expectedVariants: Number(event.count || arrivedVariants),
|
||||
sessionId: event.id,
|
||||
scaffold: event.scaffold,
|
||||
cwd: this.cwd,
|
||||
maxBytes: this.config.maxArtifactBytes,
|
||||
});
|
||||
if (!prepared.previewMode && !applied.sourceDelta && (phase === 'second' || phase === 'final')) {
|
||||
const reconciled = reconcilePublishedSourceVariants({
|
||||
current: artifact.content,
|
||||
candidate: fs.readFileSync(candidatePath, 'utf-8'),
|
||||
priorArrived: Math.max(1, arrivedVariants - 1),
|
||||
});
|
||||
if (!reconciled.ok) throw supervisorError(`reconcile_${reconciled.error}`);
|
||||
fs.writeFileSync(candidatePath, reconciled.content, 'utf-8');
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
const published = publishCodexWorkerPhase({ event, prepared, arrivedVariants, cwd: this.cwd });
|
||||
durableCandidate = { applied, published, planRecorded: false };
|
||||
}
|
||||
if (durableCandidate.applied.plan && !durableCandidate.planRecorded) {
|
||||
this.sessionStore.appendEvent({
|
||||
type: 'variant_plan',
|
||||
id: event.id,
|
||||
plan: durableCandidate.applied.plan,
|
||||
});
|
||||
durableCandidate.planRecorded = true;
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
await this.publishCheckpoint(this.base, this.token, {
|
||||
event,
|
||||
published: durableCandidate.published,
|
||||
scaffold: event.scaffold,
|
||||
arrivedVariants,
|
||||
});
|
||||
publishedFromMessage = true;
|
||||
})();
|
||||
}
|
||||
const pendingPublication = publicationPromise;
|
||||
try {
|
||||
await pendingPublication;
|
||||
} catch (error) {
|
||||
if (!earlyCandidateError) earlyCandidateError = error;
|
||||
} finally {
|
||||
if (publicationPromise === pendingPublication) publicationPromise = null;
|
||||
}
|
||||
};
|
||||
if (this.isCanceled(event.id)) return;
|
||||
const result = await this.runTurnWithReconnect({
|
||||
const outputSchema = codexWorkerOutputSchemaForPhase(
|
||||
phase,
|
||||
Number(event.count || arrivedVariants),
|
||||
{ sourceDelta: (phase === 'first' || phase === 'remainder' || phase === 'params') && !isCodexComponentPreviewMode(prepared.previewMode) },
|
||||
);
|
||||
let result = await this.runTurnWithReconnect({
|
||||
input,
|
||||
outputSchema: codexWorkerOutputSchemaForPhase(
|
||||
phase,
|
||||
Number(event.count || arrivedVariants),
|
||||
{ sourceDelta: (phase === 'first' || phase === 'second' || phase === 'final') && !prepared.previewMode },
|
||||
),
|
||||
onAgentMessage: publishCandidate,
|
||||
outputSchema,
|
||||
eventId: event.id,
|
||||
effort: phase === 'params' ? 'low' : undefined,
|
||||
});
|
||||
this.threadPrimed = true;
|
||||
this.writeState('working', { eventId: event.id });
|
||||
if (this.isCanceled(event.id)) return;
|
||||
if (!publishedFromMessage) await publishCandidate(result.answer);
|
||||
if (!publishedFromMessage) throw earlyCandidateError || supervisorError('worker_output_not_published');
|
||||
await this.publishPhase(this.base, this.token, {
|
||||
eventId: event.id,
|
||||
phase: generationPhaseName(phase, 'validating'),
|
||||
durationMs: Date.now() - phaseStartedAt,
|
||||
});
|
||||
|
||||
const baselineFindings = this.detectCandidate(prepared, {
|
||||
cwd: this.cwd,
|
||||
scriptsDir: this.scriptsDir,
|
||||
});
|
||||
let applied;
|
||||
let newFindings;
|
||||
let acceptedDetectorWaivers = [];
|
||||
for (let repairAttempt = 0; repairAttempt <= 1; repairAttempt += 1) {
|
||||
restorePreparedArtifact(prepared, artifact, { cwd: this.cwd });
|
||||
applied = applyCodexWorkerOutput({
|
||||
output: result.answer,
|
||||
prepared,
|
||||
phase,
|
||||
expectedVariants: Number(event.count || arrivedVariants),
|
||||
sessionId: event.id,
|
||||
scaffold: event.scaffold,
|
||||
cwd: this.cwd,
|
||||
maxBytes: this.config.maxArtifactBytes,
|
||||
});
|
||||
reconcileCandidateIfNeeded({
|
||||
applied,
|
||||
artifact,
|
||||
prepared,
|
||||
phase,
|
||||
arrivedVariants,
|
||||
cwd: this.cwd,
|
||||
});
|
||||
newFindings = diffDetectorFindings(
|
||||
baselineFindings,
|
||||
this.detectCandidate(prepared, { cwd: this.cwd, scriptsDir: this.scriptsDir }),
|
||||
);
|
||||
const waiverResolution = resolveDetectorFindingWaivers(
|
||||
newFindings,
|
||||
extractDetectorWaivers(result.answer),
|
||||
);
|
||||
newFindings = waiverResolution.unresolved;
|
||||
acceptedDetectorWaivers = waiverResolution.accepted;
|
||||
if (newFindings.length === 0) break;
|
||||
if (repairAttempt === 1) {
|
||||
const error = supervisorError('worker_output_detector_findings');
|
||||
error.findings = newFindings;
|
||||
throw error;
|
||||
}
|
||||
restorePreparedArtifact(prepared, artifact, { cwd: this.cwd });
|
||||
result = await this.runTurnWithReconnect({
|
||||
input: buildCodexWorkerTurnInputs({
|
||||
prompt: buildDetectorRepairPrompt(phase, newFindings),
|
||||
cwd: this.cwd,
|
||||
}),
|
||||
outputSchema: codexWorkerDetectorRepairSchema(outputSchema),
|
||||
eventId: event.id,
|
||||
});
|
||||
if (this.isCanceled(event.id)) return;
|
||||
}
|
||||
|
||||
if (applied.plan) {
|
||||
this.sessionStore.appendEvent({
|
||||
type: 'variant_plan',
|
||||
id: event.id,
|
||||
plan: applied.plan,
|
||||
});
|
||||
}
|
||||
if (acceptedDetectorWaivers.length > 0) {
|
||||
this.sessionStore.appendEvent({
|
||||
type: 'detector_waivers',
|
||||
id: event.id,
|
||||
phase,
|
||||
waivers: acceptedDetectorWaivers.map(({ waiver }) => waiver),
|
||||
});
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
const published = publishCodexWorkerPhase({ event, prepared, arrivedVariants, phase, cwd: this.cwd });
|
||||
let checkpointError;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await this.publishCheckpoint(this.base, this.token, {
|
||||
event,
|
||||
published,
|
||||
scaffold: event.scaffold,
|
||||
arrivedVariants,
|
||||
});
|
||||
checkpointError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
checkpointError = error;
|
||||
}
|
||||
}
|
||||
if (checkpointError) throw checkpointError;
|
||||
if (['remainder', 'params', 'atomic'].includes(phase)) {
|
||||
await this.publishPhase(this.base, this.token, {
|
||||
eventId: event.id,
|
||||
phase: 'parameters_ready',
|
||||
durationMs: Date.now() - phaseStartedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async runTurnWithReconnect({ input, outputSchema, onAgentMessage, eventId = this.active?.eventId }) {
|
||||
async runTurnWithReconnect({
|
||||
input,
|
||||
outputSchema,
|
||||
onAgentMessage,
|
||||
eventId = this.active?.eventId,
|
||||
effort,
|
||||
}) {
|
||||
let firstError;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const threadId = this.thread.id;
|
||||
if (this.active?.eventId === eventId) this.active.threadId = threadId;
|
||||
const turn = await this.client.startTurn({
|
||||
threadId,
|
||||
input,
|
||||
cwd: this.cwd,
|
||||
model: this.model.model || this.model.id,
|
||||
effort: preferredEffort(this.model, this.config.effort),
|
||||
effort: preferredEffort(this.model, effort || this.config.effort),
|
||||
summary: 'none',
|
||||
approvalPolicy: 'never',
|
||||
sandboxPolicy: { type: 'readOnly' },
|
||||
@@ -449,17 +500,26 @@ export class CodexLiveWorkerSupervisor {
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
this.thread = await this.client.reconnect({
|
||||
threadId: this.thread.id,
|
||||
this.thread = await this.reconnectThread(this.thread, this.model);
|
||||
this.writeState('ready', { reconnectedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async reconnectThread(thread, model = this.model) {
|
||||
const resumed = await this.client.reconnect({
|
||||
threadId: thread.id,
|
||||
resumeParams: {
|
||||
model: this.model.model || this.model.id,
|
||||
model: model.model || model.id,
|
||||
cwd: this.cwd,
|
||||
approvalPolicy: 'never',
|
||||
sandbox: 'read-only',
|
||||
baseInstructions: buildCodexWorkerInstructions(this.liveSpec),
|
||||
},
|
||||
});
|
||||
this.writeState('ready', { reconnectedAt: new Date().toISOString() });
|
||||
if (thread === this.thread) {
|
||||
this.thread = resumed;
|
||||
this.writeState('ready', { reconnectedAt: new Date().toISOString() });
|
||||
}
|
||||
return resumed;
|
||||
}
|
||||
|
||||
async cancelActive(reason, eventId = null) {
|
||||
@@ -547,6 +607,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
effort: this.model ? preferredEffort(this.model, this.config.effort) : this.config.effort,
|
||||
profile: this.config.profile,
|
||||
delivery: this.config.delivery,
|
||||
threadPrimed: this.threadPrimed,
|
||||
eventId: this.active?.eventId || null,
|
||||
};
|
||||
}
|
||||
@@ -565,7 +626,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
|
||||
function generationPhaseName(phase, state) {
|
||||
if (phase === 'first') return `first_variant_${state}`;
|
||||
if (phase === 'second') return `second_variant_${state}`;
|
||||
if (phase === 'params') return `variant_parameters_${state}`;
|
||||
return `remaining_variants_${state}`;
|
||||
}
|
||||
|
||||
@@ -591,6 +652,7 @@ export async function postVariantCheckpoint(base, token, {
|
||||
type: 'checkpoint',
|
||||
id: event.id,
|
||||
revision: published.revision,
|
||||
revisionDomain: 'publication',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
arrivedVariants,
|
||||
@@ -598,6 +660,7 @@ export async function postVariantCheckpoint(base, token, {
|
||||
sourceFile: scaffold.sourceFile || scaffold.file,
|
||||
previewFile: scaffold.file,
|
||||
previewMode: scaffold.previewMode || 'source',
|
||||
publicationKind: published.publicationKind || 'variants',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw supervisorError(`checkpoint_${response.status}`);
|
||||
@@ -652,6 +715,7 @@ export function buildDeterministicScaffoldCommand(event, scriptsDir) {
|
||||
const script = path.join(scriptsDir, insert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = ['--id', String(event.id), '--count', String(event.count || 3)];
|
||||
const target = insert ? event.insert?.anchor || {} : event.element || {};
|
||||
if (!insert) args.push('--isolated');
|
||||
if (insert) args.push('--position', String(event.insert?.position || 'after'));
|
||||
if (target.id) args.push('--element-id', String(target.id));
|
||||
const classes = Array.isArray(target.classes) ? target.classes.join(',') : target.className;
|
||||
@@ -687,84 +751,195 @@ export function runDeterministicScaffold(event, {
|
||||
return scaffold;
|
||||
}
|
||||
|
||||
function readGenerationContexts(cwd, scriptsDir, event) {
|
||||
function restorePreparedArtifact(prepared, artifact, { cwd }) {
|
||||
if (!isCodexComponentPreviewMode(prepared.previewMode)) {
|
||||
fs.writeFileSync(path.resolve(cwd, prepared.artifactFile), artifact.content, 'utf-8');
|
||||
return;
|
||||
}
|
||||
const componentDir = path.resolve(cwd, prepared.componentDir);
|
||||
fs.mkdirSync(componentDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(componentDir)) {
|
||||
if (/^(?:v\d+\.(?:svelte|vue)|params\.json)$/.test(name)) {
|
||||
fs.unlinkSync(path.join(componentDir, name));
|
||||
}
|
||||
}
|
||||
for (const [name, content] of Object.entries(artifact.files || {})) {
|
||||
fs.writeFileSync(path.join(componentDir, name), content, 'utf-8');
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.resolve(cwd, prepared.artifactFile),
|
||||
JSON.stringify(artifact.manifest, null, 2) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileCandidateIfNeeded({ applied, artifact, prepared, phase, arrivedVariants, cwd }) {
|
||||
if (isCodexComponentPreviewMode(prepared.previewMode) || applied.sourceDelta || phase !== 'remainder') return;
|
||||
const candidatePath = path.resolve(cwd, prepared.artifactFile);
|
||||
const reconciled = reconcilePublishedSourceVariants({
|
||||
current: artifact.content,
|
||||
candidate: fs.readFileSync(candidatePath, 'utf-8'),
|
||||
priorArrived: Math.max(1, arrivedVariants - 1),
|
||||
});
|
||||
if (!reconciled.ok) throw supervisorError(`reconcile_${reconciled.error}`);
|
||||
fs.writeFileSync(candidatePath, reconciled.content, 'utf-8');
|
||||
}
|
||||
|
||||
export function detectPreparedArtifact(prepared, {
|
||||
cwd = process.cwd(),
|
||||
scriptsDir = LOCAL_SCRIPTS_DIR,
|
||||
spawn = spawnSync,
|
||||
} = {}) {
|
||||
const targets = detectorTargets(prepared, cwd);
|
||||
if (targets.length === 0) return [];
|
||||
const detectorScript = [
|
||||
path.join(scriptsDir, 'detect.mjs'),
|
||||
path.join(LOCAL_SCRIPTS_DIR, 'detect.mjs'),
|
||||
].find((candidate) => fs.existsSync(candidate));
|
||||
if (!detectorScript) throw supervisorError('codex_worker_detector_unavailable');
|
||||
const result = spawn(process.execPath, [detectorScript, '--json', ...targets], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
if (result.error) throw supervisorError(`codex_worker_detector_failed:${result.error.message}`);
|
||||
try {
|
||||
const findings = JSON.parse(String(result.stdout || '[]'));
|
||||
if (!Array.isArray(findings)) throw new Error('expected findings array');
|
||||
return findings;
|
||||
} catch (error) {
|
||||
throw supervisorError(`codex_worker_detector_invalid:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function detectorTargets(prepared, cwd) {
|
||||
if (!isCodexComponentPreviewMode(prepared.previewMode)) {
|
||||
return [path.resolve(cwd, prepared.artifactFile)];
|
||||
}
|
||||
const componentDir = path.resolve(cwd, prepared.componentDir);
|
||||
try {
|
||||
return fs.readdirSync(componentDir)
|
||||
.filter((name) => /\.(?:vue|svelte)$/.test(name))
|
||||
.map((name) => path.join(componentDir, name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function diffDetectorFindings(before, after) {
|
||||
const remaining = new Map();
|
||||
for (const finding of before || []) {
|
||||
const key = detectorFindingKey(finding);
|
||||
remaining.set(key, (remaining.get(key) || 0) + 1);
|
||||
}
|
||||
const added = [];
|
||||
for (const finding of after || []) {
|
||||
const key = detectorFindingKey(finding);
|
||||
const count = remaining.get(key) || 0;
|
||||
if (count > 0) remaining.set(key, count - 1);
|
||||
else added.push(finding);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function detectorFindingKey(finding) {
|
||||
return [
|
||||
path.basename(String(finding?.file || '')),
|
||||
finding?.antipattern || finding?.id || '',
|
||||
finding?.snippet || '',
|
||||
finding?.ignoreValue || '',
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
export function buildDetectorRepairPrompt(phase, findings) {
|
||||
return [
|
||||
`The candidate for Live phase ${phase} has new Impeccable detector findings.`,
|
||||
'Use design judgment on every finding. Fix real defects. If a finding is contextually intentional or a detector false positive, leave that design intact and add one narrow detectorWaivers entry copied from the finding with a concrete reason. Return detectorWaivers as an empty array when every finding was fixed. Every finding must either disappear on the next scan or match an explicit waiver; unresolved findings still block publication.',
|
||||
'Return the complete replacement JSON for the same phase and schema. Do not explain, call tools, persist project detector config, add inline ignore comments, or alter immutable variants.',
|
||||
'<detector_findings>',
|
||||
JSON.stringify((findings || []).slice(0, 40).map((finding) => ({
|
||||
rule: finding.antipattern || finding.id,
|
||||
name: finding.name,
|
||||
description: finding.description,
|
||||
severity: finding.severity,
|
||||
snippet: finding.snippet,
|
||||
file: path.basename(String(finding.file || '')),
|
||||
ignoreValue: finding.ignoreValue || '',
|
||||
})), null, 2),
|
||||
'</detector_findings>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function resolveDetectorFindingWaivers(findings, waivers) {
|
||||
const candidates = (Array.isArray(waivers) ? waivers : [])
|
||||
.map(normalizeDetectorWaiver)
|
||||
.filter(Boolean);
|
||||
const accepted = [];
|
||||
const unresolved = [];
|
||||
for (const finding of findings || []) {
|
||||
const waiver = candidates.find((candidate) => detectorWaiverMatches(candidate, finding));
|
||||
if (waiver) accepted.push({ finding, waiver });
|
||||
else unresolved.push(finding);
|
||||
}
|
||||
return { accepted, unresolved };
|
||||
}
|
||||
|
||||
function extractDetectorWaivers(output) {
|
||||
try {
|
||||
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
|
||||
return Array.isArray(parsed?.detectorWaivers) ? parsed.detectorWaivers : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDetectorWaiver(waiver) {
|
||||
if (!waiver || typeof waiver !== 'object') return null;
|
||||
const normalized = {
|
||||
rule: String(waiver.rule || '').trim().toLowerCase(),
|
||||
file: path.basename(String(waiver.file || '').trim()),
|
||||
snippet: String(waiver.snippet || '').trim(),
|
||||
ignoreValue: String(waiver.ignoreValue || '').trim(),
|
||||
reason: String(waiver.reason || '').trim(),
|
||||
};
|
||||
return normalized.rule && normalized.reason && (normalized.snippet || normalized.ignoreValue)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
function detectorWaiverMatches(waiver, finding) {
|
||||
const rule = String(finding?.antipattern || finding?.id || '').trim().toLowerCase();
|
||||
const file = path.basename(String(finding?.file || '').trim());
|
||||
const snippet = String(finding?.snippet || '').trim();
|
||||
const ignoreValue = String(finding?.ignoreValue || '').trim();
|
||||
if (waiver.rule !== rule) return false;
|
||||
if (waiver.file && waiver.file !== file) return false;
|
||||
if (waiver.ignoreValue) return waiver.ignoreValue === ignoreValue;
|
||||
return Boolean(waiver.snippet && waiver.snippet === snippet);
|
||||
}
|
||||
|
||||
function readGenerationContexts(cwd, scriptsDir, event, { includeStable = true } = {}) {
|
||||
const context = loadContext(cwd);
|
||||
const action = event?.action;
|
||||
const safeAction = typeof action === 'string' && /^[a-z-]+$/.test(action) && action !== 'impeccable'
|
||||
? action
|
||||
: null;
|
||||
return {
|
||||
product: context.product || '',
|
||||
design: context.design || '',
|
||||
product: includeStable ? context.product || '' : '',
|
||||
design: includeStable ? context.design || '' : '',
|
||||
actionReference: safeAction
|
||||
? readOptional(path.join(scriptsDir, '..', 'reference', `${safeAction}.md`))
|
||||
: '',
|
||||
contextMetadata: {
|
||||
contextMetadata: includeStable ? {
|
||||
productPath: context.productPath,
|
||||
designPath: context.designPath,
|
||||
projectRoot: context.projectRoot,
|
||||
repoRoot: context.repoRoot,
|
||||
isMonorepo: context.isMonorepo,
|
||||
},
|
||||
sourceNeighborhood: readSourceNeighborhood(cwd, context.projectRoot, event?.scaffold?.sourceFile || event?.scaffold?.file),
|
||||
} : {},
|
||||
};
|
||||
}
|
||||
|
||||
function readSourceNeighborhood(cwd, projectRoot, sourceFile) {
|
||||
const roots = [projectRoot, cwd].filter(Boolean).map((value) => path.resolve(value));
|
||||
const result = {};
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 180_000;
|
||||
const candidateNames = [
|
||||
sourceFile,
|
||||
'package.json',
|
||||
'src/styles.css',
|
||||
'src/index.css',
|
||||
'src/globals.css',
|
||||
'app/globals.css',
|
||||
'styles/globals.css',
|
||||
'tailwind.config.js',
|
||||
'tailwind.config.ts',
|
||||
].filter(Boolean);
|
||||
if (sourceFile) {
|
||||
for (const root of roots) {
|
||||
const source = readOptional(path.join(root, sourceFile));
|
||||
for (const specifier of localImportSpecifiers(source)) {
|
||||
const base = path.join(path.dirname(sourceFile), specifier);
|
||||
for (const suffix of ['', '.js', '.jsx', '.ts', '.tsx', '.css', '/index.js', '/index.jsx', '/index.ts', '/index.tsx']) {
|
||||
const candidate = `${base}${suffix}`.split(path.sep).join('/');
|
||||
if (fs.existsSync(path.join(root, candidate))) {
|
||||
candidateNames.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) {
|
||||
for (const name of candidateNames) {
|
||||
if (Object.hasOwn(result, name)) continue;
|
||||
const file = path.join(root, name);
|
||||
const body = readOptional(file);
|
||||
if (!body) continue;
|
||||
const bytes = Buffer.byteLength(body);
|
||||
if (totalBytes + bytes > maxBytes) continue;
|
||||
result[name] = body;
|
||||
totalBytes += bytes;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function localImportSpecifiers(source) {
|
||||
if (!source) return [];
|
||||
const imports = [];
|
||||
const pattern = /(?:from\s*|import\s*)["'](\.{1,2}\/[^"']+)["']/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(source))) imports.push(match[1]);
|
||||
return [...new Set(imports)];
|
||||
}
|
||||
|
||||
function readOptional(file) {
|
||||
try { return fs.readFileSync(file, 'utf-8'); } catch { return ''; }
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ export const CODEX_WORKER_OUTPUT_SCHEMA = Object.freeze({
|
||||
required: ['files'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
const DETECTOR_WAIVER_SCHEMA = Object.freeze({
|
||||
type: 'array',
|
||||
maxItems: 40,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rule: { type: 'string', minLength: 1 },
|
||||
file: { type: 'string' },
|
||||
snippet: { type: 'string' },
|
||||
ignoreValue: { type: 'string' },
|
||||
reason: { type: 'string', minLength: 1, maxLength: 500 },
|
||||
},
|
||||
required: ['rule', 'file', 'snippet', 'ignoreValue', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
export function codexWorkerOutputSchemaForPhase(
|
||||
phase,
|
||||
expectedVariants = 3,
|
||||
@@ -74,35 +90,61 @@ export function codexWorkerOutputSchemaForPhase(
|
||||
};
|
||||
}
|
||||
|
||||
export function codexWorkerDetectorRepairSchema(outputSchema) {
|
||||
return {
|
||||
...outputSchema,
|
||||
properties: {
|
||||
...outputSchema.properties,
|
||||
detectorWaivers: DETECTOR_WAIVER_SCHEMA,
|
||||
},
|
||||
required: [...outputSchema.required, 'detectorWaivers'],
|
||||
};
|
||||
}
|
||||
|
||||
function codexSourceDeltaOutputSchema(phase, requirePlan, expectedVariants) {
|
||||
const variantId = phase === 'first'
|
||||
? 1
|
||||
: phase === 'second'
|
||||
? 2
|
||||
: Number(expectedVariants) > 2 ? 3 : 2;
|
||||
const final = phase === 'final';
|
||||
const sourceDelta = {
|
||||
const variantDelta = (minimum, maximum = minimum) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
variantId: { type: 'integer', minimum: variantId, maximum: variantId },
|
||||
variantId: { type: 'integer', minimum, maximum },
|
||||
markup: { type: 'string', minLength: 1 },
|
||||
css: { type: 'string', minLength: 1 },
|
||||
...(final ? {
|
||||
parameterCss: { type: 'string' },
|
||||
paramsJson: { type: 'string', minLength: 2 },
|
||||
} : {}),
|
||||
},
|
||||
required: final
|
||||
? ['variantId', 'markup', 'css', 'parameterCss', 'paramsJson']
|
||||
: ['variantId', 'markup', 'css'],
|
||||
required: ['variantId', 'markup', 'css'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
});
|
||||
let phaseProperties;
|
||||
let phaseRequired;
|
||||
if (phase === 'first') {
|
||||
phaseProperties = { sourceDelta: variantDelta(1) };
|
||||
phaseRequired = ['sourceDelta'];
|
||||
} else if (phase === 'remainder') {
|
||||
phaseProperties = {
|
||||
sourceDeltas: {
|
||||
type: 'array',
|
||||
minItems: Math.max(1, Number(expectedVariants) - 1),
|
||||
maxItems: Math.max(1, Number(expectedVariants) - 1),
|
||||
items: variantDelta(2, Number(expectedVariants)),
|
||||
},
|
||||
parameterCss: { type: 'string' },
|
||||
paramsJson: { type: 'string', minLength: 2 },
|
||||
};
|
||||
phaseRequired = ['sourceDeltas', 'parameterCss', 'paramsJson'];
|
||||
} else if (phase === 'params') {
|
||||
phaseProperties = {
|
||||
parameterCss: { type: 'string' },
|
||||
paramsJson: { type: 'string', minLength: 2 },
|
||||
};
|
||||
phaseRequired = ['parameterCss', 'paramsJson'];
|
||||
} else {
|
||||
phaseProperties = { sourceDelta: variantDelta(Number(expectedVariants)) };
|
||||
phaseRequired = ['sourceDelta'];
|
||||
}
|
||||
return {
|
||||
type: 'object',
|
||||
properties: requirePlan
|
||||
? { sourceDelta, plan: VARIANT_PLAN_SCHEMA }
|
||||
: { sourceDelta },
|
||||
required: requirePlan ? ['sourceDelta', 'plan'] : ['sourceDelta'],
|
||||
? { ...phaseProperties, plan: VARIANT_PLAN_SCHEMA }
|
||||
: phaseProperties,
|
||||
required: requirePlan ? [...phaseRequired, 'plan'] : phaseRequired,
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
@@ -203,9 +245,9 @@ export function isCodexRuntime(env = process.env) {
|
||||
export function buildCodexWorkerInstructions(liveSpec) {
|
||||
return [
|
||||
'You are a dedicated Impeccable Live variant producer, never the foreground desktop task.',
|
||||
'The Impeccable skill is attached to generation turns. Its Setup context is already resolved in the user message; do not rerun setup.',
|
||||
'Do not write source or mutate the project. The supervisor supplies bounded project evidence, writes staged artifacts, and publishes transactionally.',
|
||||
'Use read-only tools only when a critical relationship is genuinely missing from the supplied evidence.',
|
||||
'The Impeccable skill is attached on the first turn of this persistent Live thread. Its Setup context is already resolved in the user message; do not rerun setup.',
|
||||
'Do not write source or mutate the project. The supervisor supplies the exact selected source artifact, writes staged artifacts, and publishes transactionally.',
|
||||
'Use read-only repository tools whenever needed to understand imports, shared layouts, styles, tokens, components, or route ownership. Inspect rather than guess; discoveries remain available to later turns in this same thread.',
|
||||
'Return only the JSON object required by the output schema. The supervisor alone writes staged artifacts and publishes them transactionally.',
|
||||
'Preserve existing copy, semantics, public component APIs, accessibility, brand identity, and supplied tokens. Preserve shared-child roles, but recompose the selected element itself when the action calls for a stronger layout or spatial relationship. Do not emit data-impeccable wrappers inside variant content.',
|
||||
'Treat shared-component visual roles as design-system evidence. Preserve their established background, border, radius, and state treatment unless the request explicitly targets that component; do not turn quiet or outlined controls into filled emphasis, inject decorative glyphs or pseudo-content, or change a component role.',
|
||||
@@ -232,15 +274,13 @@ export function buildGenerationTurnInput({
|
||||
design,
|
||||
actionReference,
|
||||
contextMetadata,
|
||||
sourceNeighborhood,
|
||||
}) {
|
||||
const count = Number(event.count || 3);
|
||||
const first = phase === 'first';
|
||||
const second = phase === 'second';
|
||||
const final = phase === 'final';
|
||||
const component = Boolean(prepared.previewMode);
|
||||
const sourceDelta = !component && (first || second || final);
|
||||
const sourceDeltaVariant = first ? 1 : second ? 2 : count > 2 ? 3 : 2;
|
||||
const remainder = phase === 'remainder';
|
||||
const params = phase === 'params';
|
||||
const component = isCodexComponentPreviewMode(prepared.previewMode);
|
||||
const sourceDelta = !component && (first || remainder || params);
|
||||
const actionRules = event.action === 'bolder' && count > 1
|
||||
? [
|
||||
'For /bolder, keep variant 1 low-risk: preserve the selected root’s high-level layout and create impact through controlled hierarchy, proportion, or rhythm. Reserve root recomposition for variant 2 or 3.',
|
||||
@@ -256,37 +296,58 @@ export function buildGenerationTurnInput({
|
||||
`Before authoring, define the shared identity lock and exactly ${count} distinct, meaningful design axes. Return them in plan.directions ordered by variantId so the final phase can complete the same coherent set.`,
|
||||
'Defer tunable parameters: params must be absent or empty for this phase.',
|
||||
]
|
||||
: second
|
||||
: remainder
|
||||
? [
|
||||
'Produce only variant 2 now so it can be reviewed immediately.',
|
||||
`Produce variants 2 through ${count} and the final tunable parameters together so the complete set becomes reviewable in one publication.`,
|
||||
'Variant 1 is already visible and immutable. Do not return or alter its markup or CSS.',
|
||||
'Follow the durable variant plan below and implement direction 2 as an independently shippable option.',
|
||||
'Defer tunable parameters: params must be absent or empty for this phase.',
|
||||
'Follow the durable variant plan below and implement every remaining direction as an independently shippable option.',
|
||||
'Return the parameter manifest and wiring CSS for all variants, including immutable variant 1. Parameters may only expose meaningful axes already present in the designs and must not change any default appearance.',
|
||||
'Parameter schema examples: range = {"id":"scale","kind":"range","label":"Scale","min":0.8,"max":1.2,"step":0.1,"default":1}; steps = {"id":"density","kind":"steps","label":"Density","options":[{"value":"compact","label":"Compact"},{"value":"roomy","label":"Roomy"}]}; toggle = {"id":"accent","kind":"toggle","label":"Accent","default":false}.',
|
||||
'Range wiring sets --p-<id>. Steps and toggles use data-p-<id> on the variant wrapper. Return an empty array for a variant only when no meaningful coarse axis exists.',
|
||||
]
|
||||
: phase === 'final'
|
||||
: params
|
||||
? [
|
||||
`Complete variants ${count > 2 ? 3 : 2} through ${count} and the final parameter manifest.`,
|
||||
`Variants 1 through ${count > 2 ? 2 : 1} are already visible and immutable. Do not return or alter their files, markup, or CSS.`,
|
||||
'Follow the durable variant plan below. Preserve its identity lock and implement each remaining named axis instead of improvising a new set.',
|
||||
`All ${count} variants are already reviewable and immutable. Return only their parameter manifest and parameter wiring CSS.`,
|
||||
'Do not return markup or restyle any default appearance. Parameters may only expose meaningful axes already present in the designs.',
|
||||
'The staged artifact and schema below are complete. Do not call tools or inspect the repository during this phase.',
|
||||
'Parameter schema examples: range = {"id":"scale","kind":"range","label":"Scale","min":0.8,"max":1.2,"step":0.1,"default":1}; steps = {"id":"density","kind":"steps","label":"Density","options":[{"value":"compact","label":"Compact"},{"value":"roomy","label":"Roomy"}]}; toggle = {"id":"accent","kind":"toggle","label":"Accent","default":false}.',
|
||||
'Range wiring sets --p-<id>. Steps and toggles use data-p-<id> on the variant wrapper. Return an empty array for a variant only when no meaningful coarse axis exists.',
|
||||
]
|
||||
: [
|
||||
`Produce the complete set of ${count} variants and final parameters atomically.`,
|
||||
`Before authoring, define the shared identity lock and exactly ${count} distinct, meaningful design axes and return them in plan.directions ordered by variantId.`,
|
||||
];
|
||||
const contextBlocks = [];
|
||||
if (product) contextBlocks.push('<product_context>', String(product), '</product_context>');
|
||||
if (design) contextBlocks.push('<design_context>', String(design), '</design_context>');
|
||||
if (actionReference) contextBlocks.push('<action_reference>', String(actionReference), '</action_reference>');
|
||||
if (contextMetadata && Object.keys(contextMetadata).length > 0) {
|
||||
contextBlocks.push('<context_metadata>', JSON.stringify(contextMetadata, null, 2), '</context_metadata>');
|
||||
}
|
||||
|
||||
return [
|
||||
`LIVE GENERATION PHASE: ${phase}`,
|
||||
...phaseRules,
|
||||
...actionRules,
|
||||
sourceDelta
|
||||
? `Return exactly sourceDelta for variant ${sourceDeltaVariant}${first && count > 1 ? ' plus the complete variant plan' : ''}. markup is only the selected root replacement, without an outer data-impeccable wrapper. css is only the complete fenced base CSS for variant ${sourceDeltaVariant}, following event.scaffold.cssAuthoring.${final ? ` parameterCss contains only deferred tuning rules for variants 1 through ${count}. paramsJson is a JSON-encoded object with exactly the keys ${Array.from({ length: count }, (_, index) => JSON.stringify(String(index + 1))).join(', ')}, each containing an array of 0-4 range, steps, or toggle parameter specs.` : ''}`
|
||||
? first
|
||||
? 'Return exactly sourceDelta for variant 1 plus the complete variant plan. markup is only the selected root replacement, without an outer data-impeccable wrapper. css is only the complete fenced base CSS for variant 1, following event.scaffold.cssAuthoring.'
|
||||
: remainder
|
||||
? `Return exactly sourceDeltas with one entry for each variant 2 through ${count}, ordered by variantId, plus parameterCss and paramsJson. Each markup value is only the selected root replacement; each css value is the complete fenced base CSS for that variant. parameterCss contains tuning rules for variants 1 through ${count}. paramsJson is a JSON-encoded object with exactly the keys ${Array.from({ length: count }, (_, index) => JSON.stringify(String(index + 1))).join(', ')}, each containing an array of 0-4 range, steps, or toggle parameter specs.`
|
||||
: `Return only parameterCss and paramsJson. parameterCss contains deferred tuning rules for variants 1 through ${count}. paramsJson is a JSON-encoded object with exactly the keys ${Array.from({ length: count }, (_, index) => JSON.stringify(String(index + 1))).join(', ')}, each containing an array of 0-4 range, steps, or toggle parameter specs.`
|
||||
: component
|
||||
? `Return staged component files relative to componentDir. Allowed variant extension: .${artifact.componentExtension}. The supervisor updates manifest.json.`
|
||||
? first
|
||||
? `Return only v1.${artifact.componentExtension} relative to componentDir. The supervisor updates manifest.json.`
|
||||
: remainder
|
||||
? `Return exactly v2.${artifact.componentExtension} through v${count}.${artifact.componentExtension} plus params.json relative to componentDir.`
|
||||
: params
|
||||
? 'Return only params.json relative to componentDir, keyed by variant number.'
|
||||
: `Return v1.${artifact.componentExtension} through v${count}.${artifact.componentExtension} plus params.json relative to componentDir.`
|
||||
: `Return exactly one file whose path is ${JSON.stringify(prepared.artifactFile)} and whose content is the complete staged source artifact.`,
|
||||
sourceDelta
|
||||
? `Do not repeat the staged artifact${second || final ? ', prior variants' : ''}, style tags, wrapper comments, or any data-impeccable attributes. The supervisor merges and validates this delta transactionally.${final ? ' parameterCss may target prior variants only to wire explicit data-p-* states or --p-* variables; it must not restyle their default appearance.' : ''}`
|
||||
? `Do not repeat the staged artifact${remainder || params ? ', prior variants' : ''}, style tags, wrapper comments, or any data-impeccable attributes. The supervisor merges and validates this output transactionally.${remainder || params ? ' parameterCss may only wire explicit data-p-* states or --p-* variables; it must not restyle default appearance.' : ''}`
|
||||
: component
|
||||
? 'For the final/atomic phase include params.json keyed by variant number. Never include manifest.json or paths outside componentDir.'
|
||||
? 'Never include manifest.json or paths outside componentDir. Never repeat an immutable variant in a later phase.'
|
||||
: 'Keep the existing session wrapper and markers intact. Add only valid variant blocks and preview CSS inside that wrapper.',
|
||||
'',
|
||||
'<event>',
|
||||
@@ -296,21 +357,7 @@ export function buildGenerationTurnInput({
|
||||
JSON.stringify(variantPlan || null, null, 2),
|
||||
'</variant_plan>',
|
||||
'',
|
||||
'<product_context>',
|
||||
String(product || ''),
|
||||
'</product_context>',
|
||||
'<design_context>',
|
||||
String(design || ''),
|
||||
'</design_context>',
|
||||
'<action_reference>',
|
||||
String(actionReference || ''),
|
||||
'</action_reference>',
|
||||
'<context_metadata>',
|
||||
JSON.stringify(contextMetadata || {}, null, 2),
|
||||
'</context_metadata>',
|
||||
'<source_neighborhood>',
|
||||
JSON.stringify(sourceNeighborhood || {}, null, 2),
|
||||
'</source_neighborhood>',
|
||||
...contextBlocks,
|
||||
'<staged_artifact>',
|
||||
JSON.stringify(artifact, null, 2),
|
||||
'</staged_artifact>',
|
||||
@@ -339,7 +386,7 @@ export function resolveCodexWorkerSkillPath(scriptsDir) {
|
||||
}
|
||||
|
||||
export function readPreparedArtifact(prepared, { cwd = process.cwd(), maxBytes = 2_000_000 } = {}) {
|
||||
if (prepared.previewMode) {
|
||||
if (isCodexComponentPreviewMode(prepared.previewMode)) {
|
||||
const componentDir = resolveInside(cwd, prepared.componentDir);
|
||||
const manifestPath = resolveInside(cwd, prepared.artifactFile);
|
||||
if (!componentDir || !manifestPath) throw workerError('artifact_path_outside_project');
|
||||
@@ -363,7 +410,7 @@ export function readPreparedArtifact(prepared, { cwd = process.cwd(), maxBytes =
|
||||
const artifactPath = resolveInside(cwd, prepared.artifactFile);
|
||||
if (!artifactPath) throw workerError('artifact_path_outside_project');
|
||||
return {
|
||||
previewMode: 'source',
|
||||
previewMode: prepared.previewMode || 'source',
|
||||
path: prepared.artifactFile,
|
||||
content: readBounded(artifactPath, maxBytes),
|
||||
};
|
||||
@@ -383,25 +430,43 @@ export function applyCodexWorkerOutput({
|
||||
const requirePlan = Number(expectedVariants) > 1 && (phase === 'first' || phase === 'atomic');
|
||||
if (requirePlan && !parsed?.plan) throw workerError('worker_output_plan_missing');
|
||||
const plan = parsed?.plan ? normalizeVariantPlan(parsed.plan, expectedVariants) : null;
|
||||
if (!prepared.previewMode && (phase === 'first' || phase === 'second' || phase === 'final')) {
|
||||
if (!isCodexComponentPreviewMode(prepared.previewMode) && (phase === 'first' || phase === 'remainder' || phase === 'params')) {
|
||||
const artifactPath = resolveInside(cwd, prepared.artifactFile);
|
||||
if (!artifactPath) throw workerError('artifact_path_outside_project');
|
||||
const content = applyCodexSourceDelta({
|
||||
source: fs.readFileSync(artifactPath, 'utf-8'),
|
||||
delta: parsed?.sourceDelta,
|
||||
const common = {
|
||||
sessionId,
|
||||
expectedVariantId: phase === 'first'
|
||||
? 1
|
||||
: phase === 'second'
|
||||
? 2
|
||||
: Number(expectedVariants) > 2 ? 3 : 2,
|
||||
expectedVariants: Number(expectedVariants),
|
||||
styleMode: scaffold?.styleMode || scaffold?.cssAuthoring?.mode || 'scoped',
|
||||
styleTag: scaffold?.styleTag,
|
||||
jsx: scaffold?.commentSyntax?.open === '{/*',
|
||||
parameterCss: parsed?.sourceDelta?.parameterCss,
|
||||
paramsJson: parsed?.sourceDelta?.paramsJson,
|
||||
});
|
||||
};
|
||||
let content = fs.readFileSync(artifactPath, 'utf-8');
|
||||
if (phase === 'first') {
|
||||
content = applyCodexSourceDelta({ ...common, source: content, delta: parsed?.sourceDelta, expectedVariantId: 1 });
|
||||
} else if (phase === 'remainder') {
|
||||
const deltas = Array.isArray(parsed?.sourceDeltas) ? parsed.sourceDeltas : [];
|
||||
const expectedIds = Array.from({ length: Math.max(0, Number(expectedVariants) - 1) }, (_, index) => index + 2);
|
||||
const ids = deltas.map((delta) => Number(delta?.variantId));
|
||||
if (ids.length !== expectedIds.length || ids.some((id, index) => id !== expectedIds[index])) {
|
||||
throw workerError('worker_output_source_delta_variant_invalid');
|
||||
}
|
||||
for (const delta of deltas) {
|
||||
content = applyCodexSourceDelta({ ...common, source: content, delta, expectedVariantId: Number(delta.variantId) });
|
||||
}
|
||||
content = applyCodexSourceParameters({
|
||||
...common,
|
||||
source: content,
|
||||
parameterCss: parsed?.parameterCss,
|
||||
paramsJson: parsed?.paramsJson,
|
||||
});
|
||||
} else {
|
||||
content = applyCodexSourceParameters({
|
||||
...common,
|
||||
source: content,
|
||||
parameterCss: parsed?.parameterCss,
|
||||
paramsJson: parsed?.paramsJson,
|
||||
});
|
||||
}
|
||||
if (Buffer.byteLength(content) > maxBytes) throw workerError('worker_output_too_large');
|
||||
fs.writeFileSync(artifactPath, content, 'utf-8');
|
||||
return { files: [prepared.artifactFile], plan, sourceDelta: true };
|
||||
@@ -420,7 +485,7 @@ export function applyCodexWorkerOutput({
|
||||
totalBytes += Buffer.byteLength(file.content);
|
||||
}
|
||||
if (totalBytes > maxBytes) throw workerError('worker_output_too_large');
|
||||
if (!prepared.previewMode) {
|
||||
if (!isCodexComponentPreviewMode(prepared.previewMode)) {
|
||||
if (parsed.files.length !== 1 || parsed.files[0].path !== prepared.artifactFile) {
|
||||
throw workerError('worker_output_source_path_invalid');
|
||||
}
|
||||
@@ -438,23 +503,17 @@ export function applyCodexWorkerOutput({
|
||||
|| (prepared.previewMode === 'vue-component' ? 'vue' : 'svelte');
|
||||
const variantPattern = new RegExp(`^v(\\d+)\\.${escapeRegExp(extension)}$`);
|
||||
const allowed = new Set();
|
||||
const firstVariant = phase === 'first'
|
||||
? 1
|
||||
: phase === 'second'
|
||||
? 2
|
||||
: phase === 'final'
|
||||
? (expectedVariants > 2 ? 3 : 2)
|
||||
: 1;
|
||||
const lastVariant = phase === 'first' ? 1 : phase === 'second' ? 2 : expectedVariants;
|
||||
for (let variant = firstVariant; variant <= lastVariant; variant += 1) {
|
||||
allowed.add(`v${variant}.${extension}`);
|
||||
const firstVariant = phase === 'first' ? 1 : phase === 'remainder' ? 2 : phase === 'atomic' ? 1 : null;
|
||||
const lastVariant = phase === 'first' ? 1 : phase === 'remainder' || phase === 'atomic' ? expectedVariants : null;
|
||||
if (firstVariant != null) {
|
||||
for (let variant = firstVariant; variant <= lastVariant; variant += 1) allowed.add(`v${variant}.${extension}`);
|
||||
}
|
||||
if (phase === 'final' || phase === 'atomic') allowed.add('params.json');
|
||||
if (phase === 'remainder' || phase === 'params' || phase === 'atomic') allowed.add('params.json');
|
||||
|
||||
for (const file of parsed.files) {
|
||||
if (!allowed.has(file.path)) {
|
||||
const attemptedVariant = Number(variantPattern.exec(file.path)?.[1] || 0);
|
||||
if ((phase === 'second' || phase === 'final') && attemptedVariant > 0 && attemptedVariant < firstVariant) {
|
||||
if (phase === 'remainder' && attemptedVariant > 0 && attemptedVariant < firstVariant) {
|
||||
throw workerError('published_variant_changed');
|
||||
}
|
||||
throw workerError('worker_output_component_path_invalid');
|
||||
@@ -470,7 +529,7 @@ export function applyCodexWorkerOutput({
|
||||
throw workerError('worker_output_component_file_missing', { file: required });
|
||||
}
|
||||
}
|
||||
manifest.arrivedVariants = phase === 'first' ? 1 : phase === 'second' ? 2 : expectedVariants;
|
||||
manifest.arrivedVariants = phase === 'first' ? 1 : expectedVariants;
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
return { files: [...seen], plan };
|
||||
}
|
||||
@@ -596,6 +655,48 @@ export function applyCodexSourceDelta({
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function applyCodexSourceParameters({
|
||||
source,
|
||||
sessionId,
|
||||
expectedVariants = 3,
|
||||
styleMode = 'scoped',
|
||||
parameterCss = '',
|
||||
paramsJson,
|
||||
}) {
|
||||
const variantCount = Number(expectedVariants);
|
||||
const params = normalizeSourceParams(paramsJson, variantCount);
|
||||
const css = String(parameterCss || '').trim();
|
||||
if (/<\/?style\b|`|\$\{/i.test(css)) {
|
||||
throw workerError('worker_output_source_delta_css_unsafe');
|
||||
}
|
||||
if (css) {
|
||||
validateSourceDeltaCss(css, {
|
||||
variantIds: Array.from({ length: variantCount }, (_, index) => index + 1),
|
||||
styleMode,
|
||||
});
|
||||
}
|
||||
|
||||
const id = String(sessionId || '');
|
||||
if (!id) throw workerError('worker_output_source_delta_session_missing');
|
||||
let merged = String(source || '');
|
||||
if (css) {
|
||||
const escapedId = escapeRegExp(id);
|
||||
const styleOpen = new RegExp(`<style\\b[^>]*\\bdata-impeccable-css=(?:"${escapedId}"|'${escapedId}')[^>]*>`, 'i');
|
||||
const styleMatch = styleOpen.exec(merged);
|
||||
if (!styleMatch) throw workerError('worker_output_source_delta_style_missing');
|
||||
const contentStart = styleMatch.index + styleMatch[0].length;
|
||||
const styleClose = merged.indexOf('</style>', contentStart);
|
||||
if (styleClose < 0) throw workerError('worker_output_source_delta_style_invalid');
|
||||
const styleContent = merged.slice(contentStart, styleClose);
|
||||
const lastTick = styleContent.lastIndexOf('`');
|
||||
const nextStyleContent = lastTick >= 0
|
||||
? styleContent.slice(0, lastTick).trimEnd() + '\n' + css + '\n' + styleContent.slice(lastTick)
|
||||
: styleContent.trimEnd() + '\n' + css + '\n';
|
||||
merged = merged.slice(0, contentStart) + nextStyleContent + merged.slice(styleClose);
|
||||
}
|
||||
return applySourceParams(merged, id, params, variantCount);
|
||||
}
|
||||
|
||||
function validateSourceDeltaCss(css, { variantIds, styleMode, requireVariantId = null }) {
|
||||
const allowed = new Set(variantIds.map(String));
|
||||
const refs = [...String(css).matchAll(/\[data-impeccable-variant=(?:"([^"]+)"|'([^']+)')\]/g)]
|
||||
@@ -730,6 +831,7 @@ export function publishCodexWorkerPhase({
|
||||
event,
|
||||
prepared,
|
||||
arrivedVariants,
|
||||
phase,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
const published = publishGenerationArtifact({
|
||||
@@ -740,6 +842,7 @@ export function publishCodexWorkerPhase({
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants,
|
||||
expectedVariants: Number(event.count || arrivedVariants),
|
||||
publicationKind: ['remainder', 'params', 'atomic'].includes(phase) ? 'params' : 'variants',
|
||||
cwd,
|
||||
});
|
||||
if (!published.ok) throw workerError(`publish_${published.error}`, published);
|
||||
@@ -757,6 +860,10 @@ export function codexWorkerStateIsOwned(state, cwd) {
|
||||
&& state.threadId.length > 0;
|
||||
}
|
||||
|
||||
export function isCodexComponentPreviewMode(value) {
|
||||
return value === 'svelte-component' || value === 'vue-component';
|
||||
}
|
||||
|
||||
export function codexWorkerProcessStateIsOwned(state, cwd) {
|
||||
return codexWorkerOwnerMatches(state, cwd)
|
||||
&& Number.isInteger(state?.pid)
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
export function buildGenerationPreflight(event, scriptsDir, { isolated = false } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -12,6 +12,7 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
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);
|
||||
@@ -26,8 +27,9 @@ export function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileSyncImpl = execFileSync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
isolated = false,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { isolated });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ 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');
|
||||
@@ -32,7 +36,9 @@ export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd()
|
||||
|
||||
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
|
||||
if (componentTarget?.error) return componentTarget;
|
||||
const sourcePath = componentTarget?.sourcePath || requestedPath;
|
||||
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, () => {
|
||||
@@ -43,6 +49,9 @@ export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd()
|
||||
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) {
|
||||
@@ -61,13 +70,17 @@ export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd()
|
||||
const extension = path.extname(sourcePath) || '.html';
|
||||
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
fs.writeFileSync(artifactPath, source, 'utf-8');
|
||||
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),
|
||||
};
|
||||
@@ -86,11 +99,15 @@ export function publishGenerationArtifact({
|
||||
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);
|
||||
@@ -100,6 +117,8 @@ export function publishGenerationArtifact({
|
||||
|
||||
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) {
|
||||
@@ -108,7 +127,7 @@ export function publishGenerationArtifact({
|
||||
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
|
||||
return failure('artifact_preview_mode_mismatch');
|
||||
}
|
||||
const sourcePath = componentTarget?.sourcePath || requestedPath;
|
||||
const sourcePath = componentTarget?.sourcePath || sourceArtifactTarget?.sourcePath || requestedPath;
|
||||
|
||||
try {
|
||||
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
|
||||
@@ -139,11 +158,15 @@ export function publishGenerationArtifact({
|
||||
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');
|
||||
@@ -155,7 +178,7 @@ export function publishGenerationArtifact({
|
||||
}
|
||||
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
|
||||
for (let variant = 1; variant <= priorArrived; variant++) {
|
||||
const currentVariant = extractVariantBlock(current, variant);
|
||||
const currentVariant = extractVariantBlock(stablePreview, variant);
|
||||
const artifactVariant = extractVariantBlock(artifact, variant);
|
||||
if (!currentVariant || !artifactVariant) {
|
||||
return failure('published_variant_missing', { variant });
|
||||
@@ -164,7 +187,7 @@ export function publishGenerationArtifact({
|
||||
return failure('published_variant_changed', { variant });
|
||||
}
|
||||
}
|
||||
const currentPreviewCss = extractPreviewCss(current, id);
|
||||
const currentPreviewCss = extractPreviewCss(stablePreview, id);
|
||||
const artifactPreviewCss = extractPreviewCss(artifact, id);
|
||||
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
|
||||
return failure('published_variant_css_changed');
|
||||
@@ -178,7 +201,8 @@ export function publishGenerationArtifact({
|
||||
return failure('stale_generation_epoch', { expectedEpoch: commitSnapshot?.generationEpoch || 1 });
|
||||
}
|
||||
const artifactHash = sha256(artifact);
|
||||
atomicReplace(sourcePath, artifact);
|
||||
const publishPath = sourceArtifactTarget?.previewPath || sourcePath;
|
||||
atomicReplace(publishPath, artifact);
|
||||
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
|
||||
store.appendEvent({
|
||||
type: 'variant_published',
|
||||
@@ -187,8 +211,13 @@ export function publishGenerationArtifact({
|
||||
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 {
|
||||
@@ -198,8 +227,13 @@ export function publishGenerationArtifact({
|
||||
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) {
|
||||
@@ -255,6 +289,7 @@ function publishComponentArtifact({
|
||||
sourcePath,
|
||||
arrivedVariants,
|
||||
expectedVariants,
|
||||
publicationKind,
|
||||
store,
|
||||
cwd,
|
||||
}) {
|
||||
@@ -372,6 +407,7 @@ function publishComponentArtifact({
|
||||
previewMode: target.manifest.previewMode,
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: expected || delivered,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
at: Date.now(),
|
||||
});
|
||||
return {
|
||||
@@ -386,6 +422,7 @@ function publishComponentArtifact({
|
||||
componentDir: relative(cwd, target.componentPath),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: expected || delivered,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +462,15 @@ function readComponentPublicationTarget(manifestPath, cwd, id) {
|
||||
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;
|
||||
|
||||
@@ -126,6 +126,8 @@ function baseSnapshot(id) {
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
browserCheckpointRevision: 0,
|
||||
publicationCheckpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
@@ -135,6 +137,7 @@ function baseSnapshot(id) {
|
||||
publishedRevision: 0,
|
||||
deliveredVariants: {},
|
||||
variantPlan: null,
|
||||
paramsPublished: false,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
@@ -205,6 +208,14 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
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({
|
||||
@@ -227,6 +238,7 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
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;
|
||||
@@ -278,18 +290,33 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
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':
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
Reference in New Issue
Block a user