fraim 2.0.339 → 2.0.341
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/dist/registry/scripts/learning/completion-state.js +161 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +165 -37
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +224 -20
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +13 -14
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +235 -79
- package/dist/src/local-mcp-server/learning-usage-verify.js +13 -26
- package/dist/src/local-mcp-server/stdio-server.js +295 -112
- package/dist/src/local-mcp-server/usage-collector.js +5 -29
- package/dist/src/mcp/tool-schemas.js +2 -2
- package/dist/src/routes/analytics.js +10 -3
- package/dist/src/services/mcp-service.js +71 -11
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +2 -1
|
@@ -10,6 +10,7 @@ exports.ruleUsageKey = ruleUsageKey;
|
|
|
10
10
|
exports.resolveEntryIdentity = resolveEntryIdentity;
|
|
11
11
|
exports.normalizeModelName = normalizeModelName;
|
|
12
12
|
exports.readUsageSnapshot = readUsageSnapshot;
|
|
13
|
+
exports.resolveUsageCheckpointPath = resolveUsageCheckpointPath;
|
|
13
14
|
exports.writeUsageSnapshot = writeUsageSnapshot;
|
|
14
15
|
exports.foldUsageEvents = foldUsageEvents;
|
|
15
16
|
exports.readUsageStore = readUsageStore;
|
|
@@ -46,18 +47,10 @@ exports.compactUsageEvents = compactUsageEvents;
|
|
|
46
47
|
* (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
|
|
47
48
|
* second guardrail here.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* ## Issue #1752 — what changed and why
|
|
54
|
-
*
|
|
55
|
-
* The durable artifact is now the append-only event log in
|
|
56
|
-
* `learning-usage-events.ts`; `usage/learning-usage.json` is a derived snapshot
|
|
57
|
-
* carrying a `foldedThroughBytes` checkpoint into it. The old read-modify-write
|
|
58
|
-
* lost 1589 of 1600 writes from eight concurrent processes on a plain local disk
|
|
59
|
-
* (spike S1b), which is the real cause of both the impossible `fired > offered`
|
|
60
|
-
* counts and the four OneDrive conflict copies.
|
|
50
|
+
* Sleep consolidates the append-only journal into learning-usage.json and its
|
|
51
|
+
* recoverable .checkpoint copy before clearing processed events. Those snapshots
|
|
52
|
+
* preserve lifetime aggregates and source bookmarks after journal cleanup.
|
|
53
|
+
* Raw retrospectives preserve lessons and attestations, not every runtime offer.
|
|
61
54
|
*
|
|
62
55
|
* Four smaller corrections ride with it, each removing a place where the record
|
|
63
56
|
* gave a confident answer it could not prove:
|
|
@@ -237,6 +230,13 @@ function normalizeModelName(model) {
|
|
|
237
230
|
function emptyStore() {
|
|
238
231
|
return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {}, foldedThroughBytes: {}, offersEraStart: null };
|
|
239
232
|
}
|
|
233
|
+
function withEvidence(store, evidence) {
|
|
234
|
+
Object.defineProperty(store, 'evidence', { value: evidence, enumerable: false, configurable: true });
|
|
235
|
+
return store;
|
|
236
|
+
}
|
|
237
|
+
function unavailableStore(observation) {
|
|
238
|
+
return withEvidence(emptyStore(), { status: 'unavailable', excludedKeys: [], observations: [observation] });
|
|
239
|
+
}
|
|
240
240
|
/**
|
|
241
241
|
* Is this parsed value a record every consumer can read without crashing?
|
|
242
242
|
*
|
|
@@ -261,14 +261,21 @@ function isUsableRecord(value) {
|
|
|
261
261
|
const fired = record.fired;
|
|
262
262
|
if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
|
|
263
263
|
return false;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
&&
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
&&
|
|
270
|
-
&&
|
|
271
|
-
&&
|
|
264
|
+
const count = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
265
|
+
const counts = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
266
|
+
&& Object.values(v).every(count);
|
|
267
|
+
const nullableString = (v) => v === null || typeof v === 'string';
|
|
268
|
+
return typeof record.key === 'string' && (record.family === 'learning' || record.family === 'rule')
|
|
269
|
+
&& Array.isArray(offered.log) && offered.log.every(r => r && isValidDate(r.date)
|
|
270
|
+
&& typeof r.job === 'string' && nullableString(r.model))
|
|
271
|
+
&& count(offered.total) && count(offered.aboveThreshold)
|
|
272
|
+
&& counts(offered.byModel)
|
|
273
|
+
&& Array.isArray(fired.log) && fired.log.every(r => r && isValidDate(r.date)
|
|
274
|
+
&& typeof r.job === 'string' && nullableString(r.model) && exports.FIRING_OUTCOMES.includes(r.outcome))
|
|
275
|
+
&& count(fired.total) && counts(fired.byOutcome) && counts(fired.byModel)
|
|
276
|
+
&& exports.FIRING_OUTCOMES.every(outcome => count(fired.byOutcome[outcome]))
|
|
277
|
+
&& Array.isArray(fired.seenSources) && fired.seenSources.every(s => typeof s === 'string')
|
|
278
|
+
&& Array.isArray(record.titles) && record.titles.every(t => typeof t === 'string');
|
|
272
279
|
}
|
|
273
280
|
/**
|
|
274
281
|
* The earliest offer date a pre-#1752 snapshot can evidence.
|
|
@@ -306,32 +313,63 @@ function readCheckpoints(value) {
|
|
|
306
313
|
}
|
|
307
314
|
return out;
|
|
308
315
|
}
|
|
309
|
-
/**
|
|
310
|
-
* Parse the snapshot alone, with no event folding. A missing or corrupt snapshot
|
|
311
|
-
* reads as empty: it is derived data, so losing it is recoverable and must never
|
|
312
|
-
* break the loader that reads it.
|
|
313
|
-
*/
|
|
316
|
+
/** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
|
|
314
317
|
function readUsageSnapshot(storePath = resolveUsageStorePath()) {
|
|
318
|
+
const primary = parseUsageSnapshot(storePath);
|
|
319
|
+
if (primary && primary.evidence?.status !== 'unavailable')
|
|
320
|
+
return primary;
|
|
321
|
+
// An unknown schema is not evidence that an older checkpoint can replace it.
|
|
322
|
+
if (primary?.evidence?.observations.includes('unsupported-schema'))
|
|
323
|
+
return primary;
|
|
324
|
+
const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
|
|
325
|
+
if (backup && !backup.evidence)
|
|
326
|
+
return withEvidence(backup, {
|
|
327
|
+
status: 'reliable', excludedKeys: [],
|
|
328
|
+
observations: [(0, fs_1.existsSync)(storePath) ? 'primary-unreadable' : 'primary-missing'],
|
|
329
|
+
recovery: 'checkpoint', persisted: false,
|
|
330
|
+
});
|
|
331
|
+
if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
|
|
332
|
+
throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
|
|
333
|
+
}
|
|
334
|
+
return primary ?? emptyStore();
|
|
335
|
+
}
|
|
336
|
+
function resolveUsageCheckpointPath(storePath) {
|
|
337
|
+
return storePath + '.checkpoint';
|
|
338
|
+
}
|
|
339
|
+
function parseUsageSnapshot(storePath) {
|
|
315
340
|
try {
|
|
316
341
|
if (!(0, fs_1.existsSync)(storePath))
|
|
317
|
-
return
|
|
342
|
+
return null;
|
|
318
343
|
const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
|
|
319
344
|
// `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
|
|
320
345
|
// Object.values yields whatever the array held and every consumer that reads
|
|
321
346
|
// `entry.offered` throws on it.
|
|
322
347
|
if (!parsed || typeof parsed !== 'object' || !parsed.entries
|
|
323
348
|
|| typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
324
|
-
return
|
|
349
|
+
return unavailableStore('malformed-snapshot');
|
|
350
|
+
}
|
|
351
|
+
if (parsed.version !== undefined && parsed.version !== STORE_VERSION)
|
|
352
|
+
return unavailableStore('unsupported-schema');
|
|
353
|
+
if (parsed.foldedThroughBytes !== undefined && (!parsed.foldedThroughBytes
|
|
354
|
+
|| typeof parsed.foldedThroughBytes !== 'object' || Array.isArray(parsed.foldedThroughBytes)
|
|
355
|
+
|| Object.values(parsed.foldedThroughBytes).some(v => !Number.isSafeInteger(v) || v < 0))) {
|
|
356
|
+
return unavailableStore('malformed-checkpoints');
|
|
325
357
|
}
|
|
326
358
|
// Drop any value that is not a usable record rather than handing a caller
|
|
327
359
|
// something it will crash on. A partially corrupt store degrades to the records
|
|
328
|
-
// that are still readable
|
|
360
|
+
// that are still readable before consolidation. Consolidated snapshots must be complete.
|
|
329
361
|
const entries = {};
|
|
362
|
+
const excludedKeys = [];
|
|
330
363
|
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
331
|
-
if (isUsableRecord(value))
|
|
364
|
+
if (isUsableRecord(value) && value.key === key)
|
|
332
365
|
entries[key] = value;
|
|
366
|
+
else if (parsed.consolidated)
|
|
367
|
+
return unavailableStore('malformed-consolidated-snapshot');
|
|
368
|
+
else
|
|
369
|
+
excludedKeys.push(key);
|
|
333
370
|
}
|
|
334
|
-
|
|
371
|
+
const store = {
|
|
372
|
+
consolidated: parsed.consolidated === true,
|
|
335
373
|
version: parsed.version ?? STORE_VERSION,
|
|
336
374
|
updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
|
|
337
375
|
entries,
|
|
@@ -340,30 +378,26 @@ function readUsageSnapshot(storePath = resolveUsageStorePath()) {
|
|
|
340
378
|
? parsed.offersEraStart
|
|
341
379
|
: deriveOffersEraStart(entries),
|
|
342
380
|
};
|
|
381
|
+
return excludedKeys.length ? withEvidence(store, {
|
|
382
|
+
status: 'partial', excludedKeys, observations: ['malformed-records'],
|
|
383
|
+
}) : store;
|
|
343
384
|
}
|
|
344
385
|
catch {
|
|
345
|
-
return
|
|
386
|
+
return unavailableStore('unreadable-snapshot');
|
|
346
387
|
}
|
|
347
388
|
}
|
|
348
389
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* rename over a target any process holds open failing EPERM 100 times in 100, on
|
|
353
|
-
* both a synced and a local path, because that is Windows file-sharing semantics
|
|
354
|
-
* rather than a OneDrive artefact. The usage store is read by concurrent MCP
|
|
355
|
-
* processes by design, so a rename-published snapshot would fail essentially
|
|
356
|
-
* always in production. This is safe precisely because the snapshot is derived: a
|
|
357
|
-
* torn one is rejected by `readUsageSnapshot` and rebuilt from the log.
|
|
358
|
-
*
|
|
359
|
-
* Returns whether it landed. A failure is never thrown: the log is authoritative
|
|
360
|
-
* and the next reader either uses the older snapshot with a longer tail, or folds
|
|
361
|
-
* cold.
|
|
390
|
+
* Refresh the normal snapshot in place (Windows readers can prevent rename).
|
|
391
|
+
* Sleep alone publishes the separate flushed checkpoint backup before clearing
|
|
392
|
+
* journals, so a torn ordinary refresh can recover without replaying old history.
|
|
362
393
|
*/
|
|
363
394
|
function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
|
|
395
|
+
if (store.evidence && store.evidence.status !== 'reliable')
|
|
396
|
+
return false;
|
|
364
397
|
try {
|
|
365
398
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
|
|
366
|
-
|
|
399
|
+
const { evidence: _evidence, ...snapshot } = store;
|
|
400
|
+
(0, fs_1.writeFileSync)(storePath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flush: true });
|
|
367
401
|
return true;
|
|
368
402
|
}
|
|
369
403
|
catch {
|
|
@@ -385,26 +419,39 @@ function foldUsageEvents(options) {
|
|
|
385
419
|
// A checkpoint past the end of its log means the log was compacted or replaced
|
|
386
420
|
// under this snapshot, so the snapshot may already count events the log no
|
|
387
421
|
// longer holds. Re-folding onto it would double count. Discard and fold cold.
|
|
422
|
+
if (tail.restarted && base.consolidated)
|
|
423
|
+
throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
|
|
388
424
|
if (tail.restarted) {
|
|
389
425
|
const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
|
|
390
|
-
return applyTail(emptyStore(),
|
|
426
|
+
return applyTail(withEvidence(emptyStore(), {
|
|
427
|
+
status: 'unavailable', excludedKeys: base.evidence?.excludedKeys ?? [],
|
|
428
|
+
observations: [...(base.evidence?.observations ?? []), 'journal-restarted'],
|
|
429
|
+
}), cold, limits, now, true);
|
|
391
430
|
}
|
|
392
431
|
return applyTail(base, tail, limits, now, false);
|
|
393
432
|
}
|
|
394
433
|
function applyTail(base, tail, limits, now, restarted) {
|
|
395
434
|
const store = {
|
|
435
|
+
consolidated: base.consolidated,
|
|
396
436
|
version: base.version,
|
|
397
437
|
updatedAt: base.updatedAt,
|
|
398
438
|
entries: base.entries,
|
|
399
439
|
foldedThroughBytes: { ...(base.foldedThroughBytes ?? {}) },
|
|
400
440
|
offersEraStart: base.offersEraStart ?? null,
|
|
401
441
|
};
|
|
442
|
+
if (base.evidence)
|
|
443
|
+
withEvidence(store, base.evidence);
|
|
402
444
|
let tailBytes = 0;
|
|
445
|
+
let unsupportedEvents = false;
|
|
403
446
|
const touched = new Set();
|
|
404
447
|
for (const read of tail.reads) {
|
|
405
448
|
const from = restarted ? 0 : (store.foldedThroughBytes[read.machineId] ?? 0);
|
|
406
449
|
tailBytes += Math.max(0, read.through - from);
|
|
407
450
|
for (const event of read.records) {
|
|
451
|
+
if (!isSupportedUsageEvent(event)) {
|
|
452
|
+
unsupportedEvents = true;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
408
455
|
const key = applyEvent(store, event, limits, now);
|
|
409
456
|
if (key)
|
|
410
457
|
touched.add(key);
|
|
@@ -419,28 +466,90 @@ function applyTail(base, tail, limits, now, restarted) {
|
|
|
419
466
|
// E: the invariant is evaluated on the read path, where it is always current
|
|
420
467
|
// and costs the write path nothing.
|
|
421
468
|
flagIntegrity(store);
|
|
469
|
+
const incompleteJournal = tail.skippedLines > 0
|
|
470
|
+
|| tail.reads.some(read => read.through < read.baseOffset + read.size - read.headerBytes);
|
|
471
|
+
if (incompleteJournal || unsupportedEvents) {
|
|
472
|
+
withEvidence(store, {
|
|
473
|
+
status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [],
|
|
474
|
+
observations: [...(store.evidence?.observations ?? []),
|
|
475
|
+
...(incompleteJournal ? ['incomplete-journal'] : []),
|
|
476
|
+
...(unsupportedEvents ? ['unsupported-journal-events'] : [])],
|
|
477
|
+
});
|
|
478
|
+
}
|
|
422
479
|
return { store, skippedLines: tail.skippedLines, restarted, tailBytes, logs: tail.reads };
|
|
423
480
|
}
|
|
424
481
|
/**
|
|
425
482
|
* The checkpointed read every consumer uses. Snapshot plus the bytes appended
|
|
426
483
|
* past it, republished once the tail is worth re-checkpointing.
|
|
427
484
|
*/
|
|
485
|
+
/** Metadata detects observed writes/sync during a read; it is not a writer lock. */
|
|
486
|
+
function usageReadState(storePath, eventDir) {
|
|
487
|
+
const dir = (0, path_1.dirname)(storePath);
|
|
488
|
+
const copies = (0, fs_1.existsSync)(dir) ? (0, fs_1.readdirSync)(dir)
|
|
489
|
+
.filter(name => /^learning-usage(-.+)?\.json$/.test(name) && name !== STORE_FILE_NAME).sort() : [];
|
|
490
|
+
const paths = [storePath, resolveUsageCheckpointPath(storePath),
|
|
491
|
+
...(0, learning_usage_events_1.listEventLogs)(eventDir).map(log => log.path), ...copies.map(name => (0, path_1.join)(dir, name))];
|
|
492
|
+
const fingerprint = JSON.stringify(paths.map(file => {
|
|
493
|
+
try {
|
|
494
|
+
const stat = (0, fs_1.statSync)(file);
|
|
495
|
+
return [file, stat.size, stat.mtimeMs, stat.ctimeMs, stat.ino];
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return [file, 'missing'];
|
|
499
|
+
}
|
|
500
|
+
}));
|
|
501
|
+
return { fingerprint, copies };
|
|
502
|
+
}
|
|
428
503
|
function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
|
|
429
|
-
const snapshot = readUsageSnapshot(storePath);
|
|
430
504
|
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
505
|
+
const before = usageReadState(storePath, eventDir);
|
|
506
|
+
const snapshot = readUsageSnapshot(storePath);
|
|
507
|
+
const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
|
|
508
|
+
const finish = (store) => {
|
|
509
|
+
const after = usageReadState(storePath, eventDir);
|
|
510
|
+
const observations = [...(store.evidence?.observations ?? [])];
|
|
511
|
+
if (before.fingerprint !== after.fingerprint)
|
|
512
|
+
observations.push('inputs-changed-during-read');
|
|
513
|
+
if (after.copies.length)
|
|
514
|
+
observations.push('unmerged-store-copies');
|
|
515
|
+
if (store.consolidated && Object.keys(store.foldedThroughBytes ?? {}).some(machineId => !logs.some(log => log.machineId === machineId)))
|
|
516
|
+
observations.push('consolidated-journal-missing');
|
|
517
|
+
if (observations.length > (store.evidence?.observations.length ?? 0))
|
|
518
|
+
withEvidence(store, {
|
|
519
|
+
status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [], observations,
|
|
520
|
+
});
|
|
521
|
+
flagIntegrity(store);
|
|
522
|
+
return store;
|
|
523
|
+
};
|
|
524
|
+
if (logs.length === 0 || snapshot.evidence?.observations.includes('unsupported-schema'))
|
|
525
|
+
return finish(snapshot);
|
|
526
|
+
const fold = (base) => foldUsageEvents({
|
|
434
527
|
eventDir,
|
|
435
528
|
limits: options.limits,
|
|
436
529
|
now: options.now,
|
|
437
|
-
base
|
|
438
|
-
checkpoints:
|
|
530
|
+
base,
|
|
531
|
+
checkpoints: base.foldedThroughBytes ?? {},
|
|
439
532
|
});
|
|
533
|
+
let folded;
|
|
534
|
+
try {
|
|
535
|
+
folded = fold(snapshot);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
|
|
539
|
+
if (!backup || backup.evidence)
|
|
540
|
+
throw error;
|
|
541
|
+
folded = fold(withEvidence(backup, {
|
|
542
|
+
status: 'reliable', excludedKeys: [], observations: ['primary-journal-mismatch'],
|
|
543
|
+
recovery: 'checkpoint', persisted: false,
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
finish(folded.store);
|
|
440
547
|
const mode = options.refreshSnapshot ?? 'auto';
|
|
441
548
|
const shouldRefresh = mode === 'always'
|
|
442
549
|
|| (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
|
|
443
|
-
|
|
550
|
+
// Recovery is usable in memory. Persisting a primary replacement requires the
|
|
551
|
+
// explicit maintenance interval; an ordinary reader never establishes it.
|
|
552
|
+
if (shouldRefresh && !folded.store.evidence) {
|
|
444
553
|
folded.store.updatedAt = (options.now ?? new Date()).toISOString();
|
|
445
554
|
writeUsageSnapshot(folded.store, storePath);
|
|
446
555
|
}
|
|
@@ -627,6 +736,20 @@ function applyEvent(store, event, limits, now) {
|
|
|
627
736
|
void now;
|
|
628
737
|
return null;
|
|
629
738
|
}
|
|
739
|
+
function isSupportedUsageEvent(event) {
|
|
740
|
+
if (typeof event.key !== 'string' || !event.key || typeof event.date !== 'string' || !isValidDate(event.date))
|
|
741
|
+
return false;
|
|
742
|
+
if (event.family !== 'learning' && event.family !== 'rule')
|
|
743
|
+
return false;
|
|
744
|
+
if (typeof event.job !== 'string' || (event.model !== null && typeof event.model !== 'string'))
|
|
745
|
+
return false;
|
|
746
|
+
if (event.kind === 'offer')
|
|
747
|
+
return typeof event.aboveThreshold === 'boolean';
|
|
748
|
+
if (event.kind === 'firing')
|
|
749
|
+
return exports.FIRING_OUTCOMES.includes(event.outcome)
|
|
750
|
+
&& (event.source === undefined || event.source === 'attested' || event.source === 'backfill-legacy');
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
630
753
|
function applyOfferEvent(store, event, common) {
|
|
631
754
|
const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
|
|
632
755
|
rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
|
|
@@ -747,7 +870,7 @@ function flagIntegrity(store) {
|
|
|
747
870
|
/**
|
|
748
871
|
* Materialize the snapshot when there is none.
|
|
749
872
|
*
|
|
750
|
-
*
|
|
873
|
+
* Before the first consolidation, readers publish snapshots, but a workspace that
|
|
751
874
|
* has only ever been written to would have no snapshot at all, so every reader
|
|
752
875
|
* would pay a cold fold and anything inspecting the store as a file would find
|
|
753
876
|
* nothing there. Bounded: it only runs when the file is absent, and at that point
|
|
@@ -761,7 +884,7 @@ function ensureSnapshotExists(storePath, eventDir, limits, now) {
|
|
|
761
884
|
readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
|
|
762
885
|
}
|
|
763
886
|
catch {
|
|
764
|
-
//
|
|
887
|
+
// Initial cache publication can retry; consolidated history has a separate backup.
|
|
765
888
|
}
|
|
766
889
|
}
|
|
767
890
|
/**
|
|
@@ -874,19 +997,17 @@ function noteTitleChange(key, newTitle, options) {
|
|
|
874
997
|
store.updatedAt = (options.now ?? new Date()).toISOString();
|
|
875
998
|
writeUsageSnapshot(store, storePath);
|
|
876
999
|
}
|
|
877
|
-
/**
|
|
878
|
-
* Apply retention and log bounds across the whole store, and compact the event
|
|
879
|
-
* log behind it.
|
|
880
|
-
*
|
|
881
|
-
* Compaction is a requirement rather than an optimization: S4 measured a cold
|
|
882
|
-
* full fold at 84 ms on today's volume but 794 ms at ten times it, and the
|
|
883
|
-
* checkpoint only makes a cold fold rare, not bounded.
|
|
884
|
-
*/
|
|
885
1000
|
function pruneUsageStore(limits, options = {}) {
|
|
886
1001
|
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
887
1002
|
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
888
1003
|
const now = options.now ?? new Date();
|
|
889
1004
|
const store = readUsageStore(storePath, { limits, now, eventDir });
|
|
1005
|
+
if (store.evidence && store.evidence.status !== 'reliable')
|
|
1006
|
+
return {
|
|
1007
|
+
maintenanceStatus: 'deferred', evidence: store.evidence,
|
|
1008
|
+
prunedOfferRecords: 0, prunedFiringRecords: 0, entries: Object.keys(store.entries).length,
|
|
1009
|
+
compaction: compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store }),
|
|
1010
|
+
};
|
|
890
1011
|
let prunedOfferRecords = 0;
|
|
891
1012
|
let prunedFiringRecords = 0;
|
|
892
1013
|
for (const record of Object.values(store.entries)) {
|
|
@@ -897,28 +1018,63 @@ function pruneUsageStore(limits, options = {}) {
|
|
|
897
1018
|
prunedFiringRecords += firingsBefore - record.fired.log.length;
|
|
898
1019
|
}
|
|
899
1020
|
const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
|
|
900
|
-
return {
|
|
1021
|
+
return {
|
|
1022
|
+
maintenanceStatus: compaction.maintenanceStatus, evidence: compaction.evidence,
|
|
1023
|
+
prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction,
|
|
1024
|
+
};
|
|
901
1025
|
}
|
|
902
|
-
/**
|
|
903
|
-
*
|
|
904
|
-
* snapshot's checkpoint onto the rewritten logs.
|
|
905
|
-
*
|
|
906
|
-
* Re-basing is not optional: leaving a checkpoint pointing into the old log would
|
|
907
|
-
* make the next reader either re-count events the snapshot already holds, or skip
|
|
908
|
-
* ones it does not.
|
|
1026
|
+
/** Save totals and source bookmarks in both copies, then clear processed journals.
|
|
1027
|
+
* Requires an interval without concurrent writers or sync activity.
|
|
909
1028
|
*/
|
|
910
1029
|
function compactUsageEvents(options) {
|
|
911
1030
|
const now = options.now ?? new Date();
|
|
912
1031
|
const store = options.store
|
|
913
1032
|
?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
|
|
914
|
-
|
|
1033
|
+
// Do not turn a partial read into a durable replacement or advance bookmarks
|
|
1034
|
+
// over evidence this reader could not understand. Independent review can run.
|
|
1035
|
+
if (store.evidence && store.evidence.status !== 'reliable')
|
|
1036
|
+
return {
|
|
1037
|
+
maintenanceStatus: 'deferred', evidence: store.evidence,
|
|
1038
|
+
scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [],
|
|
1039
|
+
checkpoints: { ...(store.foldedThroughBytes ?? {}) },
|
|
1040
|
+
};
|
|
1041
|
+
for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
|
|
1042
|
+
if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
|
|
1043
|
+
throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
for (const entry of Object.values(store.entries))
|
|
1047
|
+
trimLogs(entry, options.limits, now);
|
|
1048
|
+
store.consolidated = true;
|
|
1049
|
+
store.updatedAt = now.toISOString();
|
|
1050
|
+
// Publish the recoverable checkpoint first, then the normal snapshot. No
|
|
1051
|
+
// journal is cleared unless both writes succeed. The backup is never written
|
|
1052
|
+
// by ordinary readers, so a torn cache refresh cannot destroy both copies.
|
|
1053
|
+
const checkpoint = resolveUsageCheckpointPath(options.snapshotPath);
|
|
1054
|
+
const temp = checkpoint + '.tmp';
|
|
1055
|
+
try {
|
|
1056
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
|
|
1057
|
+
const { evidence: _evidence, ...snapshot } = store;
|
|
1058
|
+
(0, fs_1.writeFileSync)(temp, JSON.stringify(snapshot, null, 2) + '\n', { encoding: 'utf8', flush: true });
|
|
1059
|
+
(0, fs_1.renameSync)(temp, checkpoint);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
try {
|
|
1063
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
1064
|
+
}
|
|
1065
|
+
catch { /* Best effort. */ }
|
|
1066
|
+
throw new Error('Cannot consolidate usage events: checkpoint backup could not be saved.');
|
|
1067
|
+
}
|
|
1068
|
+
if (!writeUsageSnapshot(store, options.snapshotPath)) {
|
|
1069
|
+
throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
|
|
1070
|
+
}
|
|
1071
|
+
const compaction = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
|
|
1072
|
+
if (event.kind !== 'offer' && event.kind !== 'firing')
|
|
1073
|
+
return true;
|
|
915
1074
|
const date = typeof event.date === 'string' ? event.date : '';
|
|
916
|
-
if (!isValidDate(date))
|
|
1075
|
+
if (typeof event.key !== 'string' || !event.key || !isValidDate(date))
|
|
917
1076
|
return true;
|
|
918
|
-
return
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
store.updatedAt = now.toISOString();
|
|
922
|
-
writeUsageSnapshot(store, options.snapshotPath);
|
|
923
|
-
return result;
|
|
1077
|
+
return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
|
|
1078
|
+
}, store.foldedThroughBytes ?? {});
|
|
1079
|
+
return { ...compaction, maintenanceStatus: 'completed', evidence: store.evidence };
|
|
924
1080
|
}
|
|
@@ -16,37 +16,23 @@ exports.buildVerifyReport = buildVerifyReport;
|
|
|
16
16
|
* It reports and never blocks. Nothing here treats a finding as a gate: it states
|
|
17
17
|
* what it found, and the manager decides.
|
|
18
18
|
*
|
|
19
|
-
* ##
|
|
19
|
+
* ## Recovery boundary
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* delete `usage/` then `learning-usage backfill` -> 149 entries, 2874 firings,
|
|
30
|
-
* 0 split identities,
|
|
31
|
-
* 1 clean model bucket
|
|
32
|
-
*
|
|
33
|
-
* A rebuild lands with zero split identities and one model bucket for free,
|
|
34
|
-
* because the identity resolution and the model normalization fix the causes and
|
|
35
|
-
* a rebuilt store carries no legacy shape. The only thing an in-place
|
|
36
|
-
* reconciliation recovers beyond that is old firings whose retrospectives
|
|
37
|
-
* retention has already deleted, and a decay clock reads the most recent firing
|
|
38
|
-
* rather than the count. What a rebuild does cost is the offer history, which
|
|
39
|
-
* rebuilds itself from live traffic and degrades toward `undetermined` and
|
|
40
|
-
* hold-back in the meantime, which is this module's posture anyway.
|
|
41
|
-
*
|
|
42
|
-
* So `verify` names the rebuild when it finds a stale shape, and the destructive
|
|
43
|
-
* step stays the manager's.
|
|
21
|
+
* Rebuild only the snapshot, keeping the event directory intact. The retained
|
|
22
|
+
* events recover the offers and firings they cover; retrospective backfill can
|
|
23
|
+
* supplement older firings but cannot recreate offers. Normal compaction keeps
|
|
24
|
+
* lifetime aggregate totals in the snapshot, so a backup is required before
|
|
25
|
+
* discarding it if history already pruned from events must remain inspectable.
|
|
26
|
+
* Rebuilding cannot invent missing history or fix identities still present in
|
|
27
|
+
* the authoritative events. Verify reports those limitations rather than
|
|
28
|
+
* claiming that deleting the usage directory is a lossless repair.
|
|
44
29
|
*/
|
|
45
30
|
const fs_1 = require("fs");
|
|
46
31
|
const path_1 = require("path");
|
|
47
32
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
48
33
|
const learning_usage_events_1 = require("./learning-usage-events");
|
|
49
34
|
const learning_usage_projection_1 = require("./learning-usage-projection");
|
|
35
|
+
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
50
36
|
const CANONICAL_FILE_NAME = 'learning-usage.json';
|
|
51
37
|
/** `learning-usage.json` plus anything a sync client forked off it. */
|
|
52
38
|
const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
|
|
@@ -179,7 +165,7 @@ function scanRecordsForIntegrity(records, offersEraStart) {
|
|
|
179
165
|
// unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
|
|
180
166
|
// set correctly, and that is information rather than a gap.
|
|
181
167
|
const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
|
|
182
|
-
if (
|
|
168
|
+
if ((0, learning_usage_eligibility_1.modelAttributionIncomplete)(record)) {
|
|
183
169
|
unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
|
|
184
170
|
}
|
|
185
171
|
}
|
|
@@ -198,6 +184,7 @@ function buildVerifyReport(options) {
|
|
|
198
184
|
const checkpoints = snapshot.foldedThroughBytes ?? {};
|
|
199
185
|
const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
|
|
200
186
|
return {
|
|
187
|
+
evidence: store.evidence,
|
|
201
188
|
snapshotPath: options.snapshotPath,
|
|
202
189
|
eventDir,
|
|
203
190
|
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
|
|
@@ -212,7 +199,7 @@ function buildVerifyReport(options) {
|
|
|
212
199
|
// The one number that makes a stale read visible: a snapshot that stops
|
|
213
200
|
// advancing while the log grows is a failing snapshot write, which S3 showed
|
|
214
201
|
// will happen intermittently on a synced folder and is otherwise silent.
|
|
215
|
-
checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.size - (checkpoints[log.machineId] ?? 0))])),
|
|
202
|
+
checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.baseOffset + log.size - log.headerBytes - (checkpoints[log.machineId] ?? 0))])),
|
|
216
203
|
skippedLines: 0,
|
|
217
204
|
totalLogBytes,
|
|
218
205
|
compactionAdvisoryBytes: exports.COMPACTION_ADVISORY_BYTES,
|