| 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs'; |
| 4 | |
| 5 | const COMPLETED_PHASES = new Set(['completed', 'discarded']); |
| 6 | |
| 7 | export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) { |
| 8 | const rootDir = getLiveSessionsDir(cwd); |
| 9 | const legacyRootDir = getLegacyLiveSessionsDir(cwd); |
| 10 | fs.mkdirSync(rootDir, { recursive: true }); |
| 11 | const snapshotCache = new Map(); |
| 12 | |
| 13 | function loadCachedOrRebuild(id) { |
| 14 | const cached = snapshotCache.get(id); |
| 15 | if (cached) return cached; |
| 16 | const journalPath = getReadableJournalPath(id); |
| 17 | const rebuilt = rebuildSnapshotFromJournal(journalPath, id); |
| 18 | snapshotCache.set(id, rebuilt); |
| 19 | return rebuilt; |
| 20 | } |
| 21 | |
| 22 | function getReadableJournalPath(id) { |
| 23 | const primary = getJournalPath(rootDir, id); |
| 24 | if (fs.existsSync(primary)) return primary; |
| 25 | const legacy = getJournalPath(legacyRootDir, id); |
| 26 | if (fs.existsSync(legacy)) return legacy; |
| 27 | return primary; |
| 28 | } |
| 29 | |
| 30 | return { |
| 31 | rootDir, |
| 32 | legacyRootDir, |
| 33 | appendEvent(event) { |
| 34 | const normalized = normalizeEvent(event, sessionId); |
| 35 | const journalPath = getJournalPath(rootDir, normalized.id); |
| 36 | const snapshotPath = getSnapshotPath(rootDir, normalized.id); |
| 37 | const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id); |
| 38 | if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) { |
| 39 | fs.copyFileSync(legacyJournalPath, journalPath); |
| 40 | } |
| 41 | const prior = loadCachedOrRebuild(normalized.id); |
| 42 | const seq = prior.nextSeq; |
| 43 | const entry = { |
| 44 | seq, |
| 45 | id: normalized.id, |
| 46 | type: normalized.type, |
| 47 | ts: new Date().toISOString(), |
| 48 | event: normalized, |
| 49 | }; |
| 50 | fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n'); |
| 51 | const next = applyEvent(prior.snapshot, entry, prior.diagnostics); |
| 52 | snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 }); |
| 53 | writeSnapshot(snapshotPath, next); |
| 54 | return next; |
| 55 | }, |
| 56 | getSnapshot(id = sessionId, opts = {}) { |
| 57 | if (!id) throw new Error('session id required'); |
| 58 | const journalPath = getReadableJournalPath(id); |
| 59 | const snapshotPath = getSnapshotPath(rootDir, id); |
| 60 | const rebuilt = rebuildSnapshotFromJournal(journalPath, id); |
| 61 | snapshotCache.set(id, rebuilt); |
| 62 | writeSnapshot(snapshotPath, rebuilt.snapshot); |
| 63 | if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null; |
| 64 | return rebuilt.snapshot; |
| 65 | }, |
| 66 | listActiveSessions() { |
| 67 | const ids = new Set(); |
| 68 | for (const dir of [legacyRootDir, rootDir]) { |
| 69 | if (!fs.existsSync(dir)) continue; |
| 70 | for (const name of fs.readdirSync(dir)) { |
| 71 | if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length)); |
| 72 | } |
| 73 | } |
| 74 | return [...ids] |
| 75 | .sort() |
| 76 | .map((id) => this.getSnapshot(id)) |
| 77 | .filter(Boolean); |
| 78 | }, |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | function normalizeEvent(event, fallbackId) { |
| 83 | if (!event || typeof event !== 'object') throw new Error('event object required'); |
| 84 | const id = event.id || fallbackId; |
| 85 | if (!id || typeof id !== 'string') throw new Error('event id required'); |
| 86 | if (!event.type || typeof event.type !== 'string') throw new Error('event type required'); |
| 87 | return { ...event, id }; |
| 88 | } |
| 89 | |
| 90 | function getJournalPath(rootDir, id) { |
| 91 | return path.join(rootDir, safeSessionId(id) + '.jsonl'); |
| 92 | } |
| 93 | |
| 94 | function getSnapshotPath(rootDir, id) { |
| 95 | return path.join(rootDir, safeSessionId(id) + '.snapshot.json'); |
| 96 | } |
| 97 | |
| 98 | function safeSessionId(id) { |
| 99 | if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id); |
| 100 | return id; |
| 101 | } |
| 102 | |
| 103 | function baseSnapshot(id) { |
| 104 | return { |
| 105 | id, |
| 106 | phase: 'new', |
| 107 | pageUrl: null, |
| 108 | sourceFile: null, |
| 109 | previewFile: null, |
| 110 | previewMode: null, |
| 111 | expectedVariants: 0, |
| 112 | arrivedVariants: 0, |
| 113 | visibleVariant: null, |
| 114 | paramValues: {}, |
| 115 | pendingEventSeq: null, |
| 116 | pendingEvent: null, |
| 117 | deliveryLease: null, |
| 118 | checkpointRevision: 0, |
| 119 | activeOwner: null, |
| 120 | sourceMarkers: {}, |
| 121 | fallbackMode: null, |
| 122 | annotationArtifacts: [], |
| 123 | diagnostics: [], |
| 124 | updatedAt: null, |
| 125 | }; |
| 126 | } |
| 127 | |
| 128 | function rebuildSnapshotFromJournal(journalPath, id) { |
| 129 | let snapshot = baseSnapshot(id); |
| 130 | const diagnostics = []; |
| 131 | let nextSeq = 1; |
| 132 | if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq }; |
| 133 | |
| 134 | const lines = fs.readFileSync(journalPath, 'utf-8').split('\n'); |
| 135 | for (let i = 0; i < lines.length; i++) { |
| 136 | const line = lines[i]; |
| 137 | if (!line.trim()) continue; |
| 138 | try { |
| 139 | const entry = JSON.parse(line); |
| 140 | if (!entry || typeof entry !== 'object') throw new Error('entry is not object'); |
| 141 | if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1); |
| 142 | snapshot = applyEvent(snapshot, entry); |
| 143 | } catch (err) { |
| 144 | diagnostics.push({ |
| 145 | error: 'journal_parse_failed', |
| 146 | line: i + 1, |
| 147 | message: err.message, |
| 148 | }); |
| 149 | } |
| 150 | } |
| 151 | snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics]; |
| 152 | return { snapshot, diagnostics, nextSeq }; |
| 153 | } |
| 154 | |
| 155 | function applyEvent(snapshot, entry, inheritedDiagnostics = []) { |
| 156 | const event = entry.event || entry; |
| 157 | const next = { |
| 158 | ...snapshot, |
| 159 | paramValues: { ...(snapshot.paramValues || {}) }, |
| 160 | sourceMarkers: { ...(snapshot.sourceMarkers || {}) }, |
| 161 | annotationArtifacts: [...(snapshot.annotationArtifacts || [])], |
| 162 | diagnostics: [...(snapshot.diagnostics || [])], |
| 163 | updatedAt: entry.ts || new Date().toISOString(), |
| 164 | }; |
| 165 | |
| 166 | if (inheritedDiagnostics.length && next.diagnostics.length === 0) { |
| 167 | next.diagnostics = [...inheritedDiagnostics]; |
| 168 | } |
| 169 | |
| 170 | switch (event.type) { |
| 171 | case 'generate': |
| 172 | next.phase = 'generate_requested'; |
| 173 | next.pageUrl = event.pageUrl ?? next.pageUrl; |
| 174 | next.expectedVariants = event.count ?? next.expectedVariants; |
| 175 | next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; |
| 176 | next.pendingEvent = toPendingEvent(event); |
| 177 | if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath }); |
| 178 | break; |
| 179 | case 'variants_ready': |
| 180 | case 'agent_done': |
| 181 | next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; |
| 182 | next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; |
| 183 | next.previewFile = event.previewFile ?? next.previewFile; |
| 184 | next.previewMode = event.previewMode ?? next.previewMode; |
| 185 | next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); |
| 186 | next.pendingEventSeq = null; |
| 187 | next.pendingEvent = null; |
| 188 | if (event.carbonize === true) { |
| 189 | next.diagnostics.push({ |
| 190 | error: 'carbonize_cleanup_required', |
| 191 | file: event.file || null, |
| 192 | message: 'Accepted variant still has carbonize markers that must be folded into source CSS.', |
| 193 | }); |
| 194 | } |
| 195 | break; |
| 196 | case 'checkpoint': |
| 197 | if (COMPLETED_PHASES.has(next.phase)) { |
| 198 | next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); |
| 199 | break; |
| 200 | } |
| 201 | if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { |
| 202 | next.phase = event.phase ?? next.phase; |
| 203 | next.checkpointRevision = event.revision ?? next.checkpointRevision; |
| 204 | next.activeOwner = event.owner ?? next.activeOwner; |
| 205 | next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; |
| 206 | next.visibleVariant = event.visibleVariant ?? next.visibleVariant; |
| 207 | next.sourceFile = event.sourceFile ?? next.sourceFile; |
| 208 | next.previewFile = event.previewFile ?? next.previewFile; |
| 209 | next.previewMode = event.previewMode ?? next.previewMode; |
| 210 | if (event.paramValues) next.paramValues = { ...event.paramValues }; |
| 211 | } else { |
| 212 | next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); |
| 213 | } |
| 214 | break; |
| 215 | case 'accept': |
| 216 | case 'accept_intent': |
| 217 | next.phase = 'accept_requested'; |
| 218 | next.visibleVariant = Number(event.variantId ?? next.visibleVariant); |
| 219 | if (event.paramValues) next.paramValues = { ...event.paramValues }; |
| 220 | next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; |
| 221 | next.pendingEvent = toPendingEvent(event); |
| 222 | break; |
| 223 | case 'manual_edit_apply': |
| 224 | next.phase = 'manual_edit_apply_requested'; |
| 225 | next.pageUrl = event.pageUrl ?? next.pageUrl; |
| 226 | next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; |
| 227 | next.pendingEvent = toPendingEvent(event); |
| 228 | break; |
| 229 | case 'steer': |
| 230 | next.phase = 'steer_requested'; |
| 231 | next.pageUrl = event.pageUrl ?? next.pageUrl; |
| 232 | next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; |
| 233 | next.pendingEvent = toPendingEvent(event); |
| 234 | break; |
| 235 | case 'steer_done': |
| 236 | next.phase = 'steer_done'; |
| 237 | next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; |
| 238 | next.previewFile = event.previewFile ?? next.previewFile; |
| 239 | next.previewMode = event.previewMode ?? next.previewMode; |
| 240 | next.message = event.message ?? next.message; |
| 241 | next.pendingEventSeq = null; |
| 242 | next.pendingEvent = null; |
| 243 | break; |
| 244 | case 'discard': |
| 245 | next.phase = 'discard_requested'; |
| 246 | next.pendingEventSeq = entry.seq ?? next.pendingEventSeq; |
| 247 | next.pendingEvent = toPendingEvent(event); |
| 248 | break; |
| 249 | case 'discarded': |
| 250 | next.phase = 'discarded'; |
| 251 | next.pendingEventSeq = null; |
| 252 | next.pendingEvent = null; |
| 253 | break; |
| 254 | case 'complete': |
| 255 | next.phase = 'completed'; |
| 256 | next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; |
| 257 | next.previewFile = event.previewFile ?? next.previewFile; |
| 258 | next.previewMode = event.previewMode ?? next.previewMode; |
| 259 | next.pendingEventSeq = null; |
| 260 | next.pendingEvent = null; |
| 261 | break; |
| 262 | case 'agent_error': |
| 263 | next.phase = 'agent_error'; |
| 264 | next.pendingEventSeq = null; |
| 265 | next.pendingEvent = null; |
| 266 | next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' }); |
| 267 | break; |
| 268 | default: |
| 269 | next.diagnostics.push({ error: 'unknown_event_type', type: event.type }); |
| 270 | break; |
| 271 | } |
| 272 | return next; |
| 273 | } |
| 274 | |
| 275 | function toPendingEvent(event) { |
| 276 | const pending = { ...event }; |
| 277 | delete pending.token; |
| 278 | return pending; |
| 279 | } |
| 280 | |
| 281 | function upsertArtifact(artifacts, artifact) { |
| 282 | if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) { |
| 283 | artifacts.push(artifact); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | function writeSnapshot(snapshotPath, snapshot) { |
| 288 | fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n'); |
| 289 | } |
| 290 |