mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
Add Svelte-native live mode adapter (#179)
* Fix live preview state for framework components * Complete stateful live preview coverage * Record Svelte manual validation * Fix Svelte live mode adapter * Fix live Steer apply flow * Fix Svelte live variant refresh recovery * Fix live exit bar teardown * Consolidate Svelte live DeepSeek sweep * Reconcile Svelte live browser after main rebase * Fix live accept review regressions * Fix carbonize column-zero indentation * Fix live poll lease expiry flake * Fix Svelte shader preview capture
This commit is contained in:
@@ -42,6 +42,10 @@ import {
|
||||
} from './live-manual-edits-buffer.mjs';
|
||||
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
|
||||
import { commitManualEdits } from './live-commit-manual-edits.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
removeAllSvelteComponentSessions,
|
||||
} from './live-svelte-component.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
@@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
|
||||
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
|
||||
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
|
||||
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
|
||||
const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
|
||||
const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
|
||||
|
||||
function tombstoneTimedOutApplyId(eventId, details = {}) {
|
||||
@@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) {
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
@@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) {
|
||||
const acknowledged = state.pendingEvents[idx].event;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return acknowledged;
|
||||
}
|
||||
|
||||
function findPendingEventById(id) {
|
||||
if (!id) return null;
|
||||
const entry = state.pendingEvents.find((item) => item.event?.id === id);
|
||||
return entry?.event || null;
|
||||
}
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
return `live-poll.mjs --reply ${id} done --data '<json>'`;
|
||||
@@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
return {
|
||||
id: snapshot.id,
|
||||
phase: snapshot.phase,
|
||||
pageUrl: snapshot.pageUrl ?? null,
|
||||
sourceFile: snapshot.sourceFile ?? null,
|
||||
previewFile: snapshot.previewFile ?? null,
|
||||
previewMode: snapshot.previewMode ?? null,
|
||||
expectedVariants: snapshot.expectedVariants ?? 0,
|
||||
arrivedVariants: snapshot.arrivedVariants ?? 0,
|
||||
visibleVariant: snapshot.visibleVariant ?? null,
|
||||
checkpointRevision: snapshot.checkpointRevision ?? 0,
|
||||
paramValues: snapshot.paramValues || {},
|
||||
};
|
||||
}
|
||||
|
||||
function activeSessionSummaries() {
|
||||
if (!state.sessionStore) return [];
|
||||
return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
|
||||
}
|
||||
|
||||
function cancelQueuedAnonymousExitEvents() {
|
||||
let removed = 0;
|
||||
for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = state.pendingEvents[i]?.event;
|
||||
if (event?.type !== 'exit' || event.id) continue;
|
||||
state.pendingEvents.splice(i, 1);
|
||||
removed += 1;
|
||||
}
|
||||
if (removed > 0) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
|
||||
const canceledById = new Map();
|
||||
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
|
||||
@@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
@@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() {
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
broadcastAgentPollingIfChanged();
|
||||
}, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
@@ -1032,7 +1082,9 @@ function flushPendingPolls() {
|
||||
}
|
||||
|
||||
function agentPollingConnected() {
|
||||
return state.pendingPolls.length > 0;
|
||||
const now = Date.now();
|
||||
return state.pendingPolls.length > 0
|
||||
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
|
||||
}
|
||||
|
||||
function broadcastAgentPollingIfChanged() {
|
||||
@@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
if (p === '/status') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
|
||||
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
const sessions = activeSessionSummaries();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
@@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
if (p === '/events' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
clearTimeout(state.exitTimer);
|
||||
state.exitTimer = null;
|
||||
cancelQueuedAnonymousExitEvents();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
type: 'connected',
|
||||
hasProjectContext: hasProjectContext(),
|
||||
agentPolling: agentPollingConnected(),
|
||||
activeSessions: activeSessionSummaries(),
|
||||
}) + '\n\n');
|
||||
|
||||
state.sseClients.add(res);
|
||||
clearTimeout(state.exitTimer);
|
||||
|
||||
// Keepalive: SSE comment every 30s prevents silent connection drops.
|
||||
const heartbeat = setInterval(() => {
|
||||
@@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
if (msg.type !== 'checkpoint') {
|
||||
enqueueEvent(msg);
|
||||
}
|
||||
@@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) {
|
||||
});
|
||||
}
|
||||
|
||||
function sessionFileMetadataFromPollReply(file) {
|
||||
if (!file || typeof file !== 'string') return { file };
|
||||
const normalized = file.split(path.sep).join('/');
|
||||
const base = { file: normalized };
|
||||
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
|
||||
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
|
||||
|
||||
let full;
|
||||
try {
|
||||
full = path.resolve(process.cwd(), normalized);
|
||||
const rel = path.relative(process.cwd(), full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
|
||||
return {
|
||||
file: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
previewFile: normalized,
|
||||
previewMode: 'svelte-component',
|
||||
};
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
@@ -1965,6 +2053,16 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
|
||||
return;
|
||||
}
|
||||
const pendingEventBeforeAck = findPendingEventById(msg.id);
|
||||
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
|
||||
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: 'steer_done_requires_file_or_message',
|
||||
hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
|
||||
let skipJournalReply = false;
|
||||
let existingSession = null;
|
||||
@@ -1987,6 +2085,7 @@ function handlePollPost(req, res) {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
|
||||
if (state.sessionStore && msg.id && !skipJournalReply) {
|
||||
try {
|
||||
const eventType = msg.type === 'steer_done'
|
||||
@@ -2001,7 +2100,10 @@ function handlePollPost(req, res) {
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
file: replyFileMeta.file,
|
||||
sourceFile: replyFileMeta.sourceFile,
|
||||
previewFile: replyFileMeta.previewFile,
|
||||
previewMode: replyFileMeta.previewMode,
|
||||
message: msg.message,
|
||||
sourceEventType: acknowledgedEvent?.type,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
@@ -2010,7 +2112,16 @@ function handlePollPost(req, res) {
|
||||
}
|
||||
flushPendingPolls();
|
||||
// Forward the reply to the browser via SSE
|
||||
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
|
||||
broadcast({
|
||||
type: msg.type || 'done',
|
||||
id: msg.id,
|
||||
message: msg.message,
|
||||
file: msg.file,
|
||||
sourceFile: replyFileMeta.sourceFile,
|
||||
previewFile: replyFileMeta.previewFile,
|
||||
previewMode: replyFileMeta.previewMode,
|
||||
data: msg.data,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -2023,6 +2134,7 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
@@ -2037,6 +2149,25 @@ function shutdown() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
try {
|
||||
removeAllSvelteComponentSessions(process.cwd());
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
try {
|
||||
const result = applyDeferredSvelteComponentAccepts(process.cwd());
|
||||
if (result.applied > 0 || result.failed > 0) {
|
||||
console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({
|
||||
cwd: process.cwd(),
|
||||
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
|
||||
});
|
||||
applyLegacyDeferredAcceptsOnStartup();
|
||||
restorePendingEventsFromStore();
|
||||
pruneStaleManualApplyEvidence(process.cwd());
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
|
||||
Reference in New Issue
Block a user