| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Live variant mode server (self-contained, zero dependencies). |
| 4 | * |
| 5 | * Serves the browser script (/live.js), the detection overlay (/detect.js), |
| 6 | * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for |
| 7 | * browser→server events. Agent communicates via HTTP long-poll (/poll). |
| 8 | * |
| 9 | * Usage: |
| 10 | * node <scripts_path>/live-server.mjs # start |
| 11 | * node <scripts_path>/live-server.mjs stop # stop + remove injected live.js tag |
| 12 | * node <scripts_path>/live-server.mjs stop --keep-inject # stop only |
| 13 | * node <scripts_path>/live-server.mjs --help |
| 14 | */ |
| 15 | |
| 16 | import http from 'node:http'; |
| 17 | import { randomUUID } from 'node:crypto'; |
| 18 | import { spawn, execFileSync } from 'node:child_process'; |
| 19 | import fs from 'node:fs'; |
| 20 | import path from 'node:path'; |
| 21 | import net from 'node:net'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | import { parseDesignMd } from './design-parser.mjs'; |
| 24 | import { resolveContextDir } from './context.mjs'; |
| 25 | import { createLiveSessionStore } from './live-session-store.mjs'; |
| 26 | import { validateEvent } from './live-event-validation.mjs'; |
| 27 | import { |
| 28 | getDesignSidecarPath, |
| 29 | getLiveDir, |
| 30 | getLiveAnnotationsDir, |
| 31 | readLiveServerInfo, |
| 32 | removeLiveServerInfo, |
| 33 | resolveDesignSidecarPath, |
| 34 | writeLiveServerInfo, |
| 35 | } from './impeccable-paths.mjs'; |
| 36 | import { |
| 37 | countByPage as countPendingByPage, |
| 38 | readBuffer as readManualEditsBuffer, |
| 39 | removeEntries as removeManualEditEntries, |
| 40 | stageEntry as stageManualEditEntry, |
| 41 | truncateBuffer as truncateManualEditsBuffer, |
| 42 | } from './live-manual-edits-buffer.mjs'; |
| 43 | import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; |
| 44 | import { commitManualEdits } from './live-commit-manual-edits.mjs'; |
| 45 | import { |
| 46 | applyDeferredSvelteComponentAccepts, |
| 47 | removeAllSvelteComponentSessions, |
| 48 | } from './live-svelte-component.mjs'; |
| 49 | |
| 50 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated |
| 52 | // DESIGN sidecar is project-local at .impeccable/design.json, with legacy |
| 53 | // DESIGN.json fallback for existing projects. |
| 54 | const CONTEXT_DIR = resolveContextDir(process.cwd()); |
| 55 | const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway |
| 56 | const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s |
| 57 | |
| 58 | // --------------------------------------------------------------------------- |
| 59 | // Port detection |
| 60 | // --------------------------------------------------------------------------- |
| 61 | |
| 62 | async function findOpenPort(start = 8400) { |
| 63 | return new Promise((resolve) => { |
| 64 | const srv = net.createServer(); |
| 65 | srv.listen(start, '127.0.0.1', () => { |
| 66 | const port = srv.address().port; |
| 67 | srv.close(() => resolve(port)); |
| 68 | }); |
| 69 | srv.on('error', () => resolve(findOpenPort(start + 1))); |
| 70 | }); |
| 71 | } |
| 72 | |
| 73 | // --------------------------------------------------------------------------- |
| 74 | // Session state |
| 75 | // --------------------------------------------------------------------------- |
| 76 | |
| 77 | const state = { |
| 78 | token: null, |
| 79 | port: null, |
| 80 | sseClients: new Set(), // SSE response objects (server→browser push) |
| 81 | pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil }) |
| 82 | pendingPolls: [], // agent poll callbacks waiting for browser events |
| 83 | nextEventSeq: 1, |
| 84 | lastAgentPollingBroadcast: null, |
| 85 | exitTimer: null, |
| 86 | sessionDir: null, // per-session tmp dir for annotation screenshots |
| 87 | sessionStore: null, |
| 88 | leaseTimer: null, |
| 89 | manualEditActivity: null, |
| 90 | nextManualEditSeq: 1, |
| 91 | // Deferreds for in-flight chat-routed Apply events. Keyed by event id; each |
| 92 | // entry is resolved when the chat agent POSTs an ack carrying the batch |
| 93 | // result, or rejected when the hard timeout fires. |
| 94 | pendingApplyDeferreds: new Map(), |
| 95 | // Updated whenever a /poll long-poll request arrives or is resolved with an |
| 96 | // event. Used to detect "a chat agent is likely attached" without requiring |
| 97 | // a poll to be parked at the exact moment we dispatch. |
| 98 | lastPollAt: 0, |
| 99 | timedOutApplyIds: new Map(), |
| 100 | }; |
| 101 | |
| 102 | const CHAT_POLL_FRESHNESS_MS = 60_000; |
| 103 | const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000); |
| 104 | const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000); |
| 105 | const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3; |
| 106 | const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; |
| 107 | const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; |
| 108 | const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; |
| 109 | const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; |
| 110 | const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; |
| 111 | const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); |
| 112 | |
| 113 | function tombstoneTimedOutApplyId(eventId, details = {}) { |
| 114 | if (!eventId) return; |
| 115 | state.timedOutApplyIds.set(eventId, details); |
| 116 | if (state.timedOutApplyIds.size <= 200) return; |
| 117 | const oldest = state.timedOutApplyIds.keys().next().value; |
| 118 | state.timedOutApplyIds.delete(oldest); |
| 119 | } |
| 120 | |
| 121 | function chatAgentLikelyActive() { |
| 122 | if (state.pendingPolls.length > 0) return true; |
| 123 | if (!state.lastPollAt) return false; |
| 124 | return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS; |
| 125 | } |
| 126 | |
| 127 | function manualEditApplyChunkSize(env = process.env) { |
| 128 | const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE); |
| 129 | if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE; |
| 130 | const size = Math.trunc(raw); |
| 131 | return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size)); |
| 132 | } |
| 133 | |
| 134 | function countManualApplyOps(entriesOrBatch) { |
| 135 | const entries = Array.isArray(entriesOrBatch) |
| 136 | ? entriesOrBatch |
| 137 | : Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : []; |
| 138 | let count = 0; |
| 139 | for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0; |
| 140 | return count; |
| 141 | } |
| 142 | |
| 143 | function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) { |
| 144 | const eventId = randomUUID().replace(/-/g, '').slice(0, 8); |
| 145 | const evidencePath = writeManualApplyEvidence(eventId, batch); |
| 146 | const event = { |
| 147 | type: 'manual_edit_apply', |
| 148 | id: eventId, |
| 149 | pageUrl, |
| 150 | batch: compactManualApplyBatch(batch), |
| 151 | evidencePath, |
| 152 | agentAction: buildManualApplyAgentAction(eventId), |
| 153 | schemaVersion: 1, |
| 154 | deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS, |
| 155 | }; |
| 156 | if (chunk) event.chunk = chunk; |
| 157 | if (repair) event.repair = repair; |
| 158 | const rollbackSnapshot = snapshotApplyEventFiles(batch); |
| 159 | recordManualEditActivity('manual_edit_apply_dispatched', { |
| 160 | id: eventId, |
| 161 | pageUrl, |
| 162 | chunk, |
| 163 | repair, |
| 164 | entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0, |
| 165 | opCount: countManualApplyOps(batch), |
| 166 | fileCount: collectManualApplyFiles(batch).length, |
| 167 | }); |
| 168 | return new Promise((resolve, reject) => { |
| 169 | const timer = setTimeout(() => { |
| 170 | state.pendingApplyDeferreds.delete(eventId); |
| 171 | tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot }); |
| 172 | acknowledgePendingEvent(eventId); |
| 173 | removeManualApplyEvidence(evidencePath); |
| 174 | recordManualEditActivity('manual_edit_apply_timeout', { |
| 175 | id: eventId, |
| 176 | pageUrl, |
| 177 | chunk, |
| 178 | entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0, |
| 179 | opCount: countManualApplyOps(batch), |
| 180 | }); |
| 181 | reject(new Error('chat_agent_timeout')); |
| 182 | }, APPLY_EVENT_HARD_TIMEOUT_MS); |
| 183 | state.pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot }); |
| 184 | enqueueEvent(event); |
| 185 | }); |
| 186 | } |
| 187 | |
| 188 | function writeManualApplyEvidence(eventId, batch) { |
| 189 | const dir = manualApplyEvidenceDir(process.cwd()); |
| 190 | fs.mkdirSync(dir, { recursive: true }); |
| 191 | const evidencePath = path.join(dir, `${eventId}.json`); |
| 192 | fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8'); |
| 193 | return evidencePath; |
| 194 | } |
| 195 | |
| 196 | function manualApplyEvidenceDir(cwd = process.cwd()) { |
| 197 | return path.join(getLiveDir(cwd), 'manual-edit-evidence'); |
| 198 | } |
| 199 | |
| 200 | function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) { |
| 201 | if (!evidencePath || typeof evidencePath !== 'string') return null; |
| 202 | const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath); |
| 203 | const evidenceDir = manualApplyEvidenceDir(cwd); |
| 204 | const relative = path.relative(evidenceDir, fullPath); |
| 205 | if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; |
| 206 | if (path.extname(relative) !== '.json') return null; |
| 207 | return fullPath; |
| 208 | } |
| 209 | |
| 210 | function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) { |
| 211 | const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd); |
| 212 | if (!fullPath) return false; |
| 213 | try { |
| 214 | fs.unlinkSync(fullPath); |
| 215 | return true; |
| 216 | } catch { |
| 217 | return false; |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | function referencedManualApplyEvidencePaths(cwd = process.cwd()) { |
| 222 | const referenced = new Set(); |
| 223 | const add = (event) => { |
| 224 | const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwd); |
| 225 | if (fullPath) referenced.add(fullPath); |
| 226 | }; |
| 227 | for (const entry of state.pendingEvents) add(entry.event); |
| 228 | for (const deferred of state.pendingApplyDeferreds.values()) add(deferred.event); |
| 229 | return referenced; |
| 230 | } |
| 231 | |
| 232 | function pruneStaleManualApplyEvidence(cwd = process.cwd()) { |
| 233 | const dir = manualApplyEvidenceDir(cwd); |
| 234 | if (!fs.existsSync(dir)) return []; |
| 235 | const referenced = referencedManualApplyEvidencePaths(cwd); |
| 236 | const removed = []; |
| 237 | for (const name of fs.readdirSync(dir)) { |
| 238 | if (!name.endsWith('.json')) continue; |
| 239 | const fullPath = path.join(dir, name); |
| 240 | if (referenced.has(fullPath)) continue; |
| 241 | try { |
| 242 | fs.unlinkSync(fullPath); |
| 243 | removed.push(fullPath); |
| 244 | } catch { |
| 245 | // Stale evidence cleanup is best-effort; Apply verification never relies |
| 246 | // on deleting these files. |
| 247 | } |
| 248 | } |
| 249 | return removed; |
| 250 | } |
| 251 | |
| 252 | function compactManualApplyBatch(batch = {}) { |
| 253 | const entries = (batch.entries || []).map(compactManualApplyEntry); |
| 254 | const candidates = compactManualApplyCandidates(batch.candidates || []); |
| 255 | return { |
| 256 | version: batch.version, |
| 257 | pageUrl: batch.pageUrl || null, |
| 258 | count: batch.count, |
| 259 | entries, |
| 260 | ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))), |
| 261 | candidates: candidates.length > 0 ? candidates : undefined, |
| 262 | context: batch.context ? { |
| 263 | bufferPath: batch.context.bufferPath, |
| 264 | totalEntries: batch.context.totalEntries, |
| 265 | totalOps: batch.context.totalOps, |
| 266 | chunkIndex: batch.context.chunkIndex, |
| 267 | chunkTotal: batch.context.chunkTotal, |
| 268 | totalApplyOps: batch.context.totalApplyOps, |
| 269 | } : undefined, |
| 270 | }; |
| 271 | } |
| 272 | |
| 273 | function compactManualApplyCandidates(candidates) { |
| 274 | return (Array.isArray(candidates) ? candidates : []) |
| 275 | .slice(0, 24) |
| 276 | .map((candidate) => ({ |
| 277 | entryId: candidate.entryId, |
| 278 | ref: candidate.ref, |
| 279 | sourceHint: compactManualApplySourceMatch(candidate.sourceHint), |
| 280 | textMatches: compactManualApplySourceMatches(candidate.textMatches, 8), |
| 281 | objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8), |
| 282 | contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8), |
| 283 | locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6), |
| 284 | })); |
| 285 | } |
| 286 | |
| 287 | function compactManualApplySourceMatches(matches, limit) { |
| 288 | return (Array.isArray(matches) ? matches : []) |
| 289 | .slice(0, limit) |
| 290 | .map(compactManualApplySourceMatch) |
| 291 | .filter(Boolean); |
| 292 | } |
| 293 | |
| 294 | function compactManualApplySourceMatch(match) { |
| 295 | if (!match || typeof match !== 'object') return null; |
| 296 | const file = match.relativeFile || match.file; |
| 297 | if (!file && !match.line) return null; |
| 298 | return { |
| 299 | file: summarizeManualLogFile(file), |
| 300 | line: match.line || null, |
| 301 | column: match.column || null, |
| 302 | reason: match.reason || match.kind || undefined, |
| 303 | status: match.status || undefined, |
| 304 | }; |
| 305 | } |
| 306 | |
| 307 | function compactManualApplyEntry(entry = {}) { |
| 308 | return { |
| 309 | id: entry.id, |
| 310 | pageUrl: entry.pageUrl, |
| 311 | stagedAt: entry.stagedAt || null, |
| 312 | element: compactManualApplyContext(entry.element), |
| 313 | ops: (entry.ops || []).map(compactManualApplyOp), |
| 314 | }; |
| 315 | } |
| 316 | |
| 317 | function compactManualApplyOp(op = {}) { |
| 318 | return { |
| 319 | entryId: op.entryId, |
| 320 | ref: op.ref, |
| 321 | contextRef: op.contextRef, |
| 322 | tag: op.tag, |
| 323 | elementId: op.elementId, |
| 324 | classes: Array.isArray(op.classes) ? op.classes : [], |
| 325 | originalText: op.originalText, |
| 326 | newText: op.newText, |
| 327 | deleted: op.deleted === true || undefined, |
| 328 | sourceHint: op.sourceHint || null, |
| 329 | leaf: compactManualApplyContext(op.leaf), |
| 330 | nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts), |
| 331 | container: compactManualApplyContext(op.container), |
| 332 | contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined, |
| 333 | }; |
| 334 | } |
| 335 | |
| 336 | function compactManualApplyContext(value) { |
| 337 | if (!value || typeof value !== 'object') return null; |
| 338 | return { |
| 339 | ref: value.ref, |
| 340 | tagName: value.tagName || value.tag || null, |
| 341 | id: value.id || null, |
| 342 | classes: Array.isArray(value.classes) ? value.classes : [], |
| 343 | textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT), |
| 344 | }; |
| 345 | } |
| 346 | |
| 347 | function compactNearbyManualEditTexts(items) { |
| 348 | return (Array.isArray(items) ? items : []) |
| 349 | .slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT) |
| 350 | .map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : { |
| 351 | ref: item?.ref, |
| 352 | tag: item?.tag, |
| 353 | classes: Array.isArray(item?.classes) ? item.classes : [], |
| 354 | text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT), |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | function truncateManualApplyText(value, max) { |
| 359 | if (typeof value !== 'string') return value || null; |
| 360 | return value.length > max ? value.slice(0, max) : value; |
| 361 | } |
| 362 | |
| 363 | async function pushApplyBatchInChunksAndWait(batch, pageUrl, context = {}) { |
| 364 | const repair = context?.repair || batch?.repair || null; |
| 365 | if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair); |
| 366 | const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize()); |
| 367 | if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl); |
| 368 | |
| 369 | const expectedOpsByEntry = new Map(); |
| 370 | for (const entry of batch?.entries || []) { |
| 371 | expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0); |
| 372 | } |
| 373 | |
| 374 | const appliedOpsByEntry = new Map(); |
| 375 | const failedByEntry = new Map(); |
| 376 | const files = new Set(); |
| 377 | const notes = []; |
| 378 | let aborted = false; |
| 379 | |
| 380 | for (const chunk of chunks) { |
| 381 | if (aborted) { |
| 382 | markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted'); |
| 383 | continue; |
| 384 | } |
| 385 | |
| 386 | let result; |
| 387 | try { |
| 388 | result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta)); |
| 389 | } catch (err) { |
| 390 | markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error'); |
| 391 | aborted = true; |
| 392 | continue; |
| 393 | } |
| 394 | |
| 395 | for (const file of result.files) files.add(file); |
| 396 | notes.push(...result.notes); |
| 397 | |
| 398 | const chunkFailedIds = new Set(); |
| 399 | for (const item of result.failed) { |
| 400 | const entryId = item.entryId || item.id; |
| 401 | if (!entryId) continue; |
| 402 | chunkFailedIds.add(entryId); |
| 403 | if (!failedByEntry.has(entryId)) { |
| 404 | failedByEntry.set(entryId, { |
| 405 | entryId, |
| 406 | reason: item.reason || item.message || 'failed', |
| 407 | candidates: Array.isArray(item.candidates) ? item.candidates : [], |
| 408 | }); |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | if (result.status === 'error') { |
| 413 | markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error'); |
| 414 | aborted = true; |
| 415 | continue; |
| 416 | } |
| 417 | |
| 418 | const reportedAppliedIds = new Set(result.appliedEntryIds); |
| 419 | for (const entryId of reportedAppliedIds) { |
| 420 | if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue; |
| 421 | appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0)); |
| 422 | } |
| 423 | |
| 424 | for (const entryId of chunk.entryIds) { |
| 425 | if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue; |
| 426 | if (!failedByEntry.has(entryId)) { |
| 427 | failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] }); |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | const appliedEntryIds = []; |
| 433 | for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) { |
| 434 | if (failedByEntry.has(entryId)) continue; |
| 435 | if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) { |
| 436 | appliedEntryIds.push(entryId); |
| 437 | } else if (!failedByEntry.has(entryId)) { |
| 438 | failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] }); |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | const failed = [...failedByEntry.values()]; |
| 443 | return { |
| 444 | status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error', |
| 445 | appliedEntryIds, |
| 446 | failed, |
| 447 | files: [...files], |
| 448 | notes, |
| 449 | }; |
| 450 | } |
| 451 | |
| 452 | function normalizeApplyChunkResult(result) { |
| 453 | const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done'; |
| 454 | return { |
| 455 | status, |
| 456 | message: typeof result?.message === 'string' ? result.message : null, |
| 457 | appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [], |
| 458 | failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [], |
| 459 | files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [], |
| 460 | notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [], |
| 461 | }; |
| 462 | } |
| 463 | |
| 464 | function manualApplyResultShapeHint(eventId = 'EVENT_ID') { |
| 465 | return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`; |
| 466 | } |
| 467 | |
| 468 | function invalidManualApplyResult(reason, eventId, extra = {}) { |
| 469 | return { |
| 470 | ok: false, |
| 471 | body: { |
| 472 | error: 'invalid_manual_apply_result', |
| 473 | reason, |
| 474 | hint: manualApplyResultShapeHint(eventId), |
| 475 | ...extra, |
| 476 | }, |
| 477 | }; |
| 478 | } |
| 479 | |
| 480 | function validateManualApplyResultMessage(msg, deferred) { |
| 481 | let data = msg?.data; |
| 482 | const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID'; |
| 483 | if (!data || typeof data !== 'object' || Array.isArray(data)) { |
| 484 | return invalidManualApplyResult('missing_result_data', eventId); |
| 485 | } |
| 486 | if ('entries' in data || 'ops' in data) { |
| 487 | return invalidManualApplyResult('summary_result_not_allowed', eventId); |
| 488 | } |
| 489 | if (!['done', 'partial', 'error'].includes(data.status)) { |
| 490 | return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null }); |
| 491 | } |
| 492 | |
| 493 | for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) { |
| 494 | if (!Array.isArray(data[key])) { |
| 495 | return invalidManualApplyResult(`${key}_must_be_array`, eventId); |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | for (const [index, value] of data.appliedEntryIds.entries()) { |
| 500 | if (typeof value !== 'string' || !value) { |
| 501 | return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index }); |
| 502 | } |
| 503 | } |
| 504 | for (const [index, value] of data.files.entries()) { |
| 505 | if (typeof value !== 'string' || !value) { |
| 506 | return invalidManualApplyResult('files_must_contain_strings', eventId, { index }); |
| 507 | } |
| 508 | } |
| 509 | for (const [index, value] of data.notes.entries()) { |
| 510 | if (typeof value !== 'string') { |
| 511 | return invalidManualApplyResult('notes_must_contain_strings', eventId, { index }); |
| 512 | } |
| 513 | } |
| 514 | for (const [index, item] of data.failed.entries()) { |
| 515 | if (!item || typeof item !== 'object' || Array.isArray(item)) { |
| 516 | return invalidManualApplyResult('failed_must_contain_objects', eventId, { index }); |
| 517 | } |
| 518 | if (typeof item.entryId !== 'string' || !item.entryId) { |
| 519 | return invalidManualApplyResult('failed_entryId_required', eventId, { index }); |
| 520 | } |
| 521 | if (typeof item.reason !== 'string' || !item.reason) { |
| 522 | return invalidManualApplyResult('failed_reason_required', eventId, { index }); |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean)); |
| 527 | for (const entryId of data.appliedEntryIds) { |
| 528 | if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) { |
| 529 | return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId }); |
| 530 | } |
| 531 | } |
| 532 | for (const item of data.failed) { |
| 533 | if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) { |
| 534 | return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId }); |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | if (data.status === 'done') { |
| 539 | if (data.failed.length > 0) { |
| 540 | return invalidManualApplyResult('done_result_has_failed_entries', eventId); |
| 541 | } |
| 542 | if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) { |
| 543 | return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId); |
| 544 | } |
| 545 | } |
| 546 | if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) { |
| 547 | return invalidManualApplyResult('partial_result_has_no_entries', eventId); |
| 548 | } |
| 549 | if (data.status === 'error' && data.appliedEntryIds.length > 0) { |
| 550 | return invalidManualApplyResult('error_result_has_applied_entries', eventId); |
| 551 | } |
| 552 | |
| 553 | return { |
| 554 | ok: true, |
| 555 | result: { |
| 556 | status: data.status, |
| 557 | message: typeof data.message === 'string' ? data.message : undefined, |
| 558 | appliedEntryIds: data.appliedEntryIds, |
| 559 | failed: data.failed, |
| 560 | files: data.files, |
| 561 | notes: data.notes, |
| 562 | }, |
| 563 | }; |
| 564 | } |
| 565 | |
| 566 | function firstFailureReason(result) { |
| 567 | const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null; |
| 568 | return first?.reason || first?.message || null; |
| 569 | } |
| 570 | |
| 571 | function markChunkEntriesFailed(failedByEntry, chunk, reason) { |
| 572 | for (const entryId of chunk.entryIds) { |
| 573 | if (failedByEntry.has(entryId)) continue; |
| 574 | failedByEntry.set(entryId, { entryId, reason, candidates: [] }); |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | function splitManualApplyBatch(batch, maxOps) { |
| 579 | const totalOpCount = countManualApplyOps(batch); |
| 580 | if (totalOpCount <= maxOps) { |
| 581 | return [{ |
| 582 | batch, |
| 583 | meta: null, |
| 584 | entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)), |
| 585 | opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])), |
| 586 | }]; |
| 587 | } |
| 588 | |
| 589 | const rawChunks = []; |
| 590 | let current = createManualApplyChunkBuilder(); |
| 591 | for (const entry of batch?.entries || []) { |
| 592 | const ops = entry.ops || []; |
| 593 | if (ops.length <= maxOps) { |
| 594 | if (current.opCount > 0 && current.opCount + ops.length > maxOps) { |
| 595 | rawChunks.push(current); |
| 596 | current = createManualApplyChunkBuilder(); |
| 597 | } |
| 598 | for (const op of ops) addOpToManualApplyChunk(current, entry, op); |
| 599 | continue; |
| 600 | } |
| 601 | if (current.opCount > 0) { |
| 602 | rawChunks.push(current); |
| 603 | current = createManualApplyChunkBuilder(); |
| 604 | } |
| 605 | for (const op of ops) { |
| 606 | if (current.opCount >= maxOps) { |
| 607 | rawChunks.push(current); |
| 608 | current = createManualApplyChunkBuilder(); |
| 609 | } |
| 610 | addOpToManualApplyChunk(current, entry, op); |
| 611 | } |
| 612 | } |
| 613 | if (current.opCount > 0) rawChunks.push(current); |
| 614 | |
| 615 | return rawChunks.map((chunk, index) => ({ |
| 616 | batch: { |
| 617 | ...batch, |
| 618 | count: chunk.opCount, |
| 619 | entries: chunk.entries, |
| 620 | ops: chunk.ops, |
| 621 | candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry), |
| 622 | context: { |
| 623 | ...(batch?.context || {}), |
| 624 | totalEntries: chunk.entries.length, |
| 625 | totalOps: chunk.opCount, |
| 626 | chunkIndex: index + 1, |
| 627 | chunkTotal: rawChunks.length, |
| 628 | totalApplyOps: totalOpCount, |
| 629 | }, |
| 630 | }, |
| 631 | meta: { |
| 632 | index: index + 1, |
| 633 | total: rawChunks.length, |
| 634 | opCount: chunk.opCount, |
| 635 | totalOpCount, |
| 636 | }, |
| 637 | entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)), |
| 638 | opCountsByEntry: chunk.opCountsByEntry, |
| 639 | })); |
| 640 | } |
| 641 | |
| 642 | function createManualApplyChunkBuilder() { |
| 643 | return { |
| 644 | entries: [], |
| 645 | entryById: new Map(), |
| 646 | entryIds: new Set(), |
| 647 | ops: [], |
| 648 | refsByEntry: new Map(), |
| 649 | opCountsByEntry: new Map(), |
| 650 | opCount: 0, |
| 651 | }; |
| 652 | } |
| 653 | |
| 654 | function addOpToManualApplyChunk(chunk, entry, op) { |
| 655 | let chunkEntry = chunk.entryById.get(entry.id); |
| 656 | if (!chunkEntry) { |
| 657 | chunkEntry = { ...entry, ops: [] }; |
| 658 | chunk.entryById.set(entry.id, chunkEntry); |
| 659 | chunk.entryIds.add(entry.id); |
| 660 | chunk.entries.push(chunkEntry); |
| 661 | } |
| 662 | chunkEntry.ops.push(op); |
| 663 | chunk.ops.push({ ...op, entryId: op.entryId || entry.id }); |
| 664 | if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set()); |
| 665 | if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref); |
| 666 | chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1); |
| 667 | chunk.opCount += 1; |
| 668 | } |
| 669 | |
| 670 | function filterManualApplyChunkCandidates(batch, refsByEntry) { |
| 671 | return (batch?.candidates || []).filter((candidate) => { |
| 672 | const refs = refsByEntry.get(candidate.entryId); |
| 673 | if (!refs) return false; |
| 674 | if (!candidate.ref) return true; |
| 675 | return refs.has(candidate.ref); |
| 676 | }); |
| 677 | } |
| 678 | |
| 679 | function resolveApplyDeferred(eventId, body) { |
| 680 | const deferred = state.pendingApplyDeferreds.get(eventId); |
| 681 | if (!deferred) return false; |
| 682 | state.pendingApplyDeferreds.delete(eventId); |
| 683 | clearTimeout(deferred.timer); |
| 684 | removeManualApplyEvidence(deferred.event?.evidencePath); |
| 685 | deferred.resolve(body); |
| 686 | return true; |
| 687 | } |
| 688 | |
| 689 | function rejectApplyDeferred(eventId, reason) { |
| 690 | const deferred = state.pendingApplyDeferreds.get(eventId); |
| 691 | if (!deferred) return false; |
| 692 | state.pendingApplyDeferreds.delete(eventId); |
| 693 | clearTimeout(deferred.timer); |
| 694 | removeManualApplyEvidence(deferred.event?.evidencePath); |
| 695 | deferred.reject(new Error(reason || 'chat_agent_error')); |
| 696 | return true; |
| 697 | } |
| 698 | |
| 699 | function snapshotApplyEventFiles(batch) { |
| 700 | const snapshot = new Map(); |
| 701 | for (const relativeFile of collectManualApplyFiles(batch)) { |
| 702 | const absolute = path.resolve(process.cwd(), relativeFile); |
| 703 | try { |
| 704 | snapshot.set(relativeFile, { |
| 705 | exists: fs.existsSync(absolute), |
| 706 | content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '', |
| 707 | }); |
| 708 | } catch { |
| 709 | // If a file cannot be read before dispatch, do not attempt late rollback. |
| 710 | } |
| 711 | } |
| 712 | return snapshot; |
| 713 | } |
| 714 | |
| 715 | function manualApplyTransactionPath(cwd = process.cwd()) { |
| 716 | return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json'); |
| 717 | } |
| 718 | |
| 719 | function readManualApplyTransaction(cwd = process.cwd()) { |
| 720 | const file = manualApplyTransactionPath(cwd); |
| 721 | if (!fs.existsSync(file)) return null; |
| 722 | try { |
| 723 | return JSON.parse(fs.readFileSync(file, 'utf-8')); |
| 724 | } catch { |
| 725 | return null; |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) { |
| 730 | const file = manualApplyTransactionPath(cwd); |
| 731 | const files = collectManualApplyFiles(batch); |
| 732 | const transaction = { |
| 733 | version: 1, |
| 734 | id: randomUUID().replace(/-/g, '').slice(0, 8), |
| 735 | createdAt: new Date().toISOString(), |
| 736 | pageUrl, |
| 737 | entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean), |
| 738 | files: files.map((relativeFile) => { |
| 739 | const absolute = path.resolve(cwd, relativeFile); |
| 740 | const exists = fs.existsSync(absolute); |
| 741 | return { |
| 742 | file: relativeFile, |
| 743 | exists, |
| 744 | content: exists ? fs.readFileSync(absolute, 'utf-8') : '', |
| 745 | }; |
| 746 | }), |
| 747 | }; |
| 748 | fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 749 | fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8'); |
| 750 | fs.renameSync(`${file}.tmp`, file); |
| 751 | return transaction; |
| 752 | } |
| 753 | |
| 754 | function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) { |
| 755 | const file = manualApplyTransactionPath(cwd); |
| 756 | if (!fs.existsSync(file)) return false; |
| 757 | if (transactionId) { |
| 758 | const existing = readManualApplyTransaction(cwd); |
| 759 | if (existing?.id && existing.id !== transactionId) return false; |
| 760 | } |
| 761 | try { |
| 762 | fs.unlinkSync(file); |
| 763 | return true; |
| 764 | } catch { |
| 765 | return false; |
| 766 | } |
| 767 | } |
| 768 | |
| 769 | function rollbackManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, reason = 'manual_edit_transaction_rollback' } = {}) { |
| 770 | const transaction = readManualApplyTransaction(cwd); |
| 771 | if (!transaction) return null; |
| 772 | if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null; |
| 773 | |
| 774 | let pendingIds = new Set(); |
| 775 | try { |
| 776 | const buffer = readManualEditsBuffer(cwd); |
| 777 | pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean)); |
| 778 | } catch { |
| 779 | pendingIds = new Set(transaction.entryIds || []); |
| 780 | } |
| 781 | const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id)); |
| 782 | if (!shouldRollback) { |
| 783 | clearManualApplyTransaction(cwd, transaction.id); |
| 784 | return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' }; |
| 785 | } |
| 786 | |
| 787 | const rolledBackFiles = []; |
| 788 | const rollbackFailures = []; |
| 789 | for (const item of transaction.files || []) { |
| 790 | const relativeFile = normalizeProjectFile(item.file); |
| 791 | if (!relativeFile) continue; |
| 792 | const absolute = path.resolve(cwd, relativeFile); |
| 793 | try { |
| 794 | if (item.exists) { |
| 795 | fs.mkdirSync(path.dirname(absolute), { recursive: true }); |
| 796 | fs.writeFileSync(absolute, item.content || '', 'utf-8'); |
| 797 | } else if (fs.existsSync(absolute)) { |
| 798 | fs.rmSync(absolute); |
| 799 | } |
| 800 | rolledBackFiles.push(relativeFile); |
| 801 | } catch (err) { |
| 802 | rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) }); |
| 803 | } |
| 804 | } |
| 805 | clearManualApplyTransaction(cwd, transaction.id); |
| 806 | recordManualEditActivity('manual_edit_transaction_rolled_back', { |
| 807 | id: transaction.id, |
| 808 | pageUrl: transaction.pageUrl || null, |
| 809 | reason, |
| 810 | entryIds: transaction.entryIds || [], |
| 811 | rolledBackFiles: rolledBackFiles.map(summarizeManualLogFile).filter(Boolean), |
| 812 | rollbackFailures: summarizeManualDiagnostics(rollbackFailures), |
| 813 | }); |
| 814 | return { id: transaction.id, reason, rolledBackFiles, rollbackFailures }; |
| 815 | } |
| 816 | |
| 817 | function collectManualApplyFiles(batch, extraFiles = []) { |
| 818 | const files = []; |
| 819 | for (const entry of batch?.entries || []) { |
| 820 | for (const op of entry.ops || []) files.push(op.sourceHint?.file); |
| 821 | } |
| 822 | for (const candidate of batch?.candidates || []) { |
| 823 | files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file); |
| 824 | for (const item of candidate.textMatches || []) files.push(item.file); |
| 825 | for (const item of candidate.objectKeyMatches || []) files.push(item.file); |
| 826 | for (const item of candidate.locatorMatches || []) files.push(item.file); |
| 827 | for (const item of candidate.contextTextMatches || []) files.push(item.file); |
| 828 | } |
| 829 | files.push(...(extraFiles || [])); |
| 830 | return [...new Set(files)] |
| 831 | .map((file) => normalizeProjectFile(file)) |
| 832 | .filter(Boolean); |
| 833 | } |
| 834 | |
| 835 | function normalizeProjectFile(file) { |
| 836 | if (!file || typeof file !== 'string') return null; |
| 837 | const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file); |
| 838 | const relative = path.relative(process.cwd(), absolute); |
| 839 | if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null; |
| 840 | return relative; |
| 841 | } |
| 842 | |
| 843 | function rollbackApplySnapshot(batch, rollbackSnapshot, extraFiles = [], reason = 'manual_edit_apply_snapshot_rollback') { |
| 844 | const scope = collectManualApplyFiles(batch, extraFiles); |
| 845 | const rolledBackFiles = []; |
| 846 | const rollbackFailures = []; |
| 847 | for (const relativeFile of scope) { |
| 848 | const before = rollbackSnapshot?.get(relativeFile); |
| 849 | if (!before) continue; |
| 850 | const absolute = path.resolve(process.cwd(), relativeFile); |
| 851 | try { |
| 852 | if (before.exists) { |
| 853 | fs.mkdirSync(path.dirname(absolute), { recursive: true }); |
| 854 | fs.writeFileSync(absolute, before.content, 'utf-8'); |
| 855 | } else if (fs.existsSync(absolute)) { |
| 856 | fs.rmSync(absolute); |
| 857 | } |
| 858 | rolledBackFiles.push(relativeFile); |
| 859 | } catch (err) { |
| 860 | rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) }); |
| 861 | } |
| 862 | } |
| 863 | return { rolledBackFiles, rollbackFailures }; |
| 864 | } |
| 865 | |
| 866 | function rollbackTimedOutApplyReply(msg) { |
| 867 | const details = state.timedOutApplyIds.get(msg.id); |
| 868 | if (!details) return { rolledBackFiles: [], rollbackFailures: [] }; |
| 869 | state.timedOutApplyIds.delete(msg.id); |
| 870 | return rollbackApplySnapshot(details.batch, details.rollbackSnapshot, msg.data?.files || [], 'stale_manual_edit_apply_reply'); |
| 871 | } |
| 872 | |
| 873 | // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; |
| 874 | // cap at 10 MB to guard against runaway writes from a misbehaving client. |
| 875 | const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; |
| 876 | |
| 877 | function enqueueEvent(event) { |
| 878 | if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return; |
| 879 | state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ }); |
| 880 | flushPendingPolls(); |
| 881 | } |
| 882 | |
| 883 | function restorePendingEventsFromStore() { |
| 884 | if (!state.sessionStore) return; |
| 885 | for (const snapshot of state.sessionStore.listActiveSessions()) { |
| 886 | if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent); |
| 887 | } |
| 888 | } |
| 889 | |
| 890 | function findAvailablePendingEvent(now = Date.now()) { |
| 891 | for (const entry of state.pendingEvents) { |
| 892 | if (entry.leaseUntil && entry.leaseUntil > now) continue; |
| 893 | return entry; |
| 894 | } |
| 895 | return null; |
| 896 | } |
| 897 | |
| 898 | function leaseEvent(entry, leaseMs) { |
| 899 | if (!entry.event?.id) { |
| 900 | const idx = state.pendingEvents.indexOf(entry); |
| 901 | if (idx !== -1) state.pendingEvents.splice(idx, 1); |
| 902 | return entry.event; |
| 903 | } |
| 904 | entry.leaseUntil = Date.now() + leaseMs; |
| 905 | scheduleLeaseFlush(); |
| 906 | broadcastAgentPollingIfChanged(); |
| 907 | return entry.event; |
| 908 | } |
| 909 | |
| 910 | function acknowledgePendingEvent(id) { |
| 911 | if (!id) return false; |
| 912 | const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id); |
| 913 | if (idx === -1) return false; |
| 914 | const acknowledged = state.pendingEvents[idx].event; |
| 915 | state.pendingEvents.splice(idx, 1); |
| 916 | scheduleLeaseFlush(); |
| 917 | broadcastAgentPollingIfChanged(); |
| 918 | return acknowledged; |
| 919 | } |
| 920 | |
| 921 | function findPendingEventById(id) { |
| 922 | if (!id) return null; |
| 923 | const entry = state.pendingEvents.find((item) => item.event?.id === id); |
| 924 | return entry?.event || null; |
| 925 | } |
| 926 | |
| 927 | function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { |
| 928 | const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; |
| 929 | return `live-poll.mjs --reply ${id} done --data '<json>'`; |
| 930 | } |
| 931 | |
| 932 | function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') { |
| 933 | return { |
| 934 | kind: 'manual_edit_apply', |
| 935 | required: 'apply_source_edits_then_reply', |
| 936 | replyCommand: manualApplyReplyCommand(eventOrId), |
| 937 | warning: 'Polling only leases this work item; it does not commit source edits.', |
| 938 | }; |
| 939 | } |
| 940 | |
| 941 | function summarizeManualApplyEvent(event = {}, batch = event.batch) { |
| 942 | const entries = Array.isArray(batch?.entries) ? batch.entries : []; |
| 943 | const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0); |
| 944 | return { |
| 945 | pageUrl: event.pageUrl || null, |
| 946 | chunk: event.chunk || null, |
| 947 | entryCount: entries.length, |
| 948 | opCount, |
| 949 | files: collectManualApplyFiles(batch), |
| 950 | }; |
| 951 | } |
| 952 | |
| 953 | function summarizePendingEventForStatus(entry) { |
| 954 | const event = entry.event || {}; |
| 955 | const summary = { |
| 956 | id: event.id, |
| 957 | type: event.type, |
| 958 | leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()), |
| 959 | leaseUntil: entry.leaseUntil || null, |
| 960 | }; |
| 961 | if (event.type === 'manual_edit_apply') { |
| 962 | summary.pageUrl = event.pageUrl || null; |
| 963 | summary.chunk = event.chunk || null; |
| 964 | summary.repair = event.repair || null; |
| 965 | summary.evidencePath = event.evidencePath || null; |
| 966 | summary.agentAction = event.agentAction || buildManualApplyAgentAction(event); |
| 967 | summary.manualApplySummary = summarizeManualApplyEvent(event, state.pendingApplyDeferreds.get(event.id)?.batch || event.batch); |
| 968 | } |
| 969 | return summary; |
| 970 | } |
| 971 | |
| 972 | function summarizeActiveSessionForClient(snapshot = {}) { |
| 973 | return { |
| 974 | id: snapshot.id, |
| 975 | phase: snapshot.phase, |
| 976 | pageUrl: snapshot.pageUrl ?? null, |
| 977 | sourceFile: snapshot.sourceFile ?? null, |
| 978 | previewFile: snapshot.previewFile ?? null, |
| 979 | previewMode: snapshot.previewMode ?? null, |
| 980 | expectedVariants: snapshot.expectedVariants ?? 0, |
| 981 | arrivedVariants: snapshot.arrivedVariants ?? 0, |
| 982 | visibleVariant: snapshot.visibleVariant ?? null, |
| 983 | checkpointRevision: snapshot.checkpointRevision ?? 0, |
| 984 | paramValues: snapshot.paramValues || {}, |
| 985 | }; |
| 986 | } |
| 987 | |
| 988 | function activeSessionSummaries() { |
| 989 | if (!state.sessionStore) return []; |
| 990 | return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); |
| 991 | } |
| 992 | |
| 993 | function cancelQueuedAnonymousExitEvents() { |
| 994 | let removed = 0; |
| 995 | for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { |
| 996 | const event = state.pendingEvents[i]?.event; |
| 997 | if (event?.type !== 'exit' || event.id) continue; |
| 998 | state.pendingEvents.splice(i, 1); |
| 999 | removed += 1; |
| 1000 | } |
| 1001 | if (removed > 0) { |
| 1002 | scheduleLeaseFlush(); |
| 1003 | broadcastAgentPollingIfChanged(); |
| 1004 | } |
| 1005 | return removed; |
| 1006 | } |
| 1007 | |
| 1008 | function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { |
| 1009 | const canceledById = new Map(); |
| 1010 | const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); |
| 1011 | |
| 1012 | for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { |
| 1013 | const event = state.pendingEvents[i]?.event; |
| 1014 | if (!shouldCancel(event)) continue; |
| 1015 | state.pendingEvents.splice(i, 1); |
| 1016 | removeManualApplyEvidence(event.evidencePath); |
| 1017 | canceledById.set(event.id, { |
| 1018 | id: event.id, |
| 1019 | pageUrl: event.pageUrl, |
| 1020 | entryCount: event.batch?.entries?.length || 0, |
| 1021 | }); |
| 1022 | } |
| 1023 | |
| 1024 | for (const [eventId, deferred] of [...state.pendingApplyDeferreds.entries()]) { |
| 1025 | if (!shouldCancel(deferred.event)) continue; |
| 1026 | state.pendingApplyDeferreds.delete(eventId); |
| 1027 | clearTimeout(deferred.timer); |
| 1028 | const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason); |
| 1029 | tombstoneTimedOutApplyId(eventId, { |
| 1030 | batch: deferred.batch, |
| 1031 | rollbackSnapshot: deferred.rollbackSnapshot, |
| 1032 | reason, |
| 1033 | }); |
| 1034 | removeManualApplyEvidence(deferred.event?.evidencePath); |
| 1035 | canceledById.set(eventId, { |
| 1036 | id: eventId, |
| 1037 | pageUrl: deferred.pageUrl, |
| 1038 | entryCount: deferred.batch?.entries?.length || 0, |
| 1039 | rolledBackFiles: rollback.rolledBackFiles, |
| 1040 | rollbackFailures: rollback.rollbackFailures, |
| 1041 | }); |
| 1042 | deferred.reject(new Error(reason)); |
| 1043 | } |
| 1044 | |
| 1045 | if (canceledById.size > 0) flushPendingPolls(); |
| 1046 | return [...canceledById.values()]; |
| 1047 | } |
| 1048 | |
| 1049 | function scheduleLeaseFlush() { |
| 1050 | if (state.leaseTimer) { |
| 1051 | clearTimeout(state.leaseTimer); |
| 1052 | state.leaseTimer = null; |
| 1053 | } |
| 1054 | const now = Date.now(); |
| 1055 | const nextLeaseUntil = state.pendingEvents |
| 1056 | .map((entry) => entry.leaseUntil || 0) |
| 1057 | .filter((leaseUntil) => leaseUntil > now) |
| 1058 | .sort((a, b) => a - b)[0]; |
| 1059 | if (!nextLeaseUntil) return; |
| 1060 | state.leaseTimer = setTimeout(() => { |
| 1061 | state.leaseTimer = null; |
| 1062 | flushPendingPolls(); |
| 1063 | broadcastAgentPollingIfChanged(); |
| 1064 | }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); |
| 1065 | } |
| 1066 | |
| 1067 | function flushPendingPolls() { |
| 1068 | let changed = false; |
| 1069 | while (state.pendingPolls.length > 0) { |
| 1070 | const entry = findAvailablePendingEvent(); |
| 1071 | if (!entry) { |
| 1072 | scheduleLeaseFlush(); |
| 1073 | broadcastAgentPollingIfChanged(); |
| 1074 | return; |
| 1075 | } |
| 1076 | const poll = state.pendingPolls.shift(); |
| 1077 | poll.resolve(leaseEvent(entry, poll.leaseMs)); |
| 1078 | changed = true; |
| 1079 | } |
| 1080 | scheduleLeaseFlush(); |
| 1081 | if (changed) broadcastAgentPollingIfChanged(); |
| 1082 | } |
| 1083 | |
| 1084 | function agentPollingConnected() { |
| 1085 | const now = Date.now(); |
| 1086 | return state.pendingPolls.length > 0 |
| 1087 | || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); |
| 1088 | } |
| 1089 | |
| 1090 | function broadcastAgentPollingIfChanged() { |
| 1091 | const connected = agentPollingConnected(); |
| 1092 | if (state.lastAgentPollingBroadcast === connected) return; |
| 1093 | state.lastAgentPollingBroadcast = connected; |
| 1094 | broadcast({ type: 'agent_polling', connected }); |
| 1095 | } |
| 1096 | |
| 1097 | /** Push a message to all connected SSE clients. */ |
| 1098 | function broadcast(msg) { |
| 1099 | const data = 'data: ' + JSON.stringify(msg) + '\n\n'; |
| 1100 | for (const res of state.sseClients) { |
| 1101 | try { res.write(data); } catch { /* client gone */ } |
| 1102 | } |
| 1103 | } |
| 1104 | |
| 1105 | function recordManualEditActivity(type, details = {}) { |
| 1106 | const entry = { |
| 1107 | seq: state.nextManualEditSeq++, |
| 1108 | type, |
| 1109 | ts: new Date().toISOString(), |
| 1110 | ...details, |
| 1111 | }; |
| 1112 | state.manualEditActivity = entry; |
| 1113 | if (DEBUG_MANUAL_EDIT_EVENTS) { |
| 1114 | try { |
| 1115 | const filePath = path.join(getLiveDir(process.cwd()), 'manual-edit-events.jsonl'); |
| 1116 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
| 1117 | fs.appendFileSync(filePath, JSON.stringify(entry) + '\n'); |
| 1118 | } catch { |
| 1119 | /* diagnostics are best-effort; never block live mode on observability */ |
| 1120 | } |
| 1121 | } |
| 1122 | broadcast(entry); |
| 1123 | return entry; |
| 1124 | } |
| 1125 | |
| 1126 | function getManualEditStatus() { |
| 1127 | try { |
| 1128 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1129 | return { totalCount, perPage, lastActivity: state.manualEditActivity }; |
| 1130 | } catch (err) { |
| 1131 | return { |
| 1132 | totalCount: null, |
| 1133 | perPage: {}, |
| 1134 | lastActivity: state.manualEditActivity, |
| 1135 | error: err.message, |
| 1136 | }; |
| 1137 | } |
| 1138 | } |
| 1139 | |
| 1140 | function summarizePendingManualEditBatch(pageUrl = null) { |
| 1141 | try { |
| 1142 | const buffer = readManualEditsBuffer(process.cwd()); |
| 1143 | const entries = (buffer.entries || []) |
| 1144 | .filter((entry) => !pageUrl || entry.pageUrl === pageUrl); |
| 1145 | return { |
| 1146 | pendingEntryCount: entries.length, |
| 1147 | pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0), |
| 1148 | }; |
| 1149 | } catch (err) { |
| 1150 | return { pendingSummaryError: err.message || String(err) }; |
| 1151 | } |
| 1152 | } |
| 1153 | |
| 1154 | function summarizeManualApplyFailures(failed) { |
| 1155 | if (!Array.isArray(failed)) return []; |
| 1156 | return failed.slice(0, 20).map((item) => ({ |
| 1157 | id: item.id || item.entryId || null, |
| 1158 | reason: item.reason || item.message || 'failed', |
| 1159 | message: compactManualLogText(item.message, 300), |
| 1160 | files: Array.isArray(item.files) ? item.files.slice(0, 12).map(summarizeManualLogFile).filter(Boolean) : undefined, |
| 1161 | checks: summarizeManualDiagnostics(item.checks), |
| 1162 | failures: summarizeManualDiagnostics(item.failures), |
| 1163 | candidates: summarizeManualDiagnostics(item.candidates), |
| 1164 | })); |
| 1165 | } |
| 1166 | |
| 1167 | function summarizeManualDiagnostics(items) { |
| 1168 | if (!Array.isArray(items) || items.length === 0) return undefined; |
| 1169 | return items.slice(0, 12).map((item) => ({ |
| 1170 | reason: item.reason || item.kind || undefined, |
| 1171 | detail: compactManualLogText(item.detail, 220), |
| 1172 | message: compactManualLogText(item.message, 300), |
| 1173 | file: summarizeManualLogFile(item.file || item.relativeFile), |
| 1174 | line: item.line || undefined, |
| 1175 | ref: compactManualLogText(item.ref, 180), |
| 1176 | marker: compactManualLogText(item.marker, 120), |
| 1177 | files: Array.isArray(item.files) ? item.files.slice(0, 8).map(summarizeManualLogFile).filter(Boolean) : undefined, |
| 1178 | })); |
| 1179 | } |
| 1180 | |
| 1181 | function summarizeManualLogFile(file) { |
| 1182 | if (!file || typeof file !== 'string') return undefined; |
| 1183 | if (!path.isAbsolute(file)) return file; |
| 1184 | const relative = path.relative(process.cwd(), file); |
| 1185 | return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file; |
| 1186 | } |
| 1187 | |
| 1188 | function compactManualLogText(value, max = 200) { |
| 1189 | if (typeof value !== 'string') return undefined; |
| 1190 | const normalized = value.replace(/\s+/g, ' ').trim(); |
| 1191 | if (normalized.length <= max) return normalized; |
| 1192 | return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`; |
| 1193 | } |
| 1194 | |
| 1195 | // --------------------------------------------------------------------------- |
| 1196 | // Load scripts |
| 1197 | // --------------------------------------------------------------------------- |
| 1198 | |
| 1199 | function loadBrowserScripts() { |
| 1200 | // Detection script: prefer the skill-bundled detector, then fall back to |
| 1201 | // source/npm package locations for local development and older installs. |
| 1202 | // This one IS cached — detect.js rarely changes during a session. |
| 1203 | const detectPaths = [ |
| 1204 | path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'), |
| 1205 | path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'), |
| 1206 | path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'), |
| 1207 | path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'), |
| 1208 | ]; |
| 1209 | let detectScript = ''; |
| 1210 | for (const p of detectPaths) { |
| 1211 | try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } |
| 1212 | } |
| 1213 | |
| 1214 | // live-browser.js: DO NOT cache. Return the path so the /live.js handler |
| 1215 | // can re-read on every request. Editing the browser script during iteration |
| 1216 | // should land on the next tab reload, not require a server restart. |
| 1217 | const sessionPath = path.join(__dirname, 'live-browser-session.js'); |
| 1218 | const livePath = path.join(__dirname, 'live-browser.js'); |
| 1219 | for (const p of [sessionPath, livePath]) { |
| 1220 | if (!fs.existsSync(p)) { |
| 1221 | process.stderr.write('Error: live browser script not found at ' + p + '\n'); |
| 1222 | process.exit(1); |
| 1223 | } |
| 1224 | } |
| 1225 | |
| 1226 | return { detectScript, sessionPath, livePath }; |
| 1227 | } |
| 1228 | |
| 1229 | function hasProjectContext() { |
| 1230 | // PRODUCT.md carries brand voice / anti-references — that's what determines |
| 1231 | // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate |
| 1232 | // concern, surfaced by the design panel's own empty state. |
| 1233 | try { |
| 1234 | fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK); |
| 1235 | return true; |
| 1236 | } catch { return false; } |
| 1237 | } |
| 1238 | |
| 1239 | function statOrNull(filePath) { |
| 1240 | try { return fs.statSync(filePath); } catch { return null; } |
| 1241 | } |
| 1242 | |
| 1243 | // HTTP request handler |
| 1244 | // --------------------------------------------------------------------------- |
| 1245 | |
| 1246 | function createRequestHandler({ detectScript, sessionPath, livePath }) { |
| 1247 | return (req, res) => { |
| 1248 | const url = new URL(req.url, `http://localhost:${state.port}`); |
| 1249 | res.setHeader('Access-Control-Allow-Origin', '*'); |
| 1250 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); |
| 1251 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); |
| 1252 | if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } |
| 1253 | |
| 1254 | const p = url.pathname; |
| 1255 | |
| 1256 | // --- Scripts --- |
| 1257 | if (p === '/live.js') { |
| 1258 | // Re-read from disk each request so edits to live-browser.js land on |
| 1259 | // the next tab reload. No-store headers prevent browser caching across |
| 1260 | // sessions — during iteration, a cached old script silently breaks |
| 1261 | // every subsequent session. |
| 1262 | let sessionScript; |
| 1263 | let liveScript; |
| 1264 | try { |
| 1265 | sessionScript = fs.readFileSync(sessionPath, 'utf-8'); |
| 1266 | liveScript = fs.readFileSync(livePath, 'utf-8'); |
| 1267 | } catch (err) { |
| 1268 | res.writeHead(500, { 'Content-Type': 'text/plain' }); |
| 1269 | res.end('Error reading live browser scripts: ' + err.message); |
| 1270 | return; |
| 1271 | } |
| 1272 | const body = |
| 1273 | `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + |
| 1274 | `window.__IMPECCABLE_PORT__ = ${state.port};\n` + |
| 1275 | sessionScript + '\n' + |
| 1276 | liveScript; |
| 1277 | res.writeHead(200, { |
| 1278 | 'Content-Type': 'application/javascript', |
| 1279 | 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', |
| 1280 | 'Pragma': 'no-cache', |
| 1281 | }); |
| 1282 | res.end(body); |
| 1283 | return; |
| 1284 | } |
| 1285 | if (p === '/detect.js' || p === '/') { |
| 1286 | if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } |
| 1287 | res.writeHead(200, { 'Content-Type': 'application/javascript' }); |
| 1288 | res.end(detectScript); |
| 1289 | return; |
| 1290 | } |
| 1291 | |
| 1292 | // --- Vendored modern-screenshot (UMD build) --- |
| 1293 | // Lazy-loaded by live.js when the user clicks Go; exposes |
| 1294 | // window.modernScreenshot.domToBlob(...) for capture. |
| 1295 | if (p === '/modern-screenshot.js') { |
| 1296 | const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); |
| 1297 | try { |
| 1298 | res.writeHead(200, { |
| 1299 | 'Content-Type': 'application/javascript', |
| 1300 | 'Cache-Control': 'public, max-age=31536000, immutable', |
| 1301 | }); |
| 1302 | res.end(fs.readFileSync(vendorPath)); |
| 1303 | } catch { |
| 1304 | res.writeHead(404); res.end('Vendor script not found'); |
| 1305 | } |
| 1306 | return; |
| 1307 | } |
| 1308 | |
| 1309 | // --- Annotation upload (browser → server, raw PNG body) --- |
| 1310 | // Client generates the eventId, POSTs the PNG, then POSTs the generate |
| 1311 | // event with screenshotPath already set. Keeps bytes out of the SSE/poll |
| 1312 | // bridge and preserves the "one shot from the user's POV" UX. |
| 1313 | if (p === '/annotation' && req.method === 'POST') { |
| 1314 | const token = url.searchParams.get('token'); |
| 1315 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1316 | const eventId = url.searchParams.get('eventId'); |
| 1317 | if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { |
| 1318 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1319 | res.end(JSON.stringify({ error: 'Invalid eventId' })); |
| 1320 | return; |
| 1321 | } |
| 1322 | if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { |
| 1323 | res.writeHead(415, { 'Content-Type': 'application/json' }); |
| 1324 | res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); |
| 1325 | return; |
| 1326 | } |
| 1327 | if (!state.sessionDir) { |
| 1328 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1329 | res.end(JSON.stringify({ error: 'Session dir unavailable' })); |
| 1330 | return; |
| 1331 | } |
| 1332 | const chunks = []; |
| 1333 | let total = 0; |
| 1334 | let aborted = false; |
| 1335 | req.on('data', (c) => { |
| 1336 | if (aborted) return; |
| 1337 | total += c.length; |
| 1338 | if (total > MAX_ANNOTATION_BYTES) { |
| 1339 | aborted = true; |
| 1340 | res.writeHead(413, { 'Content-Type': 'application/json' }); |
| 1341 | res.end(JSON.stringify({ error: 'Payload too large' })); |
| 1342 | req.destroy(); |
| 1343 | return; |
| 1344 | } |
| 1345 | chunks.push(c); |
| 1346 | }); |
| 1347 | req.on('end', () => { |
| 1348 | if (aborted) return; |
| 1349 | const absPath = path.join(state.sessionDir, eventId + '.png'); |
| 1350 | try { |
| 1351 | fs.writeFileSync(absPath, Buffer.concat(chunks)); |
| 1352 | } catch (err) { |
| 1353 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1354 | res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); |
| 1355 | return; |
| 1356 | } |
| 1357 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1358 | res.end(JSON.stringify({ ok: true, path: absPath })); |
| 1359 | }); |
| 1360 | req.on('error', () => { |
| 1361 | if (!aborted) { |
| 1362 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1363 | res.end(JSON.stringify({ error: 'Upload failed' })); |
| 1364 | } |
| 1365 | }); |
| 1366 | return; |
| 1367 | } |
| 1368 | |
| 1369 | // --- Health --- |
| 1370 | if (p === '/status') { |
| 1371 | const token = url.searchParams.get('token'); |
| 1372 | if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } |
| 1373 | const sessions = activeSessionSummaries(); |
| 1374 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1375 | res.end(JSON.stringify({ |
| 1376 | status: 'ok', |
| 1377 | port: state.port, |
| 1378 | connectedClients: state.sseClients.size, |
| 1379 | pendingEvents: state.pendingEvents.map((entry) => summarizePendingEventForStatus(entry)), |
| 1380 | agentPolling: agentPollingConnected(), |
| 1381 | activeSessions: sessions, |
| 1382 | manualEdits: getManualEditStatus(), |
| 1383 | })); |
| 1384 | return; |
| 1385 | } |
| 1386 | |
| 1387 | if (p === '/health') { |
| 1388 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1389 | res.end(JSON.stringify({ |
| 1390 | status: 'ok', port: state.port, mode: 'variant', |
| 1391 | hasProjectContext: hasProjectContext(), |
| 1392 | connectedClients: state.sseClients.size, |
| 1393 | })); |
| 1394 | return; |
| 1395 | } |
| 1396 | |
| 1397 | // --- Design system (unified v2 response) + raw --- |
| 1398 | // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json |
| 1399 | // sidecar when present. Panel merges them: |
| 1400 | // { present, parsed, sidecar, hasMd, hasSidecar, |
| 1401 | // mdNewerThanJson, parseError?, sidecarError? } |
| 1402 | // - parsed: output of parseDesignMd (frontmatter |
| 1403 | // + six canonical sections) when DESIGN.md exists. |
| 1404 | // - sidecar: .impeccable/design.json contents when present. |
| 1405 | // Expected shape: schemaVersion 2, carrying |
| 1406 | // extensions + components + narrative. |
| 1407 | // /design-system/raw returns DESIGN.md markdown verbatim |
| 1408 | if (p === '/design-system.json' || p === '/design-system/raw') { |
| 1409 | const token = url.searchParams.get('token'); |
| 1410 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1411 | |
| 1412 | const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md'); |
| 1413 | const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); |
| 1414 | const mdStat = statOrNull(mdPath); |
| 1415 | const jsonStat = statOrNull(jsonPath); |
| 1416 | |
| 1417 | if (p === '/design-system/raw') { |
| 1418 | if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } |
| 1419 | res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); |
| 1420 | res.end(fs.readFileSync(mdPath, 'utf-8')); |
| 1421 | return; |
| 1422 | } |
| 1423 | |
| 1424 | if (!mdStat && !jsonStat) { |
| 1425 | res.writeHead(404, { 'Content-Type': 'application/json' }); |
| 1426 | res.end(JSON.stringify({ present: false })); |
| 1427 | return; |
| 1428 | } |
| 1429 | |
| 1430 | const response = { |
| 1431 | present: true, |
| 1432 | hasMd: !!mdStat, |
| 1433 | hasSidecar: !!jsonStat, |
| 1434 | mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), |
| 1435 | }; |
| 1436 | |
| 1437 | if (mdStat) { |
| 1438 | try { |
| 1439 | response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); |
| 1440 | } catch (err) { |
| 1441 | response.parseError = err.message; |
| 1442 | } |
| 1443 | } |
| 1444 | |
| 1445 | if (jsonStat) { |
| 1446 | try { |
| 1447 | response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); |
| 1448 | } catch (err) { |
| 1449 | response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message; |
| 1450 | } |
| 1451 | } |
| 1452 | |
| 1453 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1454 | res.end(JSON.stringify(response)); |
| 1455 | return; |
| 1456 | } |
| 1457 | |
| 1458 | // --- Source file (no-HMR fallback) --- |
| 1459 | if (p === '/source') { |
| 1460 | const token = url.searchParams.get('token'); |
| 1461 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1462 | const filePath = url.searchParams.get('path'); |
| 1463 | if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } |
| 1464 | const absPath = path.resolve(process.cwd(), filePath); |
| 1465 | if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } |
| 1466 | let content; |
| 1467 | try { content = fs.readFileSync(absPath, 'utf-8'); } |
| 1468 | catch { res.writeHead(404); res.end('File not found'); return; } |
| 1469 | res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); |
| 1470 | res.end(content); |
| 1471 | return; |
| 1472 | } |
| 1473 | |
| 1474 | // --- SSE: server→browser push (replaces WebSocket) --- |
| 1475 | if (p === '/events' && req.method === 'GET') { |
| 1476 | const token = url.searchParams.get('token'); |
| 1477 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1478 | clearTimeout(state.exitTimer); |
| 1479 | state.exitTimer = null; |
| 1480 | cancelQueuedAnonymousExitEvents(); |
| 1481 | res.writeHead(200, { |
| 1482 | 'Content-Type': 'text/event-stream', |
| 1483 | 'Cache-Control': 'no-cache', |
| 1484 | 'Connection': 'keep-alive', |
| 1485 | }); |
| 1486 | res.write('data: ' + JSON.stringify({ |
| 1487 | type: 'connected', |
| 1488 | hasProjectContext: hasProjectContext(), |
| 1489 | agentPolling: agentPollingConnected(), |
| 1490 | activeSessions: activeSessionSummaries(), |
| 1491 | }) + '\n\n'); |
| 1492 | |
| 1493 | state.sseClients.add(res); |
| 1494 | |
| 1495 | // Keepalive: SSE comment every 30s prevents silent connection drops. |
| 1496 | const heartbeat = setInterval(() => { |
| 1497 | try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } |
| 1498 | }, SSE_HEARTBEAT_INTERVAL); |
| 1499 | |
| 1500 | req.on('close', () => { |
| 1501 | clearInterval(heartbeat); |
| 1502 | state.sseClients.delete(res); |
| 1503 | if (state.sseClients.size === 0) { |
| 1504 | clearTimeout(state.exitTimer); |
| 1505 | state.exitTimer = setTimeout(() => { |
| 1506 | if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); |
| 1507 | }, 8000); |
| 1508 | } |
| 1509 | }); |
| 1510 | return; |
| 1511 | } |
| 1512 | |
| 1513 | // --- Manual copy edits: Save stages entries, Apply commits the staged |
| 1514 | // page batch through the local AI copy-edit runner. |
| 1515 | if (p === '/manual-edit-stash' && req.method === 'POST') { |
| 1516 | let body = ''; |
| 1517 | req.on('data', (c) => { body += c; }); |
| 1518 | req.on('end', () => { |
| 1519 | let msg; |
| 1520 | try { msg = JSON.parse(body); } catch { |
| 1521 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1522 | res.end(JSON.stringify({ error: 'Invalid JSON' })); |
| 1523 | return; |
| 1524 | } |
| 1525 | if (msg.token !== state.token) { |
| 1526 | res.writeHead(401, { 'Content-Type': 'application/json' }); |
| 1527 | res.end(JSON.stringify({ error: 'Unauthorized' })); |
| 1528 | return; |
| 1529 | } |
| 1530 | const error = validateEvent({ ...msg, type: 'manual_edits' }); |
| 1531 | if (error) { |
| 1532 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1533 | res.end(JSON.stringify({ error })); |
| 1534 | return; |
| 1535 | } |
| 1536 | try { |
| 1537 | stageManualEditEntry(process.cwd(), { |
| 1538 | id: msg.id, |
| 1539 | pageUrl: msg.pageUrl, |
| 1540 | element: msg.element, |
| 1541 | ops: msg.ops, |
| 1542 | }); |
| 1543 | } catch (err) { |
| 1544 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1545 | res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message })); |
| 1546 | return; |
| 1547 | } |
| 1548 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1549 | const pendingCount = perPage[msg.pageUrl] || 0; |
| 1550 | recordManualEditActivity('manual_edit_stashed', { |
| 1551 | id: msg.id, |
| 1552 | pageUrl: msg.pageUrl, |
| 1553 | opCount: msg.ops.length, |
| 1554 | pendingCount, |
| 1555 | totalCount, |
| 1556 | hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size, |
| 1557 | }); |
| 1558 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1559 | res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage })); |
| 1560 | }); |
| 1561 | return; |
| 1562 | } |
| 1563 | |
| 1564 | // GET /manual-edit-stash?pageUrl=<url> → { count, totalCount, perPage, entries } |
| 1565 | if (p === '/manual-edit-stash' && req.method === 'GET') { |
| 1566 | const token = url.searchParams.get('token'); |
| 1567 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1568 | const pageUrl = url.searchParams.get('pageUrl') || ''; |
| 1569 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1570 | const buffer = readManualEditsBuffer(process.cwd()); |
| 1571 | const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries; |
| 1572 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1573 | res.end(JSON.stringify({ |
| 1574 | count: pageUrl ? (perPage[pageUrl] || 0) : totalCount, |
| 1575 | totalCount, |
| 1576 | perPage, |
| 1577 | entries: entriesForPage, |
| 1578 | })); |
| 1579 | return; |
| 1580 | } |
| 1581 | |
| 1582 | // POST /manual-edit-commit?pageUrl=<url> → ask the AI to apply the staged page batch. |
| 1583 | if (p === '/manual-edit-commit' && req.method === 'POST') { |
| 1584 | const token = url.searchParams.get('token'); |
| 1585 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1586 | const pageUrl = url.searchParams.get('pageUrl'); |
| 1587 | const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || ''); |
| 1588 | const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || ''); |
| 1589 | const existingTransaction = readManualApplyTransaction(process.cwd()); |
| 1590 | if (repairOnly && !existingTransaction) { |
| 1591 | res.writeHead(409, { 'Content-Type': 'application/json' }); |
| 1592 | res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' })); |
| 1593 | return; |
| 1594 | } |
| 1595 | const recoveredTransaction = repairOnly ? null : rollbackManualApplyTransaction({ |
| 1596 | cwd: process.cwd(), |
| 1597 | pageUrl, |
| 1598 | reason: 'manual_edit_commit_recovered_abandoned_transaction', |
| 1599 | }); |
| 1600 | const before = getManualEditStatus(); |
| 1601 | const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount; |
| 1602 | recordManualEditActivity('manual_edit_commit_started', { |
| 1603 | pageUrl, |
| 1604 | repairOnly, |
| 1605 | pendingCount, |
| 1606 | totalCount: before.totalCount, |
| 1607 | recoveredTransaction: recoveredTransaction ? { |
| 1608 | id: recoveredTransaction.id, |
| 1609 | reason: recoveredTransaction.reason, |
| 1610 | skipped: recoveredTransaction.skipped, |
| 1611 | rolledBackFiles: recoveredTransaction.rolledBackFiles, |
| 1612 | rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures), |
| 1613 | } : null, |
| 1614 | ...summarizePendingManualEditBatch(pageUrl), |
| 1615 | }); |
| 1616 | if (asyncMode) { |
| 1617 | res.writeHead(202, { 'Content-Type': 'application/json' }); |
| 1618 | res.end(JSON.stringify({ |
| 1619 | status: 'started', |
| 1620 | pendingCount, |
| 1621 | totalCount: before.totalCount, |
| 1622 | perPage: before.perPage, |
| 1623 | })); |
| 1624 | } |
| 1625 | (async () => { |
| 1626 | let result; |
| 1627 | let routedProvider = 'subprocess'; |
| 1628 | let transaction = null; |
| 1629 | let commitBatch = null; |
| 1630 | try { |
| 1631 | if (pendingCount > 0) { |
| 1632 | const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl }); |
| 1633 | commitBatch = transactionBatch; |
| 1634 | if (!repairOnly && countManualApplyOps(transactionBatch) > 0) { |
| 1635 | transaction = writeManualApplyTransaction({ |
| 1636 | cwd: process.cwd(), |
| 1637 | pageUrl, |
| 1638 | batch: transactionBatch, |
| 1639 | }); |
| 1640 | } else if (repairOnly && existingTransaction) { |
| 1641 | transaction = existingTransaction; |
| 1642 | } |
| 1643 | } |
| 1644 | const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase(); |
| 1645 | const useChatRoute = requestedMode === 'chat' |
| 1646 | || (requestedMode === 'auto' && chatAgentLikelyActive()); |
| 1647 | if (useChatRoute) { |
| 1648 | routedProvider = 'chat'; |
| 1649 | const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); |
| 1650 | result = await commitManualEdits({ |
| 1651 | cwd: process.cwd(), |
| 1652 | pageUrl, |
| 1653 | provider: 'chat', |
| 1654 | env: process.env, |
| 1655 | timeoutMs, |
| 1656 | chatAvailable: chatAgentLikelyActive, |
| 1657 | applyBatchToSource: (batch, context) => pushApplyBatchInChunksAndWait(batch, pageUrl, context), |
| 1658 | repairOnly, |
| 1659 | transactionId: transaction?.id || existingTransaction?.id || null, |
| 1660 | batch: commitBatch, |
| 1661 | }); |
| 1662 | } else { |
| 1663 | const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000); |
| 1664 | const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined; |
| 1665 | result = await commitManualEdits({ |
| 1666 | cwd: process.cwd(), |
| 1667 | pageUrl, |
| 1668 | provider, |
| 1669 | env: process.env, |
| 1670 | timeoutMs, |
| 1671 | chatAvailable: chatAgentLikelyActive, |
| 1672 | repairOnly, |
| 1673 | transactionId: transaction?.id || existingTransaction?.id || null, |
| 1674 | batch: commitBatch, |
| 1675 | }); |
| 1676 | } |
| 1677 | } catch (err) { |
| 1678 | if (transaction) { |
| 1679 | rollbackManualApplyTransaction({ |
| 1680 | cwd: process.cwd(), |
| 1681 | pageUrl, |
| 1682 | reason: 'manual_edit_commit_exception', |
| 1683 | }); |
| 1684 | } |
| 1685 | const message = err.stderr?.toString?.() || err.message; |
| 1686 | recordManualEditActivity('manual_edit_commit_failed', { |
| 1687 | pageUrl, |
| 1688 | provider: routedProvider, |
| 1689 | error: 'manual_edit_commit_failed', |
| 1690 | message, |
| 1691 | transactionId: transaction?.id || null, |
| 1692 | }); |
| 1693 | if (!asyncMode) { |
| 1694 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1695 | res.end(JSON.stringify({ |
| 1696 | error: 'manual_edit_commit_failed', |
| 1697 | message, |
| 1698 | })); |
| 1699 | } |
| 1700 | return; |
| 1701 | } finally { |
| 1702 | if (transaction) { |
| 1703 | const shouldKeepTransaction = result?.needsManualDecision === true; |
| 1704 | if (!shouldKeepTransaction) clearManualApplyTransaction(process.cwd(), transaction.id); |
| 1705 | } |
| 1706 | } |
| 1707 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1708 | if (result?.needsManualDecision) { |
| 1709 | recordManualEditActivity('manual_edit_repair_needs_decision', { |
| 1710 | pageUrl, |
| 1711 | provider: routedProvider, |
| 1712 | transactionId: transaction?.id || existingTransaction?.id || null, |
| 1713 | repair: result.repair || null, |
| 1714 | failed: summarizeManualApplyFailures(result.failed), |
| 1715 | files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], |
| 1716 | remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, |
| 1717 | totalCount, |
| 1718 | }); |
| 1719 | } else { |
| 1720 | recordManualEditActivity('manual_edit_commit_done', { |
| 1721 | pageUrl, |
| 1722 | provider: routedProvider, |
| 1723 | reason: result.reason || null, |
| 1724 | repair: result.repair || null, |
| 1725 | appliedCount: Array.isArray(result.applied) ? result.applied.length : 0, |
| 1726 | failedCount: Array.isArray(result.failed) ? result.failed.length : 0, |
| 1727 | failed: summarizeManualApplyFailures(result.failed), |
| 1728 | files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], |
| 1729 | warnings: summarizeManualDiagnostics(result.warnings), |
| 1730 | rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [], |
| 1731 | rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures), |
| 1732 | unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined, |
| 1733 | noteCount: Array.isArray(result.notes) ? result.notes.length : 0, |
| 1734 | cleared: result.cleared || 0, |
| 1735 | remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, |
| 1736 | totalCount, |
| 1737 | }); |
| 1738 | } |
| 1739 | if (!asyncMode) { |
| 1740 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1741 | res.end(JSON.stringify({ ...result, totalCount, perPage })); |
| 1742 | } |
| 1743 | })(); |
| 1744 | return; |
| 1745 | } |
| 1746 | |
| 1747 | // POST /manual-edit-repair-decision → user resolves an exhausted repair loop. |
| 1748 | if (p === '/manual-edit-repair-decision' && req.method === 'POST') { |
| 1749 | let body = ''; |
| 1750 | req.on('data', (chunk) => { body += chunk; }); |
| 1751 | req.on('end', () => { |
| 1752 | let payload = {}; |
| 1753 | try { payload = body ? JSON.parse(body) : {}; } catch { |
| 1754 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1755 | res.end(JSON.stringify({ error: 'Invalid JSON' })); |
| 1756 | return; |
| 1757 | } |
| 1758 | const token = payload.token || url.searchParams.get('token'); |
| 1759 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1760 | const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null; |
| 1761 | const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase(); |
| 1762 | if (action !== 'rollback') { |
| 1763 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1764 | res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action })); |
| 1765 | return; |
| 1766 | } |
| 1767 | const rollback = rollbackManualApplyTransaction({ |
| 1768 | cwd: process.cwd(), |
| 1769 | pageUrl, |
| 1770 | reason: 'manual_edit_user_requested_rollback', |
| 1771 | }); |
| 1772 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1773 | const response = { |
| 1774 | action, |
| 1775 | pageUrl, |
| 1776 | rollback, |
| 1777 | remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount, |
| 1778 | totalCount, |
| 1779 | perPage, |
| 1780 | }; |
| 1781 | recordManualEditActivity('manual_edit_repair_rollback_done', response); |
| 1782 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1783 | res.end(JSON.stringify(response)); |
| 1784 | }); |
| 1785 | return; |
| 1786 | } |
| 1787 | |
| 1788 | // POST /manual-edit-discard?pageUrl=<url> → drops entries (all if no pageUrl) |
| 1789 | if (p === '/manual-edit-discard' && req.method === 'POST') { |
| 1790 | const token = url.searchParams.get('token'); |
| 1791 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1792 | const pageUrl = url.searchParams.get('pageUrl'); |
| 1793 | let discarded; |
| 1794 | let discardedEntries = []; |
| 1795 | let canceledApplyEvents = []; |
| 1796 | let transactionRollback = null; |
| 1797 | try { |
| 1798 | const buffer = readManualEditsBuffer(process.cwd()); |
| 1799 | transactionRollback = rollbackManualApplyTransaction({ |
| 1800 | cwd: process.cwd(), |
| 1801 | pageUrl, |
| 1802 | reason: 'manual_edit_discarded', |
| 1803 | }); |
| 1804 | if (pageUrl) { |
| 1805 | discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl); |
| 1806 | discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl); |
| 1807 | } else { |
| 1808 | discardedEntries = buffer.entries; |
| 1809 | discarded = truncateManualEditsBuffer(process.cwd()); |
| 1810 | } |
| 1811 | canceledApplyEvents = cancelPendingManualApplyEvents(pageUrl); |
| 1812 | } catch (err) { |
| 1813 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1814 | res.end(JSON.stringify({ error: 'discard_failed', message: err.message })); |
| 1815 | return; |
| 1816 | } |
| 1817 | const { totalCount, perPage } = countPendingByPage(process.cwd()); |
| 1818 | recordManualEditActivity('manual_edit_discarded', { |
| 1819 | pageUrl, |
| 1820 | discarded, |
| 1821 | canceledApplyIds: canceledApplyEvents.map((event) => event.id), |
| 1822 | transactionRollback: transactionRollback ? { |
| 1823 | id: transactionRollback.id, |
| 1824 | rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [], |
| 1825 | rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures), |
| 1826 | skipped: transactionRollback.skipped, |
| 1827 | } : undefined, |
| 1828 | totalCount, |
| 1829 | }); |
| 1830 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1831 | res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage })); |
| 1832 | return; |
| 1833 | } |
| 1834 | |
| 1835 | // Defense in depth: redirect any stragglers from the old /manual-edit endpoint. |
| 1836 | if (p === '/manual-edit' && req.method === 'POST') { |
| 1837 | res.writeHead(410, { 'Content-Type': 'application/json' }); |
| 1838 | res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' })); |
| 1839 | return; |
| 1840 | } |
| 1841 | |
| 1842 | // --- Browser→server events (replaces WebSocket messages) --- |
| 1843 | if (p === '/events' && req.method === 'POST') { |
| 1844 | let body = ''; |
| 1845 | req.on('data', (c) => { body += c; }); |
| 1846 | req.on('end', () => { |
| 1847 | let msg; |
| 1848 | try { msg = JSON.parse(body); } catch { |
| 1849 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1850 | res.end(JSON.stringify({ error: 'Invalid JSON' })); |
| 1851 | return; |
| 1852 | } |
| 1853 | if (msg.token !== state.token) { |
| 1854 | res.writeHead(401, { 'Content-Type': 'application/json' }); |
| 1855 | res.end(JSON.stringify({ error: 'Unauthorized' })); |
| 1856 | return; |
| 1857 | } |
| 1858 | // Defense in depth: manual copy edits must use the staged stash/apply |
| 1859 | // endpoints. The direct Save event path is disabled in the browser. |
| 1860 | if (msg.type === 'manual_edits') { |
| 1861 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1862 | res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit-stash, not /events' })); |
| 1863 | return; |
| 1864 | } |
| 1865 | if (msg.type === 'manual_edit_apply') { |
| 1866 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1867 | res.end(JSON.stringify({ error: 'manual_edit_apply is disabled; use /manual-edit-stash then /manual-edit-commit' })); |
| 1868 | return; |
| 1869 | } |
| 1870 | const error = validateEvent(msg); |
| 1871 | if (error) { |
| 1872 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 1873 | res.end(JSON.stringify({ error })); |
| 1874 | return; |
| 1875 | } |
| 1876 | if (state.sessionStore && msg.id) { |
| 1877 | try { |
| 1878 | state.sessionStore.appendEvent(msg); |
| 1879 | } catch (err) { |
| 1880 | res.writeHead(500, { 'Content-Type': 'application/json' }); |
| 1881 | res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message })); |
| 1882 | return; |
| 1883 | } |
| 1884 | } |
| 1885 | if (msg.type === 'exit') { |
| 1886 | cleanupSvelteComponentSessionsBeforeExit(); |
| 1887 | } |
| 1888 | if (msg.type !== 'checkpoint') { |
| 1889 | enqueueEvent(msg); |
| 1890 | } |
| 1891 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1892 | res.end(JSON.stringify({ ok: true })); |
| 1893 | }); |
| 1894 | return; |
| 1895 | } |
| 1896 | |
| 1897 | // --- Stop --- |
| 1898 | if (p === '/stop') { |
| 1899 | const token = url.searchParams.get('token'); |
| 1900 | if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } |
| 1901 | res.writeHead(200, { 'Content-Type': 'text/plain' }); |
| 1902 | res.end('stopping'); |
| 1903 | shutdown(); |
| 1904 | return; |
| 1905 | } |
| 1906 | |
| 1907 | // --- Agent poll --- |
| 1908 | if (p === '/poll' && req.method === 'GET') { |
| 1909 | handlePollGet(req, res, url); |
| 1910 | return; |
| 1911 | } |
| 1912 | if (p === '/poll' && req.method === 'POST') { |
| 1913 | handlePollPost(req, res); |
| 1914 | return; |
| 1915 | } |
| 1916 | |
| 1917 | res.writeHead(404); res.end('Not found'); |
| 1918 | }; |
| 1919 | } |
| 1920 | |
| 1921 | // --------------------------------------------------------------------------- |
| 1922 | // Agent poll endpoints (unchanged from WS version) |
| 1923 | // --------------------------------------------------------------------------- |
| 1924 | |
| 1925 | function handlePollGet(req, res, url) { |
| 1926 | const token = url.searchParams.get('token'); |
| 1927 | if (token !== state.token) { |
| 1928 | res.writeHead(401, { 'Content-Type': 'application/json' }); |
| 1929 | res.end(JSON.stringify({ error: 'Unauthorized' })); |
| 1930 | return; |
| 1931 | } |
| 1932 | state.lastPollAt = Date.now(); |
| 1933 | const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); |
| 1934 | const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10); |
| 1935 | const available = findAvailablePendingEvent(); |
| 1936 | if (available) { |
| 1937 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1938 | res.end(JSON.stringify(leaseEvent(available, leaseMs))); |
| 1939 | return; |
| 1940 | } |
| 1941 | const poll = { resolve, leaseMs }; |
| 1942 | const timer = setTimeout(() => { |
| 1943 | const idx = state.pendingPolls.indexOf(poll); |
| 1944 | if (idx !== -1) state.pendingPolls.splice(idx, 1); |
| 1945 | broadcastAgentPollingIfChanged(); |
| 1946 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1947 | res.end(JSON.stringify({ type: 'timeout' })); |
| 1948 | }, timeout); |
| 1949 | function resolve(event) { |
| 1950 | clearTimeout(timer); |
| 1951 | state.lastPollAt = Date.now(); |
| 1952 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 1953 | res.end(JSON.stringify(event)); |
| 1954 | } |
| 1955 | state.pendingPolls.push(poll); |
| 1956 | broadcastAgentPollingIfChanged(); |
| 1957 | scheduleLeaseFlush(); |
| 1958 | req.on('close', () => { |
| 1959 | clearTimeout(timer); |
| 1960 | const idx = state.pendingPolls.indexOf(poll); |
| 1961 | if (idx !== -1) state.pendingPolls.splice(idx, 1); |
| 1962 | broadcastAgentPollingIfChanged(); |
| 1963 | }); |
| 1964 | } |
| 1965 | |
| 1966 | function sessionFileMetadataFromPollReply(file) { |
| 1967 | if (!file || typeof file !== 'string') return { file }; |
| 1968 | const normalized = file.split(path.sep).join('/'); |
| 1969 | const base = { file: normalized }; |
| 1970 | if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; |
| 1971 | if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; |
| 1972 | |
| 1973 | let full; |
| 1974 | try { |
| 1975 | full = path.resolve(process.cwd(), normalized); |
| 1976 | const rel = path.relative(process.cwd(), full); |
| 1977 | if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; |
| 1978 | } catch { |
| 1979 | return base; |
| 1980 | } |
| 1981 | |
| 1982 | try { |
| 1983 | const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); |
| 1984 | if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; |
| 1985 | return { |
| 1986 | file: String(manifest.sourceFile).split(path.sep).join('/'), |
| 1987 | sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), |
| 1988 | previewFile: normalized, |
| 1989 | previewMode: 'svelte-component', |
| 1990 | }; |
| 1991 | } catch { |
| 1992 | return base; |
| 1993 | } |
| 1994 | } |
| 1995 | |
| 1996 | function handlePollPost(req, res) { |
| 1997 | let body = ''; |
| 1998 | req.on('data', (c) => { body += c; }); |
| 1999 | req.on('end', () => { |
| 2000 | let msg; |
| 2001 | try { msg = JSON.parse(body); } catch { |
| 2002 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 2003 | res.end(JSON.stringify({ error: 'Invalid JSON' })); |
| 2004 | return; |
| 2005 | } |
| 2006 | if (msg.token !== state.token) { |
| 2007 | res.writeHead(401, { 'Content-Type': 'application/json' }); |
| 2008 | res.end(JSON.stringify({ error: 'Unauthorized' })); |
| 2009 | return; |
| 2010 | } |
| 2011 | const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id); |
| 2012 | if (pendingApplyDeferred) { |
| 2013 | const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred); |
| 2014 | if (!validation.ok) { |
| 2015 | recordManualEditActivity('manual_edit_apply_reply_invalid', { |
| 2016 | id: msg.id, |
| 2017 | pageUrl: pendingApplyDeferred.pageUrl, |
| 2018 | chunk: pendingApplyDeferred.event?.chunk || null, |
| 2019 | repair: pendingApplyDeferred.event?.repair || null, |
| 2020 | reason: validation.body?.reason || validation.body?.error || 'invalid_manual_apply_result', |
| 2021 | status: msg.data?.status || null, |
| 2022 | }); |
| 2023 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 2024 | res.end(JSON.stringify(validation.body)); |
| 2025 | return; |
| 2026 | } |
| 2027 | recordManualEditActivity('manual_edit_apply_reply_received', { |
| 2028 | id: msg.id, |
| 2029 | pageUrl: pendingApplyDeferred.pageUrl, |
| 2030 | chunk: pendingApplyDeferred.event?.chunk || null, |
| 2031 | repair: pendingApplyDeferred.event?.repair || null, |
| 2032 | status: validation.result.status, |
| 2033 | appliedCount: validation.result.appliedEntryIds.length, |
| 2034 | failed: summarizeManualApplyFailures(validation.result.failed), |
| 2035 | fileCount: validation.result.files.length, |
| 2036 | noteCount: validation.result.notes.length, |
| 2037 | }); |
| 2038 | resolveApplyDeferred(msg.id, validation.result); |
| 2039 | acknowledgePendingEvent(msg.id); |
| 2040 | flushPendingPolls(); |
| 2041 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 2042 | res.end(JSON.stringify({ ok: true })); |
| 2043 | return; |
| 2044 | } |
| 2045 | if (state.timedOutApplyIds.has(msg.id)) { |
| 2046 | const rollback = rollbackTimedOutApplyReply(msg); |
| 2047 | recordManualEditActivity('manual_edit_apply_stale_reply_rejected', { |
| 2048 | id: msg.id, |
| 2049 | rolledBackFileCount: rollback.rolledBackFiles?.length || 0, |
| 2050 | rollbackFailureCount: rollback.rollbackFailures?.length || 0, |
| 2051 | }); |
| 2052 | res.writeHead(409, { 'Content-Type': 'application/json' }); |
| 2053 | res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); |
| 2054 | return; |
| 2055 | } |
| 2056 | const pendingEventBeforeAck = findPendingEventById(msg.id); |
| 2057 | if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' |
| 2058 | && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { |
| 2059 | res.writeHead(400, { 'Content-Type': 'application/json' }); |
| 2060 | res.end(JSON.stringify({ |
| 2061 | error: 'steer_done_requires_file_or_message', |
| 2062 | hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', |
| 2063 | })); |
| 2064 | return; |
| 2065 | } |
| 2066 | const acknowledgedEvent = acknowledgePendingEvent(msg.id); |
| 2067 | let skipJournalReply = false; |
| 2068 | let existingSession = null; |
| 2069 | if (!acknowledgedEvent && state.sessionStore && msg.id) { |
| 2070 | try { |
| 2071 | existingSession = state.sessionStore.getSnapshot(msg.id, { includeCompleted: true }); |
| 2072 | if (!existingSession?.updatedAt) existingSession = null; |
| 2073 | skipJournalReply = existingSession?.phase === 'completed' || existingSession?.phase === 'discarded'; |
| 2074 | } catch { /* fall through and record the reply normally */ } |
| 2075 | } |
| 2076 | if (!acknowledgedEvent && !existingSession) { |
| 2077 | recordManualEditActivity('manual_edit_poll_reply_unknown', { |
| 2078 | id: msg.id || null, |
| 2079 | type: msg.type || null, |
| 2080 | }); |
| 2081 | res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' }); |
| 2082 | res.end(JSON.stringify({ |
| 2083 | error: msg.id ? 'unknown_poll_reply_id' : 'missing_poll_reply_id', |
| 2084 | id: msg.id, |
| 2085 | })); |
| 2086 | return; |
| 2087 | } |
| 2088 | const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); |
| 2089 | if (state.sessionStore && msg.id && !skipJournalReply) { |
| 2090 | try { |
| 2091 | const eventType = msg.type === 'steer_done' |
| 2092 | ? 'steer_done' |
| 2093 | : msg.type === 'discard' || msg.type === 'discarded' |
| 2094 | ? 'discarded' |
| 2095 | : msg.type === 'complete' |
| 2096 | ? 'complete' |
| 2097 | : msg.type === 'error' |
| 2098 | ? 'agent_error' |
| 2099 | : 'agent_done'; |
| 2100 | state.sessionStore.appendEvent({ |
| 2101 | type: eventType, |
| 2102 | id: msg.id, |
| 2103 | file: replyFileMeta.file, |
| 2104 | sourceFile: replyFileMeta.sourceFile, |
| 2105 | previewFile: replyFileMeta.previewFile, |
| 2106 | previewMode: replyFileMeta.previewMode, |
| 2107 | message: msg.message, |
| 2108 | sourceEventType: acknowledgedEvent?.type, |
| 2109 | carbonize: msg.data?.carbonize === true, |
| 2110 | }); |
| 2111 | } catch { /* keep reply path best-effort; browser still needs SSE */ } |
| 2112 | } |
| 2113 | flushPendingPolls(); |
| 2114 | // Forward the reply to the browser via SSE |
| 2115 | broadcast({ |
| 2116 | type: msg.type || 'done', |
| 2117 | id: msg.id, |
| 2118 | message: msg.message, |
| 2119 | file: msg.file, |
| 2120 | sourceFile: replyFileMeta.sourceFile, |
| 2121 | previewFile: replyFileMeta.previewFile, |
| 2122 | previewMode: replyFileMeta.previewMode, |
| 2123 | data: msg.data, |
| 2124 | }); |
| 2125 | res.writeHead(200, { 'Content-Type': 'application/json' }); |
| 2126 | res.end(JSON.stringify({ ok: true })); |
| 2127 | }); |
| 2128 | } |
| 2129 | |
| 2130 | // --------------------------------------------------------------------------- |
| 2131 | // Lifecycle |
| 2132 | // --------------------------------------------------------------------------- |
| 2133 | |
| 2134 | let httpServer = null; |
| 2135 | |
| 2136 | function shutdown() { |
| 2137 | cleanupSvelteComponentSessionsBeforeExit(); |
| 2138 | removeLiveServerInfo(process.cwd()); |
| 2139 | if (state.leaseTimer) clearTimeout(state.leaseTimer); |
| 2140 | state.leaseTimer = null; |
| 2141 | if (state.sessionDir) { |
| 2142 | try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} |
| 2143 | } |
| 2144 | for (const res of state.sseClients) { try { res.end(); } catch {} } |
| 2145 | state.sseClients.clear(); |
| 2146 | for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' }); |
| 2147 | state.pendingPolls.length = 0; |
| 2148 | if (httpServer) httpServer.close(); |
| 2149 | process.exit(0); |
| 2150 | } |
| 2151 | |
| 2152 | function cleanupSvelteComponentSessionsBeforeExit() { |
| 2153 | try { |
| 2154 | removeAllSvelteComponentSessions(process.cwd()); |
| 2155 | } catch (err) { |
| 2156 | console.warn('[impeccable] Svelte component session cleanup failed:', err.message); |
| 2157 | } |
| 2158 | } |
| 2159 | |
| 2160 | function applyLegacyDeferredAcceptsOnStartup() { |
| 2161 | try { |
| 2162 | const result = applyDeferredSvelteComponentAccepts(process.cwd()); |
| 2163 | if (result.applied > 0 || result.failed > 0) { |
| 2164 | console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); |
| 2165 | } |
| 2166 | } catch (err) { |
| 2167 | console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); |
| 2168 | } |
| 2169 | } |
| 2170 | |
| 2171 | // --------------------------------------------------------------------------- |
| 2172 | // Main |
| 2173 | // --------------------------------------------------------------------------- |
| 2174 | |
| 2175 | const args = process.argv.slice(2); |
| 2176 | |
| 2177 | if (args.includes('--help') || args.includes('-h')) { |
| 2178 | console.log(`Usage: node live-server.mjs [options] |
| 2179 | |
| 2180 | Start the live variant mode server (zero dependencies). |
| 2181 | |
| 2182 | Commands: |
| 2183 | (default) Start the server (foreground) |
| 2184 | stop Stop the server and remove the injected live.js script tag |
| 2185 | stop --keep-inject Stop the server only (leave the script tag in the HTML entry) |
| 2186 | |
| 2187 | Options: |
| 2188 | --background Start detached, print connection JSON to stdout, then exit |
| 2189 | --port=PORT Use a specific port (default: auto-detect starting at 8400) |
| 2190 | --keep-inject Only with stop: skip live-inject.mjs --remove |
| 2191 | --help Show this help |
| 2192 | |
| 2193 | Endpoints: |
| 2194 | /live.js Browser script (element picker + variant cycling) |
| 2195 | /detect.js Detection overlay (backwards compatible) |
| 2196 | /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) |
| 2197 | /annotation POST raw image/png to stage a variant screenshot |
| 2198 | /events SSE stream (server→browser) + POST (browser→server) |
| 2199 | /poll Long-poll for agent CLI |
| 2200 | /manual-edit-stash Stage browser copy edits |
| 2201 | /manual-edit-commit Apply staged browser copy edits |
| 2202 | /manual-edit-discard Discard staged browser copy edits |
| 2203 | /source Raw source file reader (no-HMR fallback) |
| 2204 | /status Durable recovery status (token-protected) |
| 2205 | /health Health check`); |
| 2206 | process.exit(0); |
| 2207 | } |
| 2208 | |
| 2209 | if (args.includes('stop')) { |
| 2210 | const keepInject = args.includes('--keep-inject'); |
| 2211 | try { |
| 2212 | const { info } = readLiveServerInfo(process.cwd()) || {}; |
| 2213 | const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); |
| 2214 | if (res.ok) console.log(`Stopped live server on port ${info.port}.`); |
| 2215 | } catch { |
| 2216 | console.log('No running live server found.'); |
| 2217 | } |
| 2218 | if (!keepInject) { |
| 2219 | const injectPath = path.join(__dirname, 'live-inject.mjs'); |
| 2220 | try { |
| 2221 | const out = execFileSync(process.execPath, [injectPath, '--remove'], { |
| 2222 | encoding: 'utf-8', |
| 2223 | cwd: process.cwd(), |
| 2224 | }); |
| 2225 | const line = out.trim().split('\n').filter(Boolean).pop(); |
| 2226 | if (line) { |
| 2227 | try { |
| 2228 | const j = JSON.parse(line); |
| 2229 | if (j.removed === true) { |
| 2230 | console.log(`Removed live script tag from ${j.file}.`); |
| 2231 | } |
| 2232 | } catch { |
| 2233 | /* ignore non-JSON lines */ |
| 2234 | } |
| 2235 | } |
| 2236 | } catch (err) { |
| 2237 | const detail = err.stderr?.toString?.().trim?.() |
| 2238 | || err.stdout?.toString?.().trim?.() |
| 2239 | || err.message |
| 2240 | || String(err); |
| 2241 | console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); |
| 2242 | } |
| 2243 | } |
| 2244 | process.exit(0); |
| 2245 | } |
| 2246 | |
| 2247 | // --background: spawn a detached child server, wait for it to be ready, |
| 2248 | // print the connection JSON, then exit. This keeps the startup command |
| 2249 | // simple (no shell backgrounding or chained commands). |
| 2250 | if (args.includes('--background')) { |
| 2251 | const childArgs = args.filter(a => a !== '--background'); |
| 2252 | const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], { |
| 2253 | detached: true, |
| 2254 | stdio: 'ignore', |
| 2255 | cwd: process.cwd(), |
| 2256 | }); |
| 2257 | child.unref(); |
| 2258 | |
| 2259 | // Poll for the PID file (the child writes it once the HTTP server is listening). |
| 2260 | const deadline = Date.now() + 10_000; |
| 2261 | while (Date.now() < deadline) { |
| 2262 | try { |
| 2263 | const { info } = readLiveServerInfo(process.cwd()) || {}; |
| 2264 | if (info.pid !== process.pid) { |
| 2265 | // Output JSON so the agent can read port + token from stdout. |
| 2266 | console.log(JSON.stringify(info)); |
| 2267 | process.exit(0); |
| 2268 | } |
| 2269 | } catch { /* not ready yet */ } |
| 2270 | await new Promise(r => setTimeout(r, 200)); |
| 2271 | } |
| 2272 | console.error('Timed out waiting for live server to start.'); |
| 2273 | process.exit(1); |
| 2274 | } |
| 2275 | |
| 2276 | // Check for existing session |
| 2277 | const existingRecord = readLiveServerInfo(process.cwd()); |
| 2278 | if (existingRecord?.info) { |
| 2279 | const existing = existingRecord.info; |
| 2280 | try { |
| 2281 | process.kill(existing.pid, 0); |
| 2282 | console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); |
| 2283 | console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); |
| 2284 | process.exit(1); |
| 2285 | } catch { |
| 2286 | try { fs.unlinkSync(existingRecord.path); } catch {} |
| 2287 | } |
| 2288 | } |
| 2289 | |
| 2290 | state.token = randomUUID(); |
| 2291 | state.sessionStore = createLiveSessionStore({ cwd: process.cwd() }); |
| 2292 | rollbackManualApplyTransaction({ |
| 2293 | cwd: process.cwd(), |
| 2294 | reason: 'manual_edit_server_start_recovered_abandoned_transaction', |
| 2295 | }); |
| 2296 | applyLegacyDeferredAcceptsOnStartup(); |
| 2297 | restorePendingEventsFromStore(); |
| 2298 | pruneStaleManualApplyEvidence(process.cwd()); |
| 2299 | const portArg = args.find(a => a.startsWith('--port=')); |
| 2300 | state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); |
| 2301 | // Annotation screenshots live in the project root so the agent's Read tool |
| 2302 | // doesn't trip a per-file permission prompt. Sessioned by token so concurrent |
| 2303 | // projects (or quick restarts) don't collide. |
| 2304 | const annotRoot = getLiveAnnotationsDir(process.cwd()); |
| 2305 | fs.mkdirSync(annotRoot, { recursive: true }); |
| 2306 | state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); |
| 2307 | |
| 2308 | const { detectScript, sessionPath, livePath } = loadBrowserScripts(); |
| 2309 | httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath })); |
| 2310 | |
| 2311 | httpServer.listen(state.port, '127.0.0.1', () => { |
| 2312 | writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token }); |
| 2313 | const url = `http://localhost:${state.port}`; |
| 2314 | console.log(`\nImpeccable live server running on ${url}`); |
| 2315 | console.log(`Token: ${state.token}\n`); |
| 2316 | console.log(`Script: ${url}/live.js`); |
| 2317 | console.log('Inject: managed by live-inject.mjs; Astro source tags use is:inline automatically.'); |
| 2318 | console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`); |
| 2319 | }); |
| 2320 | |
| 2321 | process.on('SIGINT', shutdown); |
| 2322 | process.on('SIGTERM', shutdown); |
| 2323 |