tokenrace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.js ADDED
@@ -0,0 +1,763 @@
1
+ /**
2
+ * store.js
3
+ *
4
+ * Estado en memoria del servidor, getters para la API REST,
5
+ * y persistencia en ~/.tokenrace/data.json.
6
+ *
7
+ * Reglas:
8
+ * - Nunca lanzar excepción — si falla escribir a disco, loguearlo y continuar.
9
+ * - Sin estado global mutable excepto el objeto `state` local.
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ // ─── Ruta de persistencia ───────────────────────────────────────────────────
17
+
18
+ const HOME_DATA_DIR = path.join(os.homedir(), '.tokenrace')
19
+ let dataDir = HOME_DATA_DIR
20
+ let dataFile = path.join(HOME_DATA_DIR, 'data.json')
21
+
22
+ // Solo para tests — redirige la ruta de datos a un directorio temporal
23
+ export function setDataPathForTesting(newPath) {
24
+ dataFile = newPath
25
+ dataDir = path.dirname(newPath)
26
+ }
27
+
28
+ // ─── Estado en memoria ───────────────────────────────────────────────────────
29
+
30
+ const state = {
31
+ timeseries: new Map(), // metricName → [{ ts, value, labels }]
32
+ sessions: new Map(), // sessionId → SessionData
33
+ sessionMappings: new Map(), // sessionId → projectName
34
+ ignoredSessions: new Set(), // sessionIds ignorados
35
+ events: [], // buffer circular, max 1000
36
+ eventIndex: 0,
37
+ projects: new Map(), // projectName → ProjectAggregates
38
+ tools: new Map(), // toolName → ToolStats
39
+ agents: new Map(), // agentId → AgentData
40
+ models: new Map(), // modelName → ModelStats
41
+ lastSeen: null,
42
+ startTime: Date.now(),
43
+ totalEvents: 0
44
+ }
45
+
46
+ // ─── Helpers internos ────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Resuelve el proyecto de una sesión.
50
+ * Prioridad: mapping manual > resource project > null
51
+ */
52
+ function resolveProject(sessionId, resourceProject) {
53
+ return state.sessionMappings.get(sessionId) ?? resourceProject ?? null
54
+ }
55
+
56
+ const TIME_MULTIPLIERS = { d: 86_400_000, h: 3_600_000, m: 60_000 }
57
+
58
+ /**
59
+ * Parsea un rango temporal "now-Xd", "now-Xh", "now-Xm", "all" o falsy.
60
+ * Devuelve el timestamp mínimo (ms) a incluir.
61
+ */
62
+ function parseTimeRange(from) {
63
+ if (!from || from === 'all') return 0
64
+
65
+ const match = /^now-(\d+)([dhm])$/.exec(from)
66
+ if (!match) return 0
67
+
68
+ return Date.now() - Number(match[1]) * TIME_MULTIPLIERS[match[2]]
69
+ }
70
+
71
+ /**
72
+ * Parsea un tamaño de bucket ("1h", "1d", "5m") a ms.
73
+ * Default: 1 hora.
74
+ */
75
+ function parseBucket(bucket) {
76
+ if (!bucket) return 3_600_000
77
+ const match = /^(\d+)([dhm])$/.exec(bucket)
78
+ if (!match) return 3_600_000
79
+ return Number(match[1]) * TIME_MULTIPLIERS[match[2]]
80
+ }
81
+
82
+ /**
83
+ * Reconstruye state.projects desde cero usando state.sessions y state.timeseries.
84
+ */
85
+ function rebuildProjectAggregates() {
86
+ state.projects.clear()
87
+
88
+ // Reconstruir desde sesiones
89
+ for (const session of state.sessions.values()) {
90
+ const project = resolveProject(session.sessionId, session.project)
91
+ if (!project) continue
92
+
93
+ if (!state.projects.has(project)) {
94
+ state.projects.set(project, {
95
+ sessions: new Set(),
96
+ commits: 0, linesAdded: 0, linesRemoved: 0
97
+ })
98
+ }
99
+ const proj = state.projects.get(project)
100
+ proj.sessions.add(session.sessionId)
101
+ }
102
+
103
+ // Reconstruir commits y líneas desde timeseries
104
+ const commitMetrics = ['claude_code.commits']
105
+ const linesAddedM = ['claude_code.lines_added']
106
+ const linesRemovedM = ['claude_code.lines_removed']
107
+
108
+ for (const [metricName, points] of state.timeseries.entries()) {
109
+ let field = null
110
+ if (commitMetrics.includes(metricName)) field = 'commits'
111
+ if (linesAddedM.includes(metricName)) field = 'linesAdded'
112
+ if (linesRemovedM.includes(metricName)) field = 'linesRemoved'
113
+ if (!field) continue
114
+
115
+ for (const point of points) {
116
+ const project = resolveProject(point.labels['session.id'], point.labels.project)
117
+ if (!project) continue
118
+ if (!state.projects.has(project)) {
119
+ state.projects.set(project, {
120
+ sessions: new Set(),
121
+ commits: 0, linesAdded: 0, linesRemoved: 0
122
+ })
123
+ }
124
+ state.projects.get(project)[field] += point.value
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Debounce: guarda a disco en el siguiente tick */
130
+ function scheduleSave() {
131
+ setTimeout(() => saveSync(), 0)
132
+ }
133
+
134
+ // ─── Mutaciones ──────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Procesa un punto de métrica normalizado.
138
+ * @param {{ name, value, timestamp, labels }} metric
139
+ */
140
+ export function processMetric({ name, value, timestamp, labels }) {
141
+ const sessionId = labels['session.id']
142
+
143
+ // Saltar sesiones ignoradas
144
+ if (sessionId && state.ignoredSessions.has(sessionId)) return
145
+
146
+ const project = resolveProject(sessionId, labels.project)
147
+ const model = labels.model ?? null
148
+
149
+ // ── Timeseries ──
150
+ if (!state.timeseries.has(name)) state.timeseries.set(name, [])
151
+ state.timeseries.get(name).push({ ts: timestamp, value, labels })
152
+
153
+ // ── Sesiones ──
154
+ if (sessionId) {
155
+ if (!state.sessions.has(sessionId)) {
156
+ state.sessions.set(sessionId, {
157
+ sessionId,
158
+ project,
159
+ feature: labels.feature ?? null,
160
+ model,
161
+ startTime: timestamp,
162
+ lastSeen: timestamp,
163
+ durationActiveMs: 0,
164
+ tokensInput: 0,
165
+ tokensOutput: 0,
166
+ tokensCache: 0,
167
+ cost: 0,
168
+ apiRequests: 0,
169
+ toolCalls: 0
170
+ })
171
+ }
172
+
173
+ const session = state.sessions.get(sessionId)
174
+ session.lastSeen = Math.max(session.lastSeen, timestamp)
175
+ if (model && !session.model) session.model = model
176
+
177
+ switch (name) {
178
+ case 'claude_code.tokens.input':
179
+ session.tokensInput += value; break
180
+ case 'claude_code.tokens.output':
181
+ session.tokensOutput += value; break
182
+ case 'claude_code.tokens.cache.read':
183
+ case 'claude_code.tokens.cache.creation':
184
+ session.tokensCache += value; break
185
+ case 'claude_code.cost':
186
+ session.cost += value; break
187
+ case 'claude_code.active_time':
188
+ session.durationActiveMs += value; break
189
+ case 'claude_code.api_requests':
190
+ session.apiRequests += value; break
191
+ }
192
+ }
193
+
194
+ // ── Proyectos ──
195
+ if (project) {
196
+ if (!state.projects.has(project)) {
197
+ state.projects.set(project, {
198
+ sessions: new Set(),
199
+ commits: 0, linesAdded: 0, linesRemoved: 0
200
+ })
201
+ }
202
+ const proj = state.projects.get(project)
203
+ if (sessionId) proj.sessions.add(sessionId)
204
+
205
+ switch (name) {
206
+ case 'claude_code.commits':
207
+ proj.commits += value; break
208
+ case 'claude_code.lines_added':
209
+ proj.linesAdded += value; break
210
+ case 'claude_code.lines_removed':
211
+ proj.linesRemoved += value; break
212
+ }
213
+ }
214
+
215
+ // ── Modelos ──
216
+ if (model) {
217
+ if (!state.models.has(model)) {
218
+ state.models.set(model, {
219
+ tokensInput: 0, tokensOutput: 0, cost: 0, requests: 0
220
+ })
221
+ }
222
+ const modelStats = state.models.get(model)
223
+ switch (name) {
224
+ case 'claude_code.tokens.input':
225
+ modelStats.tokensInput += value; break
226
+ case 'claude_code.tokens.output':
227
+ modelStats.tokensOutput += value; break
228
+ case 'claude_code.cost':
229
+ modelStats.cost += value; break
230
+ case 'claude_code.api_requests':
231
+ modelStats.requests += value; break
232
+ }
233
+ }
234
+
235
+ state.lastSeen = timestamp
236
+ state.totalEvents++
237
+ }
238
+
239
+ /**
240
+ * Procesa un evento normalizado.
241
+ * @param {{ eventName, timestamp, severity, attributes }} event
242
+ * @returns {Object} El evento procesado (para broadcast SSE)
243
+ */
244
+ export function processEvent({ eventName, timestamp, severity, attributes }) {
245
+ const sessionId = attributes['session.id']
246
+ const project = resolveProject(sessionId, attributes.project)
247
+ const model = attributes.model ?? null
248
+
249
+ const eventObj = {
250
+ timestamp,
251
+ eventName,
252
+ sessionId,
253
+ project,
254
+ model,
255
+ attributes
256
+ }
257
+
258
+ // Buffer circular — sobreescribir cuando alcanza 1000
259
+ if (state.events.length < 1000) {
260
+ state.events.push(eventObj)
261
+ } else {
262
+ state.events[state.eventIndex % 1000] = eventObj
263
+ }
264
+ state.eventIndex++
265
+
266
+ // ── Tool stats ──
267
+ if (eventName === 'tool_use') {
268
+ const toolName = attributes['tool.name'] ?? attributes.tool ?? 'unknown'
269
+
270
+ if (!state.tools.has(toolName)) {
271
+ state.tools.set(toolName, { count: 0, successes: 0, totalDurationMs: 0 })
272
+ }
273
+ const tool = state.tools.get(toolName)
274
+ tool.count++
275
+
276
+ if (attributes.success === true || attributes['tool.success'] === true) {
277
+ tool.successes++
278
+ }
279
+ if (attributes['tool.duration_ms'] !== undefined) {
280
+ tool.totalDurationMs += Number(attributes['tool.duration_ms'])
281
+ }
282
+
283
+ // Incrementar toolCalls en la sesión
284
+ if (sessionId && state.sessions.has(sessionId)) {
285
+ state.sessions.get(sessionId).toolCalls++
286
+ }
287
+ }
288
+
289
+ state.lastSeen = timestamp
290
+ state.totalEvents++
291
+
292
+ return eventObj
293
+ }
294
+
295
+ /**
296
+ * Procesa un span normalizado.
297
+ * Solo actúa si el span tiene agentId.
298
+ * @param {Object} span
299
+ */
300
+ export function processTrace(span) {
301
+ const attrs = span.attributes
302
+ const agentId = attrs['agent.id'] ?? attrs['gen_ai.agent.id'] ?? null
303
+ if (!agentId) return
304
+
305
+ if (!state.agents.has(agentId)) {
306
+ state.agents.set(agentId, {
307
+ agentId,
308
+ parentAgentId: attrs['agent.parent_id'] ?? null,
309
+ tokensInput: 0,
310
+ tokensOutput: 0,
311
+ cost: 0,
312
+ toolCalls: 0,
313
+ durationMs: 0
314
+ })
315
+ }
316
+
317
+ const agent = state.agents.get(agentId)
318
+ const duration = span.endTime - span.startTime
319
+ if (duration > 0) agent.durationMs += duration
320
+ }
321
+
322
+ /**
323
+ * Asigna un proyecto a una sesión, con aplicación retroactiva.
324
+ * @param {string} sessionId
325
+ * @param {string} project
326
+ */
327
+ export function labelSession(sessionId, project) {
328
+ state.sessionMappings.set(sessionId, project)
329
+
330
+ // Aplicar en sesión existente
331
+ if (state.sessions.has(sessionId)) {
332
+ state.sessions.get(sessionId).project = project
333
+ }
334
+
335
+ // Aplicar retroactivamente en timeseries
336
+ for (const points of state.timeseries.values()) {
337
+ for (const point of points) {
338
+ if (point.labels['session.id'] === sessionId) {
339
+ point.labels.project = project
340
+ }
341
+ }
342
+ }
343
+
344
+ rebuildProjectAggregates()
345
+ scheduleSave()
346
+ }
347
+
348
+ /**
349
+ * Marca una sesión como ignorada.
350
+ * @param {string} sessionId
351
+ */
352
+ export function ignoreSession(sessionId) {
353
+ state.ignoredSessions.add(sessionId)
354
+ scheduleSave()
355
+ }
356
+
357
+ /**
358
+ * Resetea todo el estado en memoria y guarda el estado vacío a disco.
359
+ */
360
+ export function reset() {
361
+ state.timeseries.clear()
362
+ state.sessions.clear()
363
+ state.sessionMappings.clear()
364
+ state.ignoredSessions.clear()
365
+ state.events.length = 0
366
+ state.eventIndex = 0
367
+ state.projects.clear()
368
+ state.tools.clear()
369
+ state.agents.clear()
370
+ state.models.clear()
371
+ state.lastSeen = null
372
+ state.startTime = Date.now()
373
+ state.totalEvents = 0
374
+
375
+ saveSync()
376
+ }
377
+
378
+ // ─── Getters ─────────────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Estado de conexión del servidor.
382
+ */
383
+ export function getStatus() {
384
+ return {
385
+ connected: state.lastSeen !== null,
386
+ lastSeen: state.lastSeen,
387
+ sessionCount: Array.from(state.sessions.keys())
388
+ .filter(id => !state.ignoredSessions.has(id)).length,
389
+ totalEvents: state.totalEvents,
390
+ uptime: Date.now() - state.startTime
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Resumen agregado filtrado por rango temporal.
396
+ * @param {string} from - Rango temporal ("now-7d", "now-24h", "all")
397
+ */
398
+ export function getSummary(from) {
399
+ const minTs = parseTimeRange(from)
400
+
401
+ let tokensInput = 0
402
+ let tokensOutput = 0
403
+ let tokensCache = 0
404
+ let cost = 0
405
+ let activeTimeMs = 0
406
+ const sessionSet = new Set()
407
+
408
+ for (const session of state.sessions.values()) {
409
+ if (state.ignoredSessions.has(session.sessionId)) continue
410
+ if (session.lastSeen < minTs) continue
411
+ tokensInput += session.tokensInput
412
+ tokensOutput += session.tokensOutput
413
+ tokensCache += session.tokensCache
414
+ cost += session.cost
415
+ activeTimeMs += session.durationActiveMs
416
+ sessionSet.add(session.sessionId)
417
+ }
418
+
419
+ // Commits, PRs y líneas desde timeseries
420
+ let commits = 0
421
+ let pullRequests = 0
422
+ let linesAdded = 0
423
+ let linesRemoved = 0
424
+
425
+ const tsMap = {
426
+ 'claude_code.commits': (v) => { commits += v },
427
+ 'claude_code.pull_requests': (v) => { pullRequests += v },
428
+ 'claude_code.lines_added': (v) => { linesAdded += v },
429
+ 'claude_code.lines_removed': (v) => { linesRemoved += v }
430
+ }
431
+
432
+ for (const [metric, accum] of Object.entries(tsMap)) {
433
+ for (const point of state.timeseries.get(metric) ?? []) {
434
+ if (point.ts >= minTs) accum(point.value)
435
+ }
436
+ }
437
+
438
+ const efficiency = tokensInput > 0 ? tokensOutput / tokensInput : 0
439
+
440
+ return {
441
+ tokens: { input: tokensInput, output: tokensOutput, cache: tokensCache },
442
+ cost,
443
+ activeTimeMs,
444
+ sessions: sessionSet.size,
445
+ commits,
446
+ pullRequests,
447
+ linesAdded,
448
+ linesRemoved,
449
+ efficiency
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Serie temporal de una métrica, agrupada en buckets.
455
+ * @param {string} metric
456
+ * @param {string} from
457
+ * @param {string} bucket
458
+ * @returns {Array<{timestamp, value}>}
459
+ */
460
+ export function getTimeseries(metric, from, bucket) {
461
+ const minTs = parseTimeRange(from)
462
+ const bucketMs = parseBucket(bucket)
463
+ const points = state.timeseries.get(metric) ?? []
464
+
465
+ const buckets = new Map()
466
+ for (const point of points) {
467
+ if (point.ts < minTs) continue
468
+ const key = Math.floor(point.ts / bucketMs) * bucketMs
469
+ buckets.set(key, (buckets.get(key) ?? 0) + point.value)
470
+ }
471
+
472
+ return Array.from(buckets.entries())
473
+ .map(([timestamp, value]) => ({ timestamp, value }))
474
+ .sort((a, b) => a.timestamp - b.timestamp)
475
+ }
476
+
477
+ /**
478
+ * Proyectos con sus métricas agregadas filtradas por rango temporal.
479
+ * @param {string} from
480
+ */
481
+ export function getProjects(from) {
482
+ const minTs = parseTimeRange(from)
483
+ const result = []
484
+
485
+ for (const [project, proj] of state.projects.entries()) {
486
+ // Filtrar sesiones activas en el rango
487
+ const activeSessions = new Set()
488
+ let cost = 0, tokensInput = 0, tokensOutput = 0, tokensCache = 0
489
+
490
+ for (const sessionId of proj.sessions) {
491
+ const session = state.sessions.get(sessionId)
492
+ if (!session || session.lastSeen < minTs) continue
493
+ activeSessions.add(sessionId)
494
+ cost += session.cost
495
+ tokensInput += session.tokensInput
496
+ tokensOutput += session.tokensOutput
497
+ tokensCache += session.tokensCache
498
+ }
499
+
500
+ if (activeSessions.size === 0 && minTs > 0) continue
501
+
502
+ // Commits y líneas filtrados por rango
503
+ let commits = 0, linesAdded = 0, linesRemoved = 0
504
+ const tsMap = {
505
+ 'claude_code.commits': (v) => { commits += v },
506
+ 'claude_code.lines_added': (v) => { linesAdded += v },
507
+ 'claude_code.lines_removed': (v) => { linesRemoved += v }
508
+ }
509
+ for (const [metric, accum] of Object.entries(tsMap)) {
510
+ for (const point of state.timeseries.get(metric) ?? []) {
511
+ const pointProject = resolveProject(point.labels['session.id'], point.labels.project)
512
+ if (pointProject === project && point.ts >= minTs) accum(point.value)
513
+ }
514
+ }
515
+
516
+ const cacheHitRate = tokensInput > 0 ? tokensCache / tokensInput : 0
517
+
518
+ result.push({
519
+ project,
520
+ cost,
521
+ tokensInput,
522
+ tokensOutput,
523
+ cacheHitRate,
524
+ sessions: activeSessions.size,
525
+ commits,
526
+ linesAdded,
527
+ linesRemoved
528
+ })
529
+ }
530
+
531
+ return result.sort((a, b) => b.cost - a.cost)
532
+ }
533
+
534
+ /**
535
+ * Lista de sesiones, opcionalmente filtradas por proyecto.
536
+ * @param {{ limit?, project? }} options
537
+ */
538
+ export function getSessions({ limit = 50, project = null } = {}) {
539
+ let sessions = Array.from(state.sessions.values())
540
+ .filter(s => !state.ignoredSessions.has(s.sessionId))
541
+
542
+ if (project) {
543
+ sessions = sessions.filter(s => resolveProject(s.sessionId, s.project) === project)
544
+ }
545
+
546
+ sessions.sort((a, b) => b.startTime - a.startTime)
547
+
548
+ return sessions.slice(0, limit).map(s => ({
549
+ ...s,
550
+ project: resolveProject(s.sessionId, s.project)
551
+ }))
552
+ }
553
+
554
+ /**
555
+ * Sesiones sin proyecto asignado y que no están ignoradas.
556
+ */
557
+ export function getUnlabeledSessions() {
558
+ const result = []
559
+ for (const session of state.sessions.values()) {
560
+ if (state.ignoredSessions.has(session.sessionId)) continue
561
+ if (resolveProject(session.sessionId, session.project) !== null) continue
562
+ result.push({
563
+ sessionId: session.sessionId,
564
+ model: session.model,
565
+ timestamp: session.startTime,
566
+ tokensInput: session.tokensInput
567
+ })
568
+ }
569
+ return result
570
+ }
571
+
572
+ /**
573
+ * Últimos N eventos de una sesión.
574
+ * @param {string} sessionId
575
+ * @param {number} limit
576
+ */
577
+ export function getSessionEvents(sessionId, limit = 100) {
578
+ return getEvents({ limit, sessionId })
579
+ }
580
+
581
+ /**
582
+ * Lista de eventos filtrados y ordenados cronológicamente (más recientes primero).
583
+ * @param {{ limit?, type?, project?, sessionId? }} options
584
+ */
585
+ export function getEvents({ limit = 200, type = null, project = null, sessionId = null } = {}) {
586
+ // Ordenar el buffer circular por timestamp (más reciente primero)
587
+ let events = state.events
588
+ .slice()
589
+ .sort((a, b) => b.timestamp - a.timestamp)
590
+
591
+ if (type) {
592
+ events = events.filter(e => e.eventName === type || e.eventName.includes(type))
593
+ }
594
+ if (project) {
595
+ events = events.filter(e => e.project === project)
596
+ }
597
+ if (sessionId) {
598
+ events = events.filter(e => e.sessionId === sessionId)
599
+ }
600
+
601
+ return events.slice(0, limit)
602
+ }
603
+
604
+ /**
605
+ * Estadísticas de uso de herramientas.
606
+ * @param {string} from
607
+ */
608
+ export function getTools(from) {
609
+ const minTs = parseTimeRange(from)
610
+
611
+ const usage = Array.from(state.tools.entries()).map(([toolName, stats]) => ({
612
+ toolName,
613
+ count: stats.count,
614
+ successRate: stats.count > 0 ? stats.successes / stats.count : 0,
615
+ avgDurationMs: stats.count > 0 ? stats.totalDurationMs / stats.count : 0
616
+ })).sort((a, b) => b.count - a.count)
617
+
618
+ // Tasa de aprobación/rechazo desde eventos en el rango
619
+ let approved = 0
620
+ let rejected = 0
621
+ for (const event of state.events) {
622
+ if (event.timestamp < minTs) continue
623
+ if (event.eventName !== 'tool_use' && !event.eventName.includes('tool')) continue
624
+ if (event.attributes.approved === true) approved++
625
+ if (event.attributes.approved === false) rejected++
626
+ }
627
+
628
+ return { usage, decisionRate: { approved, rejected } }
629
+ }
630
+
631
+ /**
632
+ * Lista de agentes registrados.
633
+ */
634
+ export function getAgents() {
635
+ return Array.from(state.agents.values())
636
+ }
637
+
638
+ /**
639
+ * Estadísticas por modelo ordenadas por coste descendente.
640
+ * @param {string} from
641
+ */
642
+ export function getModels(from) {
643
+ return Array.from(state.models.entries())
644
+ .map(([model, stats]) => ({
645
+ model,
646
+ requests: stats.requests,
647
+ tokensInput: stats.tokensInput,
648
+ tokensOutput: stats.tokensOutput,
649
+ cost: stats.cost,
650
+ avgLatencyMs: 0,
651
+ avgTtftMs: 0
652
+ }))
653
+ .sort((a, b) => b.cost - a.cost)
654
+ }
655
+
656
+ // ─── Persistencia ─────────────────────────────────────────────────────────────
657
+
658
+ /**
659
+ * Guarda el estado completo en disco de forma síncrona.
660
+ * Nunca lanza excepción.
661
+ */
662
+ export function saveSync() {
663
+ try {
664
+ fs.mkdirSync(dataDir, { recursive: true })
665
+
666
+ const data = {
667
+ timeseries: Array.from(state.timeseries.entries()),
668
+ sessions: Array.from(state.sessions.values()),
669
+ sessionMappings: Array.from(state.sessionMappings.entries()),
670
+ ignoredSessions: Array.from(state.ignoredSessions),
671
+ events: state.events,
672
+ eventIndex: state.eventIndex,
673
+ totalEvents: state.totalEvents,
674
+ startTime: state.startTime
675
+ }
676
+
677
+ fs.writeFileSync(dataFile, JSON.stringify(data))
678
+ } catch (err) {
679
+ console.error('[store] Error guardando datos en disco:', err.message)
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Carga el estado desde disco al arrancar.
685
+ * Si el archivo no existe o está corrupto, continúa con estado vacío.
686
+ */
687
+ export function loadFromDisk() {
688
+ try {
689
+ const raw = fs.readFileSync(dataFile, 'utf8')
690
+ const data = JSON.parse(raw)
691
+
692
+ // Restaurar timeseries (Map de arrays)
693
+ if (Array.isArray(data.timeseries)) {
694
+ for (const [key, points] of data.timeseries) {
695
+ state.timeseries.set(key, points)
696
+ }
697
+ }
698
+
699
+ // Restaurar sesiones
700
+ if (Array.isArray(data.sessions)) {
701
+ for (const session of data.sessions) {
702
+ state.sessions.set(session.sessionId, session)
703
+ }
704
+ }
705
+
706
+ // Restaurar session mappings
707
+ if (Array.isArray(data.sessionMappings)) {
708
+ for (const [k, v] of data.sessionMappings) {
709
+ state.sessionMappings.set(k, v)
710
+ }
711
+ }
712
+
713
+ // Restaurar sesiones ignoradas
714
+ if (Array.isArray(data.ignoredSessions)) {
715
+ for (const id of data.ignoredSessions) {
716
+ state.ignoredSessions.add(id)
717
+ }
718
+ }
719
+
720
+ // Restaurar buffer de eventos
721
+ if (Array.isArray(data.events)) {
722
+ state.events.push(...data.events)
723
+ }
724
+ if (typeof data.eventIndex === 'number') {
725
+ state.eventIndex = data.eventIndex
726
+ }
727
+ if (typeof data.totalEvents === 'number') {
728
+ state.totalEvents = data.totalEvents
729
+ }
730
+ if (typeof data.startTime === 'number') {
731
+ state.startTime = data.startTime
732
+ }
733
+
734
+ // Re-aplicar mappings a sesiones y timeseries
735
+ for (const [sessionId, project] of state.sessionMappings.entries()) {
736
+ if (state.sessions.has(sessionId)) {
737
+ state.sessions.get(sessionId).project = project
738
+ }
739
+ for (const points of state.timeseries.values()) {
740
+ for (const point of points) {
741
+ if (point.labels['session.id'] === sessionId) {
742
+ point.labels.project = project
743
+ }
744
+ }
745
+ }
746
+ }
747
+
748
+ rebuildProjectAggregates()
749
+ } catch (err) {
750
+ // Archivo no existe o corrupto — arrancar limpio
751
+ if (err.code !== 'ENOENT') {
752
+ console.error('[store] Error cargando datos desde disco:', err.message)
753
+ }
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Inicia el guardado automático cada 60 segundos.
759
+ * @returns {NodeJS.Timeout} Interval ID para cancelar si es necesario
760
+ */
761
+ export function startAutoSave() {
762
+ return setInterval(saveSync, 60_000)
763
+ }