返回 AiToEarn
live-session-store.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-session-store.mjs
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
290 lines Plain Text