dsh-all-usage 1.1.18 → 1.2.1
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/CHANGELOG.md +469 -394
- package/LICENSE +21 -21
- package/README.en.md +273 -0
- package/README.md +145 -279
- package/assets/model-icons/ai2-color.svg +1 -0
- package/assets/model-icons/ai21-brand-color.svg +1 -0
- package/assets/model-icons/aionlabs.svg +1 -0
- package/assets/model-icons/alephalpha.svg +1 -0
- package/assets/model-icons/baai.svg +1 -0
- package/assets/model-icons/baichuan-color.svg +1 -0
- package/assets/model-icons/baidu-color.svg +1 -0
- package/assets/model-icons/bytedance-color.svg +1 -0
- package/assets/model-icons/cohere-color.svg +1 -0
- package/assets/model-icons/hunyuan-color.svg +1 -0
- package/assets/model-icons/ibm.svg +1 -0
- package/assets/model-icons/iflytekcloud-color.svg +1 -0
- package/assets/model-icons/internlm-color.svg +1 -0
- package/assets/model-icons/kling-color.svg +1 -0
- package/assets/model-icons/liquid.svg +1 -0
- package/assets/model-icons/longcat-color.svg +1 -0
- package/assets/model-icons/manifest.json +739 -213
- package/assets/model-icons/microsoft-color.svg +1 -0
- package/assets/model-icons/mistral-color.svg +1 -0
- package/assets/model-icons/nousresearch.svg +1 -0
- package/assets/model-icons/nova-color.svg +1 -0
- package/assets/model-icons/nvidia-color.svg +1 -0
- package/assets/model-icons/perplexity-color.svg +1 -0
- package/assets/model-icons/sensenova-color.svg +1 -0
- package/assets/model-icons/skywork-color.svg +1 -0
- package/assets/model-icons/snowflake-color.svg +1 -0
- package/assets/model-icons/stepfun-color.svg +1 -0
- package/assets/model-icons/tencent-color.svg +1 -0
- package/assets/model-icons/tii-color.svg +1 -0
- package/assets/model-icons/upstage-color.svg +1 -0
- package/assets/model-icons/voyage-color.svg +1 -0
- package/assets/model-icons/xiaomimimo.svg +1 -0
- package/assets/model-icons/yandex.svg +1 -0
- package/assets/readme-architecture-en.svg +95 -0
- package/assets/readme-architecture-zh.svg +95 -0
- package/assets/readme-sync-en.svg +81 -0
- package/assets/readme-sync-zh.svg +81 -0
- package/assets/screenshots/en/01-overview.png +0 -0
- package/assets/screenshots/en/02-heatmap.png +0 -0
- package/assets/screenshots/en/03-model-usage.png +0 -0
- package/assets/screenshots/en/04-request-logs.png +0 -0
- package/assets/screenshots/en/05-cost-settings.png +0 -0
- package/assets/screenshots/zh/01-overview.png +0 -0
- package/assets/screenshots/zh/02-heatmap.png +0 -0
- package/assets/screenshots/zh/03-model-usage.png +0 -0
- package/assets/screenshots/zh/04-request-logs.png +0 -0
- package/assets/screenshots/zh/05-cost-settings.png +0 -0
- package/cordis.patch.yml +5 -5
- package/lib/aggregation.js +1850 -1112
- package/lib/client.js +1 -1
- package/lib/http.js +417 -385
- package/lib/icons.js +40 -0
- package/lib/ledger.js +1000 -531
- package/lib/plugin.js +348 -327
- package/lib/pricing-runtime.js +645 -551
- package/lib/pricing.js +1488 -1412
- package/lib/records-cache.js +113 -0
- package/lib/session-sync.js +976 -841
- package/lib/usage-core.js +33 -10
- package/lib/version-check.js +133 -133
- package/package.json +7 -3
- package/screenshots.json +10 -3
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
package/lib/session-sync.js
CHANGED
|
@@ -1,841 +1,976 @@
|
|
|
1
|
-
import { RETIRED_WORKSPACE_ID, retargetLedgerRecord } from './ledger.js'
|
|
2
|
-
import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, workspacePathKey } from './usage-core.js'
|
|
3
|
-
|
|
4
|
-
const RECONCILE_INTERVAL_MS = 120000
|
|
5
|
-
const RECONCILE_HINT_DELAY_MS = 3000
|
|
6
|
-
const WORKSPACE_SYNC_DEBOUNCE_MS = 300
|
|
7
|
-
// The registry event is a fast path, not a guarantee: a change that lands while a
|
|
8
|
-
// baseline runs used to be dropped outright, and a missed event left the plugin
|
|
9
|
-
// stale until the next restart. The (in-memory) registry list is polled as well.
|
|
10
|
-
const WORKSPACE_PROBE_INTERVAL_MS = 30000
|
|
11
|
-
|
|
12
|
-
export function createSessionSync(host) {
|
|
13
|
-
const { ctx, state } = host
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const safeContextTimeout = async (ms) => {
|
|
37
|
-
if (state.disposed) return false
|
|
38
|
-
try {
|
|
39
|
-
await ctx.timeout(ms)
|
|
40
|
-
return !state.disposed
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (!state.disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
43
|
-
return false
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
state.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
state.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
state.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
state.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (state.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1
|
+
import { RETIRED_WORKSPACE_ID, retargetLedgerRecord } from './ledger.js'
|
|
2
|
+
import { contextTimeKey, extractUsageEvent, pickPricingTime, touchContextTimes, usageStepKey, workspacePathKey } from './usage-core.js'
|
|
3
|
+
|
|
4
|
+
const RECONCILE_INTERVAL_MS = 120000
|
|
5
|
+
const RECONCILE_HINT_DELAY_MS = 3000
|
|
6
|
+
const WORKSPACE_SYNC_DEBOUNCE_MS = 300
|
|
7
|
+
// The registry event is a fast path, not a guarantee: a change that lands while a
|
|
8
|
+
// baseline runs used to be dropped outright, and a missed event left the plugin
|
|
9
|
+
// stale until the next restart. The (in-memory) registry list is polled as well.
|
|
10
|
+
const WORKSPACE_PROBE_INTERVAL_MS = 30000
|
|
11
|
+
|
|
12
|
+
export function createSessionSync(host) {
|
|
13
|
+
const { ctx, state } = host
|
|
14
|
+
state.retiringWorkspaceIds = state.retiringWorkspaceIds || new Set()
|
|
15
|
+
if (!(state.liveResyncTriggers instanceof Map)) state.liveResyncTriggers = new Map()
|
|
16
|
+
const { sessionPersistence } = host.services
|
|
17
|
+
const {
|
|
18
|
+
validEventTime,
|
|
19
|
+
identityFromRoute,
|
|
20
|
+
identityFromMessage,
|
|
21
|
+
coerceIdentity,
|
|
22
|
+
addTurn,
|
|
23
|
+
addUsage,
|
|
24
|
+
resetAggregationState,
|
|
25
|
+
} = host.aggregation
|
|
26
|
+
const { beginSync, noteSyncError, markStatsChanged } = host
|
|
27
|
+
const {
|
|
28
|
+
sessionEvents,
|
|
29
|
+
buildLedgerRecord,
|
|
30
|
+
applyLedgerRecord,
|
|
31
|
+
storeLedgerRecord,
|
|
32
|
+
replaceLedgerRecord,
|
|
33
|
+
persistLedgerRecord,
|
|
34
|
+
drainLedgerWrites,
|
|
35
|
+
} = host.ledger
|
|
36
|
+
const safeContextTimeout = async (ms) => {
|
|
37
|
+
if (state.disposed) return false
|
|
38
|
+
try {
|
|
39
|
+
await ctx.timeout(ms)
|
|
40
|
+
return !state.disposed
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (!state.disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Session history reads prefer the official observation API: one immutable cut
|
|
47
|
+
// with projection work skipped, released through the lease's Symbol.dispose.
|
|
48
|
+
// A Host without observeSession keeps the legacy readSession snapshot.
|
|
49
|
+
function readSessionSnapshot(sid) {
|
|
50
|
+
const query = ctx.sessionQuery
|
|
51
|
+
if (typeof query.observeSession === 'function') return query.observeSession(sid, { projectionMode: 'none' })
|
|
52
|
+
return query.readSession(sid)
|
|
53
|
+
}
|
|
54
|
+
// Releasing is synchronous and only applies to a lease; a readSession snapshot
|
|
55
|
+
// carries no disposal protocol and needs no action.
|
|
56
|
+
function releaseSessionSnapshot(snap) {
|
|
57
|
+
if (snap === null || snap === undefined) return
|
|
58
|
+
const dispose = typeof Symbol.dispose === 'symbol' ? snap[Symbol.dispose] : undefined
|
|
59
|
+
if (typeof dispose === 'function') dispose.call(snap)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function foldEvent(wsId, time, type, data, sid, seq, materialization = 'live') {
|
|
63
|
+
// The durable record keeps the original workspace id. Its in-memory
|
|
64
|
+
// contribution must follow a removal immediately, including queued live
|
|
65
|
+
// events that captured the old mapping before retirement began.
|
|
66
|
+
if (state.retiringWorkspaceIds.has(wsId)) wsId = RETIRED_WORKSPACE_ID
|
|
67
|
+
if ((type === 'turn/end' || type === 'assistant/message' || type === 'assistant/chunk') && !validEventTime(time)) return
|
|
68
|
+
if (type === 'request/context') {
|
|
69
|
+
state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
|
|
70
|
+
if (validEventTime(time)) {
|
|
71
|
+
let times = state.sessionContextTimes.get(sid)
|
|
72
|
+
if (times === undefined) { times = new Map(); state.sessionContextTimes.set(sid, times) }
|
|
73
|
+
touchContextTimes(times, contextTimeKey(data && data.turn, data && data.step), time)
|
|
74
|
+
}
|
|
75
|
+
} else if (type === 'request/header') {
|
|
76
|
+
state.sessionModel.set(sid, identityFromRoute(data, state.sessionModel.get(sid)))
|
|
77
|
+
} else if (type === 'turn/end') {
|
|
78
|
+
addTurn(wsId, time, sid, data && typeof data.turn === 'number' ? data.turn : null, state.sessionModel.get(sid), materialization, seq)
|
|
79
|
+
} else if (type === 'assistant/message' || type === 'assistant/chunk') {
|
|
80
|
+
const usageEvent = extractUsageEvent({ type, time, data, seq })
|
|
81
|
+
if (usageEvent === null) return
|
|
82
|
+
const identity = usageEvent.kind === 'message' ? identityFromMessage(data, state.sessionModel.get(sid)) : coerceIdentity(state.sessionModel.get(sid))
|
|
83
|
+
state.sessionModel.set(sid, identity)
|
|
84
|
+
const pricing = pickPricingTime(state.sessionContextTimes.get(sid), time, usageEvent.turn, usageEvent.step)
|
|
85
|
+
addUsage(wsId, time, usageEvent.usage, identity, sid, data, seq, materialization, pricing.time, pricing.source)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function foldEvents(wsId, events, fromSeq, sid, materialization = 'scan') {
|
|
89
|
+
// Full folds keep their callers responsible for atomicity: runBaseline and
|
|
90
|
+
// syncLiveSession remove the session's old usage/turn/context/identity
|
|
91
|
+
// first when the snapshot is authoritative (see rebuildSession below), so
|
|
92
|
+
// usage deleted by a history rewrite disappears from the aggregate.
|
|
93
|
+
for (const ev of events) {
|
|
94
|
+
if (fromSeq !== undefined) {
|
|
95
|
+
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
96
|
+
if (s <= fromSeq) continue
|
|
97
|
+
}
|
|
98
|
+
if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'assistant/chunk' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq, materialization)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function lastSeqOf(events) {
|
|
102
|
+
let last = -1
|
|
103
|
+
for (const ev of events) {
|
|
104
|
+
const s = Number.isSafeInteger(ev.seq) ? ev.seq : -1
|
|
105
|
+
if (s > last) last = s
|
|
106
|
+
}
|
|
107
|
+
return last
|
|
108
|
+
}
|
|
109
|
+
function sequenceProfile(events) {
|
|
110
|
+
let previous = -1
|
|
111
|
+
let last = -1
|
|
112
|
+
let nonMonotonic = false
|
|
113
|
+
let hasInvalid = false
|
|
114
|
+
for (const ev of events) {
|
|
115
|
+
const seq = ev && Number.isSafeInteger(ev.seq) && ev.seq >= 0 ? ev.seq : -1
|
|
116
|
+
if (seq < 0) { hasInvalid = true; continue }
|
|
117
|
+
if (seq <= previous) nonMonotonic = true
|
|
118
|
+
previous = seq
|
|
119
|
+
if (seq > last) last = seq
|
|
120
|
+
}
|
|
121
|
+
return { lastSeq: last, nonMonotonic, hasInvalid }
|
|
122
|
+
}
|
|
123
|
+
function enqueue(sid, task) {
|
|
124
|
+
const prev = state.chains.get(sid) || Promise.resolve()
|
|
125
|
+
const next = prev.then(() => task(), () => task())
|
|
126
|
+
state.chains.set(sid, next)
|
|
127
|
+
const cleanup = () => { if (state.chains.get(sid) === next) state.chains.delete(sid) }
|
|
128
|
+
void next.then(cleanup, cleanup)
|
|
129
|
+
return next
|
|
130
|
+
}
|
|
131
|
+
function markLedgerDirty(sid) {
|
|
132
|
+
if (typeof sid !== 'string' || sid === '') return
|
|
133
|
+
state.ledgerDirtySessions.add(sid)
|
|
134
|
+
state.ledgerDirtyEpochs.set(sid, (state.ledgerDirtyEpochs.get(sid) || 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
function buildWorkspaceSnapshot(workspaces) {
|
|
137
|
+
const wsMeta = new Map()
|
|
138
|
+
const pathIndex = new Map()
|
|
139
|
+
const memberOf = new Map()
|
|
140
|
+
for (const w of workspaces) {
|
|
141
|
+
const id = w && w.id
|
|
142
|
+
const path = w && typeof w.path === 'string' ? w.path : ''
|
|
143
|
+
const title = w && typeof w.title === 'string' ? w.title : ''
|
|
144
|
+
if (id === undefined) continue
|
|
145
|
+
wsMeta.set(id, { id, title, path })
|
|
146
|
+
if (path !== '') pathIndex.set(workspacePathKey(path), id)
|
|
147
|
+
if (w && Array.isArray(w.sessionIds)) {
|
|
148
|
+
for (const sid of w.sessionIds) memberOf.set(sid, id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { wsMeta, pathIndex, memberOf }
|
|
152
|
+
}
|
|
153
|
+
function installWorkspaceSnapshot(next) {
|
|
154
|
+
let metadataChanged = state.wsMeta.size !== next.wsMeta.size || state.pathIndex.size !== next.pathIndex.size || state.memberOf.size !== next.memberOf.size
|
|
155
|
+
if (!metadataChanged) {
|
|
156
|
+
for (const [id, value] of next.wsMeta) {
|
|
157
|
+
const previous = state.wsMeta.get(id)
|
|
158
|
+
if (previous === undefined || previous.title !== value.title || previous.path !== value.path) { metadataChanged = true; break }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!metadataChanged) {
|
|
162
|
+
for (const [path, id] of next.pathIndex) if (state.pathIndex.get(path) !== id) { metadataChanged = true; break }
|
|
163
|
+
}
|
|
164
|
+
if (!metadataChanged) {
|
|
165
|
+
for (const [sid, id] of next.memberOf) if (state.memberOf.get(sid) !== id) { metadataChanged = true; break }
|
|
166
|
+
}
|
|
167
|
+
state.wsMeta.clear()
|
|
168
|
+
state.pathIndex.clear()
|
|
169
|
+
state.memberOf.clear()
|
|
170
|
+
for (const [id, value] of next.wsMeta) { state.retiringWorkspaceIds.delete(id); state.wsMeta.set(id, value) }
|
|
171
|
+
for (const [path, id] of next.pathIndex) state.pathIndex.set(path, id)
|
|
172
|
+
for (const [sid, id] of next.memberOf) state.memberOf.set(sid, id)
|
|
173
|
+
if (metadataChanged) markStatsChanged('metadata')
|
|
174
|
+
return metadataChanged
|
|
175
|
+
}
|
|
176
|
+
function workspaceForCwd(cwd) {
|
|
177
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined
|
|
178
|
+
return state.pathIndex.get(workspacePathKey(cwd))
|
|
179
|
+
}
|
|
180
|
+
// DSH 0.1.5 replaced \`sessionPersistence.listSnapshots()\` with \`list()\`; both
|
|
181
|
+
// return per-session \`{ header, revision }\` rows. Without a revision the
|
|
182
|
+
// ledger-reuse fast path never fires, so every baseline re-reads every log.
|
|
183
|
+
async function loadSessionRevisions() {
|
|
184
|
+
const source = sessionPersistence
|
|
185
|
+
if (source === undefined || source === null) return null
|
|
186
|
+
const read = typeof source.list === 'function'
|
|
187
|
+
? () => source.list()
|
|
188
|
+
: (typeof source.listSnapshots === 'function' ? () => source.listSnapshots() : null)
|
|
189
|
+
if (read === null) return null
|
|
190
|
+
const rows = await read()
|
|
191
|
+
if (!Array.isArray(rows)) return null
|
|
192
|
+
const revisions = new Map()
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
const rid = row && row.header && typeof row.header.id === 'string' ? row.header.id : undefined
|
|
195
|
+
if (rid !== undefined && row && typeof row.revision === 'string') revisions.set(rid, row.revision)
|
|
196
|
+
}
|
|
197
|
+
return revisions
|
|
198
|
+
}
|
|
199
|
+
function wsForLiveSession(session, sid) {
|
|
200
|
+
let wsId = state.memberOf.get(sid)
|
|
201
|
+
if (wsId !== undefined) return wsId
|
|
202
|
+
const header = session && session.header
|
|
203
|
+
const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
|
|
204
|
+
wsId = workspaceForCwd(cwd)
|
|
205
|
+
if (wsId !== undefined) state.memberOf.set(sid, wsId)
|
|
206
|
+
return wsId
|
|
207
|
+
}
|
|
208
|
+
function cancelLiveResync(sid) {
|
|
209
|
+
const timer = state.liveResyncTimers.get(sid)
|
|
210
|
+
if (timer !== undefined) {
|
|
211
|
+
clearTimeout(timer)
|
|
212
|
+
state.liveResyncTimers.delete(sid)
|
|
213
|
+
}
|
|
214
|
+
state.liveResyncAttempts.delete(sid)
|
|
215
|
+
state.liveResyncPending.delete(sid)
|
|
216
|
+
state.liveResyncTriggers.delete(sid)
|
|
217
|
+
}
|
|
218
|
+
function usageEventSignature(event) {
|
|
219
|
+
const data = event.data || {}
|
|
220
|
+
const message = data.message || {}
|
|
221
|
+
const usage = event.type === 'assistant/chunk' ? data.chunk && data.chunk.usage : data.usage
|
|
222
|
+
return JSON.stringify([event.seq, event.time, event.type, data.turn, data.step, data.usageId, data.sampleId, data.requestId, data.callId, data.stepId, data.messageId, message.id, message.source, usage])
|
|
223
|
+
}
|
|
224
|
+
function retainLiveResyncTrigger(sid, key, event) {
|
|
225
|
+
let pending = state.liveResyncTriggers.get(sid)
|
|
226
|
+
if (pending === undefined) { pending = new Map(); state.liveResyncTriggers.set(sid, pending) }
|
|
227
|
+
const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
|
|
228
|
+
const previous = pending.get(key)
|
|
229
|
+
if (previous !== undefined && previous.seq > seq) return
|
|
230
|
+
pending.set(key, { seq, signature: usageEventSignature(event) })
|
|
231
|
+
}
|
|
232
|
+
function snapshotCoversTriggers(sid, events) {
|
|
233
|
+
const pending = state.liveResyncTriggers.get(sid)
|
|
234
|
+
if (pending === undefined || pending.size === 0) return true
|
|
235
|
+
const candidates = new Map()
|
|
236
|
+
for (const event of events) {
|
|
237
|
+
if (event === null || typeof event !== 'object' || (event.type !== 'assistant/message' && event.type !== 'assistant/chunk') || event.data === null || typeof event.data !== 'object') continue
|
|
238
|
+
const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
|
|
239
|
+
const key = usageStepKey(sid, event.data, seq, event.time)
|
|
240
|
+
if (!pending.has(key)) continue
|
|
241
|
+
const frame = extractUsageEvent(event)
|
|
242
|
+
if (frame === null) continue
|
|
243
|
+
const previous = candidates.get(key)
|
|
244
|
+
if (previous === undefined || seq > previous.seq) candidates.set(key, { seq, signature: usageEventSignature(event) })
|
|
245
|
+
}
|
|
246
|
+
for (const [key, expected] of pending) {
|
|
247
|
+
const found = candidates.get(key)
|
|
248
|
+
if (found === undefined || found.seq < expected.seq || (found.seq === expected.seq && found.signature !== expected.signature)) return false
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
function scheduleLiveResync(sid, wsId, generation) {
|
|
253
|
+
if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid) || state.liveResyncTimers.has(sid)) return
|
|
254
|
+
const attempt = state.liveResyncAttempts.get(sid) || 0
|
|
255
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, Math.min(attempt, 5)))
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
state.liveResyncTimers.delete(sid)
|
|
258
|
+
if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
|
|
259
|
+
void enqueue(sid, () => resyncLiveSession(sid, wsId, generation))
|
|
260
|
+
}, delay)
|
|
261
|
+
state.liveResyncTimers.set(sid, timer)
|
|
262
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
263
|
+
}
|
|
264
|
+
function foldLiveFallback(sid, wsId, event) {
|
|
265
|
+
if (event === null || event === undefined) return
|
|
266
|
+
const seq = Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1
|
|
267
|
+
foldEvent(wsId, event.time, event.type, event.data, sid, seq, 'live')
|
|
268
|
+
if (seq >= 0) {
|
|
269
|
+
const current = state.sessionSeq.get(sid)
|
|
270
|
+
if (current === undefined || seq > current) state.sessionSeq.set(sid, seq)
|
|
271
|
+
}
|
|
272
|
+
state.sessionCount.add(sid)
|
|
273
|
+
}
|
|
274
|
+
async function syncLiveSession(sid, wsId, event, generation) {
|
|
275
|
+
let snap
|
|
276
|
+
try {
|
|
277
|
+
snap = await readSessionSnapshot(sid)
|
|
278
|
+
if (state.disposed || generation !== state.aggregationGeneration) return true
|
|
279
|
+
const events = snap === null || snap === undefined ? undefined : snap.events
|
|
280
|
+
if (Array.isArray(events)) {
|
|
281
|
+
const snapshotLast = lastSeqOf(events)
|
|
282
|
+
const snapshotProfile = sequenceProfile(events)
|
|
283
|
+
const eventSeq = event === null || event === undefined ? -1 : (Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : -1)
|
|
284
|
+
// The snapshot may replace the session only when it is provably
|
|
285
|
+
// complete AND it already contains the triggering event: a timer / full
|
|
286
|
+
// resync (no event), or an explicitly contained, monotonic event
|
|
287
|
+
// stream. A snapshot that merely has a higher tail than the event is
|
|
288
|
+
// NOT proof — the event could be a live append the snapshot missed (or
|
|
289
|
+
// the snapshot could have been rewritten without it), so the event is
|
|
290
|
+
// upserted and the session stays pending for a follow-up alignment.
|
|
291
|
+
// Events without a usable seq never trigger a destructive replacement.
|
|
292
|
+
const containsEvent = event !== null && event !== undefined && eventSeq >= 0 && events.some((candidate) => candidate && Number.isSafeInteger(candidate.seq) && candidate.seq === eventSeq)
|
|
293
|
+
const coversTriggers = snapshotCoversTriggers(sid, events)
|
|
294
|
+
const authoritative = (event === null || event === undefined ? true : eventSeq < 0 ? false : containsEvent) && !snapshotProfile.nonMonotonic && !snapshotProfile.hasInvalid && coversTriggers
|
|
295
|
+
// A pending usage trigger can only be settled by a complete, sequence-valid
|
|
296
|
+
// rebuild. Even a snapshot containing it is unsafe to fold as live data when
|
|
297
|
+
// unrelated sequence damage makes the snapshot ambiguous.
|
|
298
|
+
if (state.liveResyncTriggers.has(sid) && (!coversTriggers || snapshotProfile.nonMonotonic || snapshotProfile.hasInvalid)) return false
|
|
299
|
+
if (authoritative) {
|
|
300
|
+
host.aggregation.removeSession(sid)
|
|
301
|
+
// The authoritative snapshot is the new truth for both the in-memory
|
|
302
|
+
// aggregate and the durable ledger: rebuild the record from scratch
|
|
303
|
+
// and persist it, so a restart after a live history rewrite cannot
|
|
304
|
+
// resurrect usage that the snapshot deleted.
|
|
305
|
+
const rebuiltRecord = buildLedgerRecord({ id: sid, events }, wsId, 'scan', undefined, null, true)
|
|
306
|
+
if (rebuiltRecord !== null) {
|
|
307
|
+
const canonical = replaceLedgerRecord(rebuiltRecord)
|
|
308
|
+
if (canonical === rebuiltRecord) {
|
|
309
|
+
void persistLedgerRecord(rebuiltRecord).then(() => {
|
|
310
|
+
if (state.ledgerWriteFailedSessions && state.ledgerWriteFailedSessions.has(sid)) {
|
|
311
|
+
console.error('[all-usage] authoritative resync ledger persist failed; session stays dirty:', sid)
|
|
312
|
+
noteSyncError('resync-ledger-persist-failed')
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Complete persisted history may have reversed timestamps even when
|
|
319
|
+
// its sequences are canonical. Only incomplete fallback folds use the
|
|
320
|
+
// live replay heuristics.
|
|
321
|
+
foldEvents(wsId, events, undefined, sid, authoritative ? 'scan' : 'live')
|
|
322
|
+
let nextLast = snapshotLast
|
|
323
|
+
const needsFollowup = !authoritative && event !== null && event !== undefined
|
|
324
|
+
if (needsFollowup) {
|
|
325
|
+
foldLiveFallback(sid, wsId, event)
|
|
326
|
+
const current = state.sessionSeq.get(sid)
|
|
327
|
+
nextLast = Math.max(nextLast, current === undefined ? -1 : current)
|
|
328
|
+
}
|
|
329
|
+
state.sessionSeq.set(sid, nextLast)
|
|
330
|
+
state.sessionCount.add(sid)
|
|
331
|
+
if (needsFollowup) {
|
|
332
|
+
// The snapshot is behind: keep the pending flag so the follow-up
|
|
333
|
+
// resync realigns the cursor once the missing history arrives.
|
|
334
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
335
|
+
} else {
|
|
336
|
+
state.liveResyncPending.delete(sid)
|
|
337
|
+
cancelLiveResync(sid)
|
|
338
|
+
}
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
// Keep the event as a fallback and retry a complete session state.sync later.
|
|
343
|
+
} finally {
|
|
344
|
+
// The lease is held for the whole read — success, failure, or a generation
|
|
345
|
+
// change — and released only once this call is completely done. A legacy
|
|
346
|
+
// readSession snapshot carries no disposal protocol.
|
|
347
|
+
releaseSessionSnapshot(snap)
|
|
348
|
+
}
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
async function resyncLiveSession(sid, wsId, generation) {
|
|
352
|
+
if (state.disposed || generation !== state.aggregationGeneration || !state.liveResyncPending.has(sid)) return
|
|
353
|
+
if (await syncLiveSession(sid, wsId, null, generation)) return
|
|
354
|
+
state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
|
|
355
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
356
|
+
}
|
|
357
|
+
async function processLiveEvent(sid, wsId, event, generation = state.aggregationGeneration) {
|
|
358
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
359
|
+
const seq = Number.isSafeInteger(event.seq) ? event.seq : -1
|
|
360
|
+
const last = state.sessionSeq.get(sid)
|
|
361
|
+
const frame = extractUsageEvent(event)
|
|
362
|
+
const key = frame === null ? null : usageStepKey(sid, frame.data, seq, event.time)
|
|
363
|
+
// A known key can be replaced only after authoritative alignment once its
|
|
364
|
+
// detail is evicted. A replay-shaped new key also needs proof before the live
|
|
365
|
+
// replay fence can discard it, so preserve either trigger through retries.
|
|
366
|
+
const digest = state.perSession.get(sid)
|
|
367
|
+
const knownKey = key !== null && digest?.keys.has(key)
|
|
368
|
+
const evictedCorrection = knownKey && !state.usageByStep.has(key)
|
|
369
|
+
const replayShapedAppend = key !== null && !knownKey && digest !== undefined && (event.time < digest.newestTime || state.turnRecords.has(sid + ':turn:' + frame.turn))
|
|
370
|
+
// A fresh sequence can still look like a replay after a clock correction or
|
|
371
|
+
// late usage delivery. Prove it against persisted history before rejecting it.
|
|
372
|
+
const needsUsageSync = seq > (last ?? -1) && (evictedCorrection || replayShapedAppend)
|
|
373
|
+
const needsSync = last === undefined || state.liveResyncPending.has(sid) || (seq >= 0 && seq > last + 1) || needsUsageSync
|
|
374
|
+
if (needsSync) {
|
|
375
|
+
if (needsUsageSync) retainLiveResyncTrigger(sid, key, event)
|
|
376
|
+
state.liveResyncPending.add(sid)
|
|
377
|
+
if (await syncLiveSession(sid, wsId, event, generation)) return
|
|
378
|
+
// Keep sync-triggering usage out of the cube until history proves whether
|
|
379
|
+
// it is an addition or a replacement for an evicted contribution.
|
|
380
|
+
if (!evictedCorrection) foldLiveFallback(sid, wsId, event)
|
|
381
|
+
state.liveResyncAttempts.set(sid, (state.liveResyncAttempts.get(sid) || 0) + 1)
|
|
382
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (seq < 0) {
|
|
386
|
+
foldLiveFallback(sid, wsId, event)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (seq <= last) return
|
|
390
|
+
foldLiveFallback(sid, wsId, event)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- durable usage ledger ----------
|
|
394
|
+
function scheduleNativeBaselineRetry(generation, delay) {
|
|
395
|
+
if (state.disposed || state.baselineFallbackTimer !== null) return
|
|
396
|
+
state.baselineFallbackTimer = setTimeout(() => {
|
|
397
|
+
state.baselineFallbackTimer = null
|
|
398
|
+
if (!state.disposed && generation === state.aggregationGeneration && !state.scan.started && !state.scan.done) void runBaseline(generation)
|
|
399
|
+
}, delay)
|
|
400
|
+
if (state.baselineFallbackTimer && typeof state.baselineFallbackTimer.unref === 'function') state.baselineFallbackTimer.unref()
|
|
401
|
+
}
|
|
402
|
+
function scheduleBaselineRetry(generation = state.aggregationGeneration) {
|
|
403
|
+
if (state.disposed || state.baselineRetryScheduled || state.scan.done || generation !== state.aggregationGeneration) return
|
|
404
|
+
state.baselineRetryScheduled = true
|
|
405
|
+
const delay = state.baselineRetryDelay
|
|
406
|
+
state.baselineRetryDelay = Math.min(state.baselineRetryDelay * 2, 30000)
|
|
407
|
+
void safeContextTimeout(delay).then((ready) => {
|
|
408
|
+
if (generation !== state.aggregationGeneration) return undefined
|
|
409
|
+
state.baselineRetryScheduled = false
|
|
410
|
+
if (ready && !state.scan.started && !state.scan.done) return runBaseline(generation)
|
|
411
|
+
if (!ready && !state.disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
412
|
+
return undefined
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
function scanOneSession(sid, record, wsId, generation, snapshots) {
|
|
416
|
+
return enqueue(sid, async () => {
|
|
417
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
418
|
+
// Held across the awaits below: the resident cache may drop the record while
|
|
419
|
+
// the session log is being read, and the recovery path still needs it.
|
|
420
|
+
let loadedRecord
|
|
421
|
+
let snap
|
|
422
|
+
try {
|
|
423
|
+
if (state.sessionSeq.has(sid) && !state.liveResyncPending.has(sid)) return
|
|
424
|
+
// v1.0.8: when the persisted log revision is unchanged since the last ledger
|
|
425
|
+
// write, the whole readSession (full event transfer) is skipped — the ledger
|
|
426
|
+
// record is applied directly and the live feed keeps catching new events.
|
|
427
|
+
// The record is not resident any more: a cold start enumerates the ledger
|
|
428
|
+
// keys and every session reads its own document here, on demand.
|
|
429
|
+
if (state.ledgerRecords.get(sid) === undefined) await host.ledger.recordFor(sid)
|
|
430
|
+
const previousRecord = state.ledgerRecords.get(sid)
|
|
431
|
+
loadedRecord = previousRecord
|
|
432
|
+
const revision = snapshots === null ? undefined : snapshots.get(sid)
|
|
433
|
+
if (!state.liveResyncPending.has(sid) && previousRecord !== undefined && previousRecord.needsUpgrade !== true && previousRecord.rebuildRequired === undefined && previousRecord.workspaceId === wsId && typeof previousRecord.lastRevision === 'string' && typeof revision === 'string' && revision === previousRecord.lastRevision) {
|
|
434
|
+
state.sync.sessionsSkippedByRevision += 1
|
|
435
|
+
applyLedgerRecord(previousRecord, 'ledger-reuse')
|
|
436
|
+
state.sessionSeq.set(sid, previousRecord.lastSeq)
|
|
437
|
+
state.sessionCount.add(sid)
|
|
438
|
+
markStatsChanged('scan')
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
state.sync.sessionsRead += 1
|
|
442
|
+
markStatsChanged('scan')
|
|
443
|
+
snap = await readSessionSnapshot(sid)
|
|
444
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
445
|
+
if (snap && Array.isArray(snap.events)) {
|
|
446
|
+
if (state.liveResyncTriggers.has(sid) && !snapshotCoversTriggers(sid, snap.events)) {
|
|
447
|
+
state.liveResyncPending.add(sid)
|
|
448
|
+
const saved = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord
|
|
449
|
+
if (!state.sessionSeq.has(sid) && saved !== undefined) {
|
|
450
|
+
applyLedgerRecord(saved, 'ledger-recovery')
|
|
451
|
+
state.sessionSeq.set(sid, saved.lastSeq)
|
|
452
|
+
state.sessionCount.add(sid)
|
|
453
|
+
}
|
|
454
|
+
scheduleLiveResync(sid, wsId, generation)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
// v1.0.7: incremental seed — the durable ledger doubles as a per-session
|
|
458
|
+
// cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
|
|
459
|
+
// session applies its canonical record directly and never re-folds;
|
|
460
|
+
// a changed session seeds the previous record once, then folds only the
|
|
461
|
+
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
462
|
+
const sequence = sequenceProfile(snap.events)
|
|
463
|
+
const currentLastSeq = sequence.lastSeq
|
|
464
|
+
const hasPendingTrigger = state.liveResyncTriggers.has(sid)
|
|
465
|
+
const previous = hasPendingTrigger ? null : state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord
|
|
466
|
+
if (hasPendingTrigger) host.aggregation.removeSession(sid)
|
|
467
|
+
const canFoldTail = !hasPendingTrigger && previous !== undefined && previous.needsUpgrade !== true && previous.rebuildRequired === undefined && previous.workspaceId === wsId && !sequence.nonMonotonic && !sequence.hasInvalid && previous.lastSeq >= 0 && currentLastSeq > previous.lastSeq
|
|
468
|
+
if (canFoldTail) {
|
|
469
|
+
applyLedgerRecord(previous, 'ledger-reuse')
|
|
470
|
+
foldEvents(wsId, snap.events, previous.lastSeq, sid, 'scan')
|
|
471
|
+
} else {
|
|
472
|
+
// A changed revision with no new tail may still contain a replacement;
|
|
473
|
+
// rebuild from the complete read instead of trusting lastSeq alone.
|
|
474
|
+
foldEvents(wsId, snap.events, undefined, sid, 'scan')
|
|
475
|
+
}
|
|
476
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId, 'scan', revision, previous, !sequence.hasInvalid)
|
|
477
|
+
const canonical = ledger === null ? (state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : previousRecord) : (canFoldTail ? storeLedgerRecord(ledger) : replaceLedgerRecord(ledger))
|
|
478
|
+
if (canonical === ledger) {
|
|
479
|
+
void persistLedgerRecord(ledger)
|
|
480
|
+
}
|
|
481
|
+
const observedLastSeq = state.sessionSeq.get(sid)
|
|
482
|
+
const nextLastSeq = Math.max(currentLastSeq, observedLastSeq === undefined ? -1 : observedLastSeq)
|
|
483
|
+
state.sessionSeq.set(sid, nextLastSeq)
|
|
484
|
+
state.sessionCount.add(sid)
|
|
485
|
+
if (observedLastSeq === undefined || observedLastSeq <= currentLastSeq) cancelLiveResync(sid)
|
|
486
|
+
else scheduleLiveResync(sid, wsId, generation)
|
|
487
|
+
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
if (generation !== state.aggregationGeneration) return
|
|
490
|
+
state.sync.sessionsFailed += 1
|
|
491
|
+
state.scan.failed += 1
|
|
492
|
+
noteSyncError('session-read-failed')
|
|
493
|
+
const saved = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : loadedRecord
|
|
494
|
+
if (saved !== undefined) {
|
|
495
|
+
applyLedgerRecord(saved, 'ledger-recovery')
|
|
496
|
+
if (saved.turns.length > 0 || saved.usage.length > 0) state.sync.sessionsRestoredFromLedger += 1
|
|
497
|
+
state.sessionSeq.set(sid, -1)
|
|
498
|
+
state.sessionCount.add(sid)
|
|
499
|
+
} else {
|
|
500
|
+
state.sessionSeq.set(sid, -1)
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
// The lease is released once the read and the fold are over; the scan
|
|
504
|
+
// bookkeeping still has to run even if the lease disposal does not.
|
|
505
|
+
try {
|
|
506
|
+
releaseSessionSnapshot(snap)
|
|
507
|
+
} finally {
|
|
508
|
+
if (generation === state.aggregationGeneration) {
|
|
509
|
+
state.scan.scanned += 1
|
|
510
|
+
markStatsChanged('scan')
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function runBaseline(generation = state.aggregationGeneration) {
|
|
518
|
+
if (state.scan.started || state.disposed || generation !== state.aggregationGeneration) return
|
|
519
|
+
state.scan.started = true
|
|
520
|
+
beginSync()
|
|
521
|
+
await Promise.all([state.ledgerReady, state.pricingReady])
|
|
522
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
523
|
+
let setupFailed = false
|
|
524
|
+
try {
|
|
525
|
+
const workspaces = ctx.workspaceRegistry.list()
|
|
526
|
+
installWorkspaceSnapshot(buildWorkspaceSnapshot(workspaces))
|
|
527
|
+
} catch (err) {
|
|
528
|
+
console.error('[all-usage] workspace list failed:', err)
|
|
529
|
+
setupFailed = true
|
|
530
|
+
noteSyncError('workspace-list-failed')
|
|
531
|
+
}
|
|
532
|
+
let records = null
|
|
533
|
+
try {
|
|
534
|
+
records = await ctx.sessionQuery.listSessions()
|
|
535
|
+
} catch (err) {
|
|
536
|
+
console.error('[all-usage] session list failed:', err)
|
|
537
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
538
|
+
noteSyncError('session-list-failed')
|
|
539
|
+
}
|
|
540
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
541
|
+
// v1.0.8: cheap per-session change signal (header line + stat, no full-log read)
|
|
542
|
+
let snapshots = null
|
|
543
|
+
try {
|
|
544
|
+
const revisions = await loadSessionRevisions()
|
|
545
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
546
|
+
if (revisions !== null) {
|
|
547
|
+
state.sync.persistenceSnapshotsAvailable = true
|
|
548
|
+
snapshots = revisions
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
552
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
553
|
+
state.sync.persistenceSnapshotsAvailable = false
|
|
554
|
+
markStatsChanged('scan')
|
|
555
|
+
}
|
|
556
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
557
|
+
if (setupFailed || !Array.isArray(records)) {
|
|
558
|
+
// A transient registry failure must not be reported as a completed empty state.scan.
|
|
559
|
+
state.scan.started = false
|
|
560
|
+
markStatsChanged('scan')
|
|
561
|
+
scheduleBaselineRetry(generation)
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
state.scan.total = records.length
|
|
565
|
+
state.sync.sessionsTotal = records.length
|
|
566
|
+
markStatsChanged('scan')
|
|
567
|
+
const listedSessionIds = new Set()
|
|
568
|
+
for (const record of records) {
|
|
569
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
570
|
+
const sid = record.header.id
|
|
571
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
572
|
+
const wsId = workspaceForCwd(cwd)
|
|
573
|
+
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
574
|
+
}
|
|
575
|
+
const ledgerKeyList = Array.from(state.ledgerKeys)
|
|
576
|
+
for (let keyIndex = 0; keyIndex < ledgerKeyList.length; keyIndex += 1) {
|
|
577
|
+
if (keyIndex % 32 === 0) await host.ledger.loadRecords(ledgerKeyList.slice(keyIndex, keyIndex + 32))
|
|
578
|
+
const sid = ledgerKeyList[keyIndex]
|
|
579
|
+
if (listedSessionIds.has(sid)) continue
|
|
580
|
+
const record = await host.ledger.recordFor(sid)
|
|
581
|
+
if (record !== undefined && record !== null) {
|
|
582
|
+
// Recorded usage survives deletion. A session whose workspace, cwd or
|
|
583
|
+
// log is gone still counts from its ledger row; only legacy synthetic
|
|
584
|
+
// rows, which never belonged to a registered workspace, stay excluded.
|
|
585
|
+
if (typeof record.workspaceId === 'string' && record.workspaceId.startsWith('unregistered:')) continue
|
|
586
|
+
// Usage from a workspace that is no longer registered is summed into the
|
|
587
|
+
// shared deleted bucket, so history survives without a row per dead
|
|
588
|
+
// workspace. The stored row keeps its original workspace id and cwd.
|
|
589
|
+
const retired = typeof record.workspaceId !== 'string' || !state.wsMeta.has(record.workspaceId)
|
|
590
|
+
const carriesUsage = record.turns.length > 0 || record.usage.length > 0
|
|
591
|
+
// An empty row must not advertise a bucket that holds nothing.
|
|
592
|
+
if (retired && carriesUsage) state.retiredBucketUsed = true
|
|
593
|
+
applyLedgerRecord(retired ? retargetLedgerRecord(record) : record, retired ? 'ledger-retired' : 'ledger-recovery')
|
|
594
|
+
if (carriesUsage) state.sync.sessionsRestoredFromLedger += 1
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (state.sync.sessionsRestoredFromLedger > 0) markStatsChanged('scan')
|
|
598
|
+
// Prefetch a small window of documents so the reads of the sessions the scan is
|
|
599
|
+
// about to visit overlap the folding work (kept aligned with the records array).
|
|
600
|
+
const scanIds = records.map((entry) => (entry !== null && entry !== undefined && entry.header !== undefined && typeof entry.header.id === 'string' ? entry.header.id : null))
|
|
601
|
+
for (let scanIndex = 0; scanIndex < records.length; scanIndex += 1) {
|
|
602
|
+
const record = records[scanIndex]
|
|
603
|
+
if (scanIndex % 24 === 0) await host.ledger.loadRecords(scanIds.slice(scanIndex, scanIndex + 24))
|
|
604
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
605
|
+
if (record === undefined || record === null || record.header === undefined) {
|
|
606
|
+
state.scan.scanned += 1
|
|
607
|
+
markStatsChanged('scan')
|
|
608
|
+
continue
|
|
609
|
+
}
|
|
610
|
+
const sid = record.header.id
|
|
611
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
612
|
+
const wsId = workspaceForCwd(cwd)
|
|
613
|
+
if (sid === undefined || wsId === undefined) {
|
|
614
|
+
state.scan.scanned += 1
|
|
615
|
+
markStatsChanged('scan')
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
listedSessionIds.add(sid)
|
|
619
|
+
await scanOneSession(sid, record, wsId, generation, snapshots)
|
|
620
|
+
if (!(await safeContextTimeout(0))) {
|
|
621
|
+
state.scan.started = false
|
|
622
|
+
noteSyncError('baseline-yield-unavailable')
|
|
623
|
+
scheduleBaselineRetry(generation)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
628
|
+
await drainLedgerWrites()
|
|
629
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
630
|
+
const pricingReconciliation = await host.pricing.reconcileUsageCosts({ backfill: true })
|
|
631
|
+
if (pricingReconciliation.backfill.priced > 0 || pricingReconciliation.temporal.reconciled > 0) {
|
|
632
|
+
await drainLedgerWrites()
|
|
633
|
+
markStatsChanged('pricing')
|
|
634
|
+
}
|
|
635
|
+
if (state.disposed || generation !== state.aggregationGeneration) return
|
|
636
|
+
// Everything above (folding, backfill, temporal reconcile) needs the rows; from
|
|
637
|
+
// here on they are only needed to render logs, so the settled sessions hand
|
|
638
|
+
// theirs back to the ledger and are read again on demand.
|
|
639
|
+
const evicted = host.aggregation.evictColdRows()
|
|
640
|
+
if (evicted.rows > 0) console.info('[all-usage] dropped ' + evicted.rows + ' resident usage rows from ' + evicted.sessions + ' settled sessions')
|
|
641
|
+
state.knownSessionIds.clear()
|
|
642
|
+
for (const sid of listedSessionIds) state.knownSessionIds.add(sid)
|
|
643
|
+
state.scan.done = true
|
|
644
|
+
if (state.workspaceSyncDeferred) {
|
|
645
|
+
state.workspaceSyncDeferred = false
|
|
646
|
+
scheduleWorkspaceSync()
|
|
647
|
+
}
|
|
648
|
+
state.sync.lastCompletedAt = Date.now()
|
|
649
|
+
if (state.sync.sessionsFailed === 0) {
|
|
650
|
+
state.sync.lastErrorAt = 0
|
|
651
|
+
state.sync.lastErrorCode = null
|
|
652
|
+
}
|
|
653
|
+
markStatsChanged('scan')
|
|
654
|
+
if (state.reconcilePending) scheduleReconcileHint()
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function scheduleWorkspaceSync() {
|
|
658
|
+
if (state.disposed) return
|
|
659
|
+
if (state.workspaceSyncTimer !== null) return
|
|
660
|
+
state.workspaceSyncTimer = setTimeout(() => {
|
|
661
|
+
state.workspaceSyncTimer = null
|
|
662
|
+
if (!state.disposed) void runWorkspaceSync()
|
|
663
|
+
}, WORKSPACE_SYNC_DEBOUNCE_MS)
|
|
664
|
+
if (state.workspaceSyncTimer && typeof state.workspaceSyncTimer.unref === 'function') state.workspaceSyncTimer.unref()
|
|
665
|
+
}
|
|
666
|
+
async function runWorkspaceSync() {
|
|
667
|
+
if (state.disposed) return
|
|
668
|
+
if (state.workspaceSyncInFlight) {
|
|
669
|
+
state.workspaceSyncPending = true
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
state.workspaceSyncInFlight = true
|
|
673
|
+
state.sync.workspaceSyncRuns += 1
|
|
674
|
+
try {
|
|
675
|
+
await synchronizeWorkspaceRegistry()
|
|
676
|
+
} catch (err) {
|
|
677
|
+
console.error('[all-usage] workspace synchronize failed:', err)
|
|
678
|
+
noteSyncError('workspace-sync-failed')
|
|
679
|
+
} finally {
|
|
680
|
+
state.workspaceSyncInFlight = false
|
|
681
|
+
if (state.workspaceSyncPending) {
|
|
682
|
+
state.workspaceSyncPending = false
|
|
683
|
+
scheduleWorkspaceSync()
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async function synchronizeWorkspaceRegistry() {
|
|
688
|
+
if (state.disposed) return
|
|
689
|
+
// A registry change that lands while a baseline is running used to be
|
|
690
|
+
// dropped outright, which is why a new workspace could stay invisible until
|
|
691
|
+
// the next restart. Remember it and re-run once the scan settles.
|
|
692
|
+
if (!state.scan.done) {
|
|
693
|
+
state.workspaceSyncDeferred = true
|
|
694
|
+
state.sync.workspaceSyncDeferred += 1
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
let workspaces
|
|
698
|
+
try {
|
|
699
|
+
workspaces = ctx.workspaceRegistry.list()
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.error('[all-usage] workspace list failed:', err)
|
|
702
|
+
noteSyncError('workspace-list-failed')
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
const next = buildWorkspaceSnapshot(workspaces)
|
|
706
|
+
const removedWs = new Set()
|
|
707
|
+
for (const id of state.wsMeta.keys()) if (!next.wsMeta.has(id)) removedWs.add(id)
|
|
708
|
+
const addedWs = new Set()
|
|
709
|
+
for (const id of next.wsMeta.keys()) if (!state.wsMeta.has(id)) addedWs.add(id)
|
|
710
|
+
for (const id of removedWs) state.retiringWorkspaceIds.add(id)
|
|
711
|
+
state.sync.workspaceSyncAdded += addedWs.size
|
|
712
|
+
state.sync.workspaceSyncRemoved += removedWs.size
|
|
713
|
+
// Reuse path: no membership change — metadata only, zero rescan.
|
|
714
|
+
if (removedWs.size === 0 && addedWs.size === 0) {
|
|
715
|
+
installWorkspaceSnapshot(next)
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
// Retire path: a deregistered workspace keeps everything it recorded. The
|
|
719
|
+
// ledger's promise is that flushed usage survives deletion, so sessions are
|
|
720
|
+
// re-applied under the shared deleted bucket instead of being subtracted and
|
|
721
|
+
// dropped. New sessions still cannot enter the workspace: pathIndex only
|
|
722
|
+
// ever carries registered workspaces, so workspaceForCwd() no longer
|
|
723
|
+
// resolves its cwd and the scan skips it.
|
|
724
|
+
if (removedWs.size > 0) {
|
|
725
|
+
const retiredSids = new Set()
|
|
726
|
+
for (const sid of state.ledgerKeys) {
|
|
727
|
+
const record = await host.ledger.recordFor(sid)
|
|
728
|
+
if (record === null || record === undefined || !removedWs.has(record.workspaceId)) continue
|
|
729
|
+
retiredSids.add(sid)
|
|
730
|
+
}
|
|
731
|
+
for (const [sid, id] of state.memberOf) if (removedWs.has(id)) retiredSids.add(sid)
|
|
732
|
+
let moved = 0
|
|
733
|
+
for (const sid of retiredSids) {
|
|
734
|
+
if (typeof sid !== 'string' || sid === '') continue
|
|
735
|
+
// retargetSession() walks the session's rows: bring back any that were
|
|
736
|
+
// dropped from memory, or the workspace split would keep the old bucket.
|
|
737
|
+
if (typeof host.ledger.loadRowsForSession === 'function') await host.ledger.loadRowsForSession(sid)
|
|
738
|
+
// Re-key the live aggregate first: usage folded from the feed but not yet
|
|
739
|
+
// persisted has no ledger row to replay, and a session the registry no
|
|
740
|
+
// longer lists is never re-scanned afterwards.
|
|
741
|
+
moved += host.aggregation.retargetSession(sid, RETIRED_WORKSPACE_ID)
|
|
742
|
+
const record = state.ledgerRecords.get(sid)
|
|
743
|
+
// Keep the in-memory record aligned with the aggregate; the persisted row
|
|
744
|
+
// keeps its original workspace id and cwd, so recovery re-derives this.
|
|
745
|
+
if (record !== undefined && (record.turns.length > 0 || record.usage.length > 0)) moved += 1
|
|
746
|
+
if (record !== undefined) state.ledgerRecords.set(sid, retargetLedgerRecord(record))
|
|
747
|
+
}
|
|
748
|
+
if (moved > 0) state.retiredBucketUsed = true
|
|
749
|
+
}
|
|
750
|
+
installWorkspaceSnapshot(next)
|
|
751
|
+
// Add path: scan only the sessions that belong to newly registered workspaces.
|
|
752
|
+
if (addedWs.size === 0) return
|
|
753
|
+
let records = null
|
|
754
|
+
try {
|
|
755
|
+
records = await ctx.sessionQuery.listSessions()
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error('[all-usage] session list failed:', err)
|
|
758
|
+
noteSyncError('session-list-failed')
|
|
759
|
+
}
|
|
760
|
+
if (!Array.isArray(records)) return
|
|
761
|
+
state.sync.sessionsTotal = records.length
|
|
762
|
+
markStatsChanged('scan')
|
|
763
|
+
let snapshots = null
|
|
764
|
+
try {
|
|
765
|
+
snapshots = await loadSessionRevisions()
|
|
766
|
+
} catch (err) {
|
|
767
|
+
console.error('[all-usage] session persistence snapshots unavailable:', err)
|
|
768
|
+
}
|
|
769
|
+
for (const record of records) {
|
|
770
|
+
if (state.disposed) return
|
|
771
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
772
|
+
const sid = record.header.id
|
|
773
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
774
|
+
const wsId = workspaceForCwd(cwd)
|
|
775
|
+
if (sid === undefined || wsId === undefined) continue
|
|
776
|
+
if (!addedWs.has(wsId)) continue
|
|
777
|
+
state.knownSessionIds.add(sid)
|
|
778
|
+
scanOneSession(sid, record, wsId, state.aggregationGeneration, snapshots)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function sessionIdsFromRecords(records) {
|
|
783
|
+
const ids = new Set()
|
|
784
|
+
for (const record of records) {
|
|
785
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
786
|
+
const sid = record.header.id
|
|
787
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
788
|
+
const wsId = workspaceForCwd(cwd)
|
|
789
|
+
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
790
|
+
}
|
|
791
|
+
return ids
|
|
792
|
+
}
|
|
793
|
+
async function reconcileSessions() {
|
|
794
|
+
if (state.disposed || state.reconcileInFlight || !state.scan.done) return
|
|
795
|
+
state.reconcilePending = false
|
|
796
|
+
state.reconcileInFlight = true
|
|
797
|
+
try {
|
|
798
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
799
|
+
if (state.disposed || !Array.isArray(records)) return
|
|
800
|
+
const currentIds = sessionIdsFromRecords(records)
|
|
801
|
+
let removed = false
|
|
802
|
+
for (const sid of state.knownSessionIds) {
|
|
803
|
+
if (!currentIds.has(sid)) { removed = true; break }
|
|
804
|
+
}
|
|
805
|
+
if (removed && !state.disposed && state.scan.done) {
|
|
806
|
+
console.info('[all-usage] session removal detected; rebuilding usage index')
|
|
807
|
+
const generation = resetAggregationState()
|
|
808
|
+
void runBaseline(generation)
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
state.knownSessionIds.clear()
|
|
812
|
+
for (const sid of currentIds) state.knownSessionIds.add(sid)
|
|
813
|
+
} catch (err) {
|
|
814
|
+
console.error('[all-usage] session reconciliation failed:', err)
|
|
815
|
+
noteSyncError('session-reconcile-failed')
|
|
816
|
+
} finally {
|
|
817
|
+
state.reconcileInFlight = false
|
|
818
|
+
if (state.reconcilePending && !state.disposed) scheduleReconcileHint()
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function scheduleReconcileHint() {
|
|
822
|
+
if (state.disposed) return
|
|
823
|
+
state.reconcilePending = true
|
|
824
|
+
if (state.reconcileHintScheduled || state.reconcileInFlight) return
|
|
825
|
+
state.reconcileHintScheduled = true
|
|
826
|
+
const generation = state.aggregationGeneration
|
|
827
|
+
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
828
|
+
if (generation !== state.aggregationGeneration) return
|
|
829
|
+
state.reconcileHintScheduled = false
|
|
830
|
+
if (ready && !state.disposed) void reconcileSessions()
|
|
831
|
+
}, () => {
|
|
832
|
+
if (generation === state.aggregationGeneration) state.reconcileHintScheduled = false
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
function scheduleReconcileTimer() {
|
|
836
|
+
if (state.disposed || state.reconcileTimer !== null) return
|
|
837
|
+
state.reconcileTimer = setTimeout(() => {
|
|
838
|
+
state.reconcileTimer = null
|
|
839
|
+
if (!state.disposed) {
|
|
840
|
+
void reconcileSessions()
|
|
841
|
+
scheduleReconcileTimer()
|
|
842
|
+
}
|
|
843
|
+
}, RECONCILE_INTERVAL_MS)
|
|
844
|
+
if (state.reconcileTimer && typeof state.reconcileTimer.unref === 'function') state.reconcileTimer.unref()
|
|
845
|
+
}
|
|
846
|
+
// Belt and braces for the registry probe: the event fast path can be missed
|
|
847
|
+
// (dropped while scanning, or never delivered), and unlike the event this
|
|
848
|
+
// cannot silently leave the workspace list stale until a restart.
|
|
849
|
+
function scheduleWorkspaceProbeTimer() {
|
|
850
|
+
if (state.disposed || state.workspaceProbeTimer !== null) return
|
|
851
|
+
state.workspaceProbeTimer = setTimeout(() => {
|
|
852
|
+
state.workspaceProbeTimer = null
|
|
853
|
+
if (!state.disposed) {
|
|
854
|
+
void runWorkspaceSync()
|
|
855
|
+
scheduleWorkspaceProbeTimer()
|
|
856
|
+
}
|
|
857
|
+
}, WORKSPACE_PROBE_INTERVAL_MS)
|
|
858
|
+
if (state.workspaceProbeTimer && typeof state.workspaceProbeTimer.unref === 'function') state.workspaceProbeTimer.unref()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ---------- live feed ----------
|
|
862
|
+
ctx.on('session/event', (session, event) => {
|
|
863
|
+
if (state.disposed) return
|
|
864
|
+
if (event === undefined || event === null) return
|
|
865
|
+
const type = event.type
|
|
866
|
+
if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'assistant/chunk' && type !== 'request/context' && type !== 'request/header') return
|
|
867
|
+
const sid = session && session.id
|
|
868
|
+
if (typeof sid !== 'string') return
|
|
869
|
+
markLedgerDirty(sid)
|
|
870
|
+
const wsId = wsForLiveSession(session, sid)
|
|
871
|
+
if (wsId === undefined) return
|
|
872
|
+
const generation = state.aggregationGeneration
|
|
873
|
+
state.knownSessionIds.add(sid)
|
|
874
|
+
enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
|
|
875
|
+
})
|
|
876
|
+
ctx.on('session/flush', async (session) => {
|
|
877
|
+
if (state.disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
878
|
+
const sid = session.id
|
|
879
|
+
// Flush changes the same record as live resync and pricing. Serialize the
|
|
880
|
+
// complete read/build/write checkpoint with those mutations.
|
|
881
|
+
return enqueue(sid, async () => {
|
|
882
|
+
if (state.disposed) return
|
|
883
|
+
const previous = state.ledgerRecords.get(sid) !== undefined ? state.ledgerRecords.get(sid) : await host.ledger.recordFor(sid)
|
|
884
|
+
const eventList = sessionEvents(session)
|
|
885
|
+
// DSH sequences events with seq === log index: the tail event gives the
|
|
886
|
+
// latest sequence in O(1) without scanning a 500K-event session twice.
|
|
887
|
+
const tailEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null
|
|
888
|
+
const currentLastSeq = tailEvent !== null && Number.isSafeInteger(tailEvent.seq) && tailEvent.seq >= 0 ? tailEvent.seq : lastSeqOf(eventList)
|
|
889
|
+
const hasNewEvents = state.ledgerDirtySessions.has(sid) || state.ledgerWriteFailedSessions.has(sid) || (previous === undefined && currentLastSeq >= 0) || (previous !== undefined && currentLastSeq > previous.lastSeq)
|
|
890
|
+
if (!hasNewEvents) return
|
|
891
|
+
const dirtyEpoch = state.ledgerDirtyEpochs.get(sid) || 0
|
|
892
|
+
await Promise.all([state.ledgerReady, state.pricingReady])
|
|
893
|
+
if (state.disposed) return
|
|
894
|
+
const wsId = wsForLiveSession(session, sid)
|
|
895
|
+
if (wsId === undefined) return
|
|
896
|
+
// Any dirty flush whose log contains invalid sequences may have rewritten
|
|
897
|
+
// history (even when a new tail was appended) or carries contract-external
|
|
898
|
+
// events; refuse to fold it and rebuild the whole usage index instead, so
|
|
899
|
+
// the in-memory aggregate and the persisted ledger cannot disagree.
|
|
900
|
+
if (previous !== undefined && eventList.length > 0) {
|
|
901
|
+
const sequence = sequenceProfile(eventList)
|
|
902
|
+
if (sequence.hasInvalid) {
|
|
903
|
+
if (state.scan.done && !state.disposed) {
|
|
904
|
+
console.info('[all-usage] invalid sequences in flushed log; rebuilding usage index')
|
|
905
|
+
// Persist the rebuild flag so the baseline cannot fast-path the stale
|
|
906
|
+
// record (the persistence revision may still match a lagging log).
|
|
907
|
+
const current = state.ledgerRecords.get(sid)
|
|
908
|
+
if (current !== undefined) {
|
|
909
|
+
current.rebuildRequired = 'invalid-flush-sequence'
|
|
910
|
+
current.needsUpgrade = true
|
|
911
|
+
void persistLedgerRecord(current)
|
|
912
|
+
}
|
|
913
|
+
const generation = resetAggregationState()
|
|
914
|
+
void runBaseline(generation)
|
|
915
|
+
}
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush', undefined, previous)
|
|
920
|
+
if (ledger === null) return
|
|
921
|
+
const canonical = storeLedgerRecord(ledger)
|
|
922
|
+
if (canonical === ledger) {
|
|
923
|
+
const persisted = persistLedgerRecord(ledger)
|
|
924
|
+
// This is an explicit checkpoint; bypass the background debounce.
|
|
925
|
+
await drainLedgerWrites()
|
|
926
|
+
await persisted
|
|
927
|
+
}
|
|
928
|
+
if (state.ledgerDirtyEpochs.get(sid) === dirtyEpoch) {
|
|
929
|
+
state.ledgerDirtySessions.delete(sid)
|
|
930
|
+
state.ledgerDirtyEpochs.delete(sid)
|
|
931
|
+
}
|
|
932
|
+
})
|
|
933
|
+
})
|
|
934
|
+
ctx.on('session/disposed', () => {
|
|
935
|
+
if (!state.disposed) scheduleReconcileHint()
|
|
936
|
+
})
|
|
937
|
+
// Workspace registry probe: DSH emits domain/changed after every durable
|
|
938
|
+
// workspace-domain write (create/delete/rename/reorder/archive/attach/detach).
|
|
939
|
+
// On any such change we re-read workspaceRegistry.list() and, when membership
|
|
940
|
+
// changed, rescan only the affected workspaces — unchanged ones keep their
|
|
941
|
+
// computed aggregates and ledger untouched.
|
|
942
|
+
ctx.on('domain/changed', (change) => {
|
|
943
|
+
if (state.disposed) return
|
|
944
|
+
if (change === undefined || change === null) return
|
|
945
|
+
if (change.domain !== 'workspace') return
|
|
946
|
+
if (change.table !== 'workspaces' && change.table !== '') return
|
|
947
|
+
state.sync.workspaceProbeEvents += 1
|
|
948
|
+
scheduleWorkspaceSync()
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
foldEvent,
|
|
953
|
+
foldEvents,
|
|
954
|
+
lastSeqOf,
|
|
955
|
+
sequenceProfile,
|
|
956
|
+
enqueue,
|
|
957
|
+
wsForLiveSession,
|
|
958
|
+
cancelLiveResync,
|
|
959
|
+
scheduleLiveResync,
|
|
960
|
+
foldLiveFallback,
|
|
961
|
+
syncLiveSession,
|
|
962
|
+
resyncLiveSession,
|
|
963
|
+
processLiveEvent,
|
|
964
|
+
scheduleNativeBaselineRetry,
|
|
965
|
+
scheduleBaselineRetry,
|
|
966
|
+
runBaseline,
|
|
967
|
+
synchronizeWorkspaceRegistry,
|
|
968
|
+
scheduleWorkspaceSync,
|
|
969
|
+
sessionIdsFromRecords,
|
|
970
|
+
reconcileSessions,
|
|
971
|
+
scheduleReconcileHint,
|
|
972
|
+
scheduleReconcileTimer,
|
|
973
|
+
scheduleWorkspaceProbeTimer,
|
|
974
|
+
markLedgerDirty
|
|
975
|
+
}
|
|
976
|
+
}
|