fraim 2.0.338 → 2.0.340
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 +153 -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 +43 -16
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +145 -27
- package/dist/src/local-mcp-server/learning-usage-analysis.js +79 -2
- package/dist/src/local-mcp-server/learning-usage-attestation.js +9 -2
- package/dist/src/local-mcp-server/learning-usage-command.js +127 -22
- package/dist/src/local-mcp-server/learning-usage-events.js +267 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +626 -88
- package/dist/src/local-mcp-server/learning-usage-verify.js +207 -0
- package/dist/src/local-mcp-server/stdio-server.js +37 -3
- package/package.json +2 -1
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
|
|
3
|
+
exports.appendEvents = exports.listEventLogs = exports.resolveMachineId = exports.resolveEventDir = exports.AMBIENT_JOB_LABELS = exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
|
|
4
4
|
exports.resolveUsageStorePath = resolveUsageStorePath;
|
|
5
5
|
exports.resolveUsageLimits = resolveUsageLimits;
|
|
6
|
+
exports.defaultUsageLimits = defaultUsageLimits;
|
|
6
7
|
exports.normalizeEntryTitle = normalizeEntryTitle;
|
|
7
8
|
exports.titleUsageKey = titleUsageKey;
|
|
8
9
|
exports.ruleUsageKey = ruleUsageKey;
|
|
10
|
+
exports.resolveEntryIdentity = resolveEntryIdentity;
|
|
11
|
+
exports.normalizeModelName = normalizeModelName;
|
|
12
|
+
exports.readUsageSnapshot = readUsageSnapshot;
|
|
13
|
+
exports.resolveUsageCheckpointPath = resolveUsageCheckpointPath;
|
|
14
|
+
exports.writeUsageSnapshot = writeUsageSnapshot;
|
|
15
|
+
exports.foldUsageEvents = foldUsageEvents;
|
|
9
16
|
exports.readUsageStore = readUsageStore;
|
|
10
17
|
exports.sanitizeNote = sanitizeNote;
|
|
18
|
+
exports.integrityViolation = integrityViolation;
|
|
11
19
|
exports.recordOffers = recordOffers;
|
|
12
20
|
exports.recordFirings = recordFirings;
|
|
13
21
|
exports.noteTitleChange = noteTitleChange;
|
|
14
22
|
exports.pruneUsageStore = pruneUsageStore;
|
|
23
|
+
exports.compactUsageEvents = compactUsageEvents;
|
|
15
24
|
/**
|
|
16
25
|
* Issue #1103 — the usage record for learnings and rules.
|
|
17
26
|
*
|
|
@@ -38,15 +47,39 @@ exports.pruneUsageStore = pruneUsageStore;
|
|
|
38
47
|
* (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
|
|
39
48
|
* second guardrail here.
|
|
40
49
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
54
|
+
*
|
|
55
|
+
* Four smaller corrections ride with it, each removing a place where the record
|
|
56
|
+
* gave a confident answer it could not prove:
|
|
57
|
+
*
|
|
58
|
+
* A1 `resolveEntryIdentity` is the one definition of what an entry is. Four
|
|
59
|
+
* call sites each built `id ?? titleUsageKey(...)` independently, so one
|
|
60
|
+
* lesson could be two records.
|
|
61
|
+
* C2 Model names are lowercased at write. `GPT-5` and `gpt-5` were two buckets
|
|
62
|
+
* splitting one denominator (measured 6857 against 2652).
|
|
63
|
+
* D3 Job-labelled offers are reserved against eviction by session traffic.
|
|
64
|
+
* 2067 of 2354 retained offer records were `session` or `__freeform__`, so
|
|
65
|
+
* "was this in context for that job" had almost no resolution left.
|
|
66
|
+
* E A scoped integrity invariant. The literal `fired <= offered` the issue
|
|
67
|
+
* asks for is unsound, because `runBackfill` legitimately writes firings
|
|
68
|
+
* for 299 retrospectives that predate offer recording. Scoped to attested
|
|
69
|
+
* firings dated at or after the store's first recorded offer, it holds: the
|
|
70
|
+
* CLI throws on a violation, and the injection path only flags, because a
|
|
71
|
+
* usage failure must never change what the agent is given (R2).
|
|
44
72
|
*/
|
|
45
73
|
const fs_1 = require("fs");
|
|
46
74
|
const path_1 = require("path");
|
|
47
75
|
const pack_home_1 = require("../cli/utils/pack-home");
|
|
48
76
|
const artifact_retention_cleanup_1 = require("./artifact-retention-cleanup");
|
|
49
77
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
78
|
+
const learning_usage_events_1 = require("./learning-usage-events");
|
|
79
|
+
Object.defineProperty(exports, "appendEvents", { enumerable: true, get: function () { return learning_usage_events_1.appendEvents; } });
|
|
80
|
+
Object.defineProperty(exports, "listEventLogs", { enumerable: true, get: function () { return learning_usage_events_1.listEventLogs; } });
|
|
81
|
+
Object.defineProperty(exports, "resolveEventDir", { enumerable: true, get: function () { return learning_usage_events_1.resolveEventDir; } });
|
|
82
|
+
Object.defineProperty(exports, "resolveMachineId", { enumerable: true, get: function () { return learning_usage_events_1.resolveMachineId; } });
|
|
50
83
|
// ── Vocabulary ───────────────────────────────────────────────────────────────
|
|
51
84
|
/**
|
|
52
85
|
* The five outcomes (spec: Outcome vocabulary). `ignored` and `not-offered` are
|
|
@@ -69,10 +102,29 @@ const DEFAULT_FIRING_LOG_LIMIT = 20;
|
|
|
69
102
|
* needs per-job offer detail rather than a counter.
|
|
70
103
|
*/
|
|
71
104
|
const DEFAULT_OFFER_LOG_LIMIT = 50;
|
|
105
|
+
/**
|
|
106
|
+
* Issue #1752 D3. Of the offer log's slots, this many are held for records that
|
|
107
|
+
* carry a real job label, so `fraim_connect`'s per-session traffic cannot evict
|
|
108
|
+
* the only records that can answer R7.
|
|
109
|
+
*/
|
|
110
|
+
const DEFAULT_JOB_OFFER_RESERVE = 25;
|
|
72
111
|
/** Open Question 2: configurable and greater than a handful. */
|
|
73
112
|
const DEFAULT_RETIREMENT_OFFER_THRESHOLD = 10;
|
|
113
|
+
/**
|
|
114
|
+
* Job labels the runtime writes when no job is selected. An offer carrying one of
|
|
115
|
+
* these says the entry was in a session's context, not that it was in context for
|
|
116
|
+
* any particular job, so it is not what R7 resolves against.
|
|
117
|
+
*/
|
|
118
|
+
exports.AMBIENT_JOB_LABELS = ['session', '__freeform__', 'unknown', ''];
|
|
74
119
|
/** R16: one line, bounded, no fences. Long enough for a sentence, short enough to be useless as a transcript. */
|
|
75
120
|
const MAX_NOTE_LENGTH = 240;
|
|
121
|
+
/**
|
|
122
|
+
* Republish the snapshot once the unfolded tail passes this. Small enough that a
|
|
123
|
+
* cold fold stays rare, large enough that `fraim_connect` is not rewriting a
|
|
124
|
+
* 1 MB file on every call. At the measured ~250 bytes per event this is roughly
|
|
125
|
+
* two thousand events, which S4b folds in about 10 ms.
|
|
126
|
+
*/
|
|
127
|
+
const SNAPSHOT_REFRESH_BYTES = 512 * 1024;
|
|
76
128
|
// ── Paths and config ─────────────────────────────────────────────────────────
|
|
77
129
|
/**
|
|
78
130
|
* The store path, inside the manager layer so the existing git-backend guardrail
|
|
@@ -108,10 +160,25 @@ function resolveUsageLimits(workspaceRoot) {
|
|
|
108
160
|
return {
|
|
109
161
|
firingLogLimit: positiveInt(block.firingLogLimit, DEFAULT_FIRING_LOG_LIMIT),
|
|
110
162
|
offerLogLimit: positiveInt(block.offerLogLimit, DEFAULT_OFFER_LOG_LIMIT),
|
|
163
|
+
jobOfferReserve: positiveInt(block.jobOfferReserve, DEFAULT_JOB_OFFER_RESERVE),
|
|
111
164
|
retirementOfferThreshold: positiveInt(block.retirementOfferThreshold, DEFAULT_RETIREMENT_OFFER_THRESHOLD),
|
|
112
165
|
retentionDays: typeof retentionDays === 'number' ? retentionDays : -1,
|
|
113
166
|
};
|
|
114
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Bounds for a read that has no workspace to resolve them from. Retention is -1
|
|
170
|
+
* on purpose: a fold must not silently drop log records against a guessed
|
|
171
|
+
* retention window. Pruning is `pruneUsageStore`'s job and is explicit.
|
|
172
|
+
*/
|
|
173
|
+
function defaultUsageLimits() {
|
|
174
|
+
return {
|
|
175
|
+
firingLogLimit: DEFAULT_FIRING_LOG_LIMIT,
|
|
176
|
+
offerLogLimit: DEFAULT_OFFER_LOG_LIMIT,
|
|
177
|
+
jobOfferReserve: DEFAULT_JOB_OFFER_RESERVE,
|
|
178
|
+
retirementOfferThreshold: DEFAULT_RETIREMENT_OFFER_THRESHOLD,
|
|
179
|
+
retentionDays: -1,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
115
182
|
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
116
183
|
/** Normalize a title for keying. Case and whitespace only: never a similarity measure (D4). */
|
|
117
184
|
function normalizeEntryTitle(title) {
|
|
@@ -129,9 +196,39 @@ function titleUsageKey(fileType, title) {
|
|
|
129
196
|
function ruleUsageKey(displayPath) {
|
|
130
197
|
return `R:${displayPath.replace(/\\/g, '/')}`;
|
|
131
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Issue #1752 A1: the single definition of what an entry is.
|
|
201
|
+
*
|
|
202
|
+
* `enumerateFileEntries`, `buildEntryIndex`, `lookupUsage` and the `classify` CLI
|
|
203
|
+
* each built `id ?? titleUsageKey(...)` for themselves, which is four definitions
|
|
204
|
+
* that can drift. Defect 4 is what drift looks like: one lesson stamped in one
|
|
205
|
+
* tier and unstamped in another became two records, one collecting every offer
|
|
206
|
+
* and the other every firing, and `standing` then recommended promoting and
|
|
207
|
+
* demoting the same lesson in a single response.
|
|
208
|
+
*
|
|
209
|
+
* The id always wins. A title key is a fallback for a lesson no tier has stamped,
|
|
210
|
+
* never a second identity for one that has been.
|
|
211
|
+
*/
|
|
212
|
+
function resolveEntryIdentity(input) {
|
|
213
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
214
|
+
if (id)
|
|
215
|
+
return { key: id, source: 'id' };
|
|
216
|
+
return { key: titleUsageKey(input.fileType ?? 'unknown', input.title ?? ''), source: 'title' };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Issue #1752 C2: one model, one bucket.
|
|
220
|
+
*
|
|
221
|
+
* Measured in the live store: `GPT-5` 6857 against `gpt-5` 2652, and `gpt-6` 1764
|
|
222
|
+
* against `GPT-6` 140. Every per-model calculation, including retirement
|
|
223
|
+
* candidacy, was splitting its own denominator.
|
|
224
|
+
*/
|
|
225
|
+
function normalizeModelName(model) {
|
|
226
|
+
const normalized = String(model ?? '').trim().toLowerCase();
|
|
227
|
+
return normalized || null;
|
|
228
|
+
}
|
|
132
229
|
// ── Read and write ───────────────────────────────────────────────────────────
|
|
133
230
|
function emptyStore() {
|
|
134
|
-
return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {} };
|
|
231
|
+
return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {}, foldedThroughBytes: {}, offersEraStart: null };
|
|
135
232
|
}
|
|
136
233
|
/**
|
|
137
234
|
* Is this parsed value a record every consumer can read without crashing?
|
|
@@ -167,38 +264,196 @@ function isUsableRecord(value) {
|
|
|
167
264
|
&& Array.isArray(record.titles);
|
|
168
265
|
}
|
|
169
266
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
267
|
+
* The earliest offer date a pre-#1752 snapshot can evidence.
|
|
268
|
+
*
|
|
269
|
+
* Found by validating against a copy of the real store: without this, a store
|
|
270
|
+
* written before the event log existed has no `offersEraStart`, the scoped
|
|
271
|
+
* invariant has no era to scope to, and it reports zero violations on a corpus
|
|
272
|
+
* that has 62 entries holding more attested firings than recorded offers. Silent
|
|
273
|
+
* and wrong, which is the failure class this whole issue is about.
|
|
274
|
+
*
|
|
275
|
+
* Derived from the bounded offer logs, so it is at or later than the true first
|
|
276
|
+
* offer. Later means fewer firings counted as in-era, which under-reports rather
|
|
277
|
+
* than inventing a violation, and that is the right direction for a check whose
|
|
278
|
+
* CLI form refuses a write.
|
|
172
279
|
*/
|
|
173
|
-
function
|
|
280
|
+
function deriveOffersEraStart(entries) {
|
|
281
|
+
let earliest = null;
|
|
282
|
+
for (const record of Object.values(entries)) {
|
|
283
|
+
for (const offer of record.offered.log) {
|
|
284
|
+
if (!offer?.date)
|
|
285
|
+
continue;
|
|
286
|
+
if (earliest === null || offer.date < earliest)
|
|
287
|
+
earliest = offer.date;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return earliest;
|
|
291
|
+
}
|
|
292
|
+
function readCheckpoints(value) {
|
|
293
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
294
|
+
return {};
|
|
295
|
+
const out = {};
|
|
296
|
+
for (const [machineId, bytes] of Object.entries(value)) {
|
|
297
|
+
if (typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0)
|
|
298
|
+
out[machineId] = bytes;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
/** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
|
|
303
|
+
function readUsageSnapshot(storePath = resolveUsageStorePath()) {
|
|
304
|
+
for (const candidate of [storePath, resolveUsageCheckpointPath(storePath)]) {
|
|
305
|
+
const snapshot = parseUsageSnapshot(candidate);
|
|
306
|
+
if (snapshot)
|
|
307
|
+
return snapshot;
|
|
308
|
+
}
|
|
309
|
+
if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
|
|
310
|
+
throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
|
|
311
|
+
}
|
|
312
|
+
return emptyStore();
|
|
313
|
+
}
|
|
314
|
+
function resolveUsageCheckpointPath(storePath) {
|
|
315
|
+
return storePath + '.checkpoint';
|
|
316
|
+
}
|
|
317
|
+
function parseUsageSnapshot(storePath) {
|
|
174
318
|
try {
|
|
175
319
|
if (!(0, fs_1.existsSync)(storePath))
|
|
176
|
-
return
|
|
320
|
+
return null;
|
|
177
321
|
const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
|
|
178
322
|
// `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
|
|
179
323
|
// Object.values yields whatever the array held and every consumer that reads
|
|
180
324
|
// `entry.offered` throws on it.
|
|
181
325
|
if (!parsed || typeof parsed !== 'object' || !parsed.entries
|
|
182
326
|
|| typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
183
|
-
return
|
|
327
|
+
return null;
|
|
184
328
|
}
|
|
185
329
|
// Drop any value that is not a usable record rather than handing a caller
|
|
186
330
|
// something it will crash on. A partially corrupt store degrades to the records
|
|
187
|
-
// that are still readable
|
|
331
|
+
// that are still readable before consolidation. Consolidated snapshots must be complete.
|
|
188
332
|
const entries = {};
|
|
189
333
|
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
190
334
|
if (isUsableRecord(value))
|
|
191
335
|
entries[key] = value;
|
|
336
|
+
else if (parsed.consolidated)
|
|
337
|
+
return null;
|
|
192
338
|
}
|
|
193
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
consolidated: parsed.consolidated === true,
|
|
341
|
+
version: parsed.version ?? STORE_VERSION,
|
|
342
|
+
updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
|
|
343
|
+
entries,
|
|
344
|
+
foldedThroughBytes: readCheckpoints(parsed.foldedThroughBytes),
|
|
345
|
+
offersEraStart: typeof parsed.offersEraStart === 'string'
|
|
346
|
+
? parsed.offersEraStart
|
|
347
|
+
: deriveOffersEraStart(entries),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Refresh the normal snapshot in place (Windows readers can prevent rename).
|
|
356
|
+
* Sleep alone publishes the separate flushed checkpoint backup before clearing
|
|
357
|
+
* journals, so a torn ordinary refresh can recover without replaying old history.
|
|
358
|
+
*/
|
|
359
|
+
function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
|
|
360
|
+
try {
|
|
361
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
|
|
362
|
+
(0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', flush: true });
|
|
363
|
+
return true;
|
|
194
364
|
}
|
|
195
365
|
catch {
|
|
196
|
-
return
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Fold an event log into a base record set.
|
|
371
|
+
*
|
|
372
|
+
* `base` defaults to empty, which is a cold full fold. `readUsageStore` passes the
|
|
373
|
+
* snapshot and its checkpoints, which is the warm path S4b measured flat at
|
|
374
|
+
* 2.6 ms from 9.9 MB to 79 MB of log.
|
|
375
|
+
*/
|
|
376
|
+
function foldUsageEvents(options) {
|
|
377
|
+
const limits = options.limits ?? defaultUsageLimits();
|
|
378
|
+
const now = options.now ?? new Date();
|
|
379
|
+
const base = options.base ?? emptyStore();
|
|
380
|
+
const tail = (0, learning_usage_events_1.readEventsSince)(options.eventDir, options.checkpoints ?? {});
|
|
381
|
+
// A checkpoint past the end of its log means the log was compacted or replaced
|
|
382
|
+
// under this snapshot, so the snapshot may already count events the log no
|
|
383
|
+
// longer holds. Re-folding onto it would double count. Discard and fold cold.
|
|
384
|
+
if (tail.restarted && base.consolidated)
|
|
385
|
+
throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
|
|
386
|
+
if (tail.restarted) {
|
|
387
|
+
const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
|
|
388
|
+
return applyTail(emptyStore(), cold, limits, now, true);
|
|
389
|
+
}
|
|
390
|
+
return applyTail(base, tail, limits, now, false);
|
|
391
|
+
}
|
|
392
|
+
function applyTail(base, tail, limits, now, restarted) {
|
|
393
|
+
const store = {
|
|
394
|
+
consolidated: base.consolidated,
|
|
395
|
+
version: base.version,
|
|
396
|
+
updatedAt: base.updatedAt,
|
|
397
|
+
entries: base.entries,
|
|
398
|
+
foldedThroughBytes: { ...(base.foldedThroughBytes ?? {}) },
|
|
399
|
+
offersEraStart: base.offersEraStart ?? null,
|
|
400
|
+
};
|
|
401
|
+
let tailBytes = 0;
|
|
402
|
+
const touched = new Set();
|
|
403
|
+
for (const read of tail.reads) {
|
|
404
|
+
const from = restarted ? 0 : (store.foldedThroughBytes[read.machineId] ?? 0);
|
|
405
|
+
tailBytes += Math.max(0, read.through - from);
|
|
406
|
+
for (const event of read.records) {
|
|
407
|
+
const key = applyEvent(store, event, limits, now);
|
|
408
|
+
if (key)
|
|
409
|
+
touched.add(key);
|
|
410
|
+
}
|
|
411
|
+
store.foldedThroughBytes[read.machineId] = read.through;
|
|
412
|
+
}
|
|
413
|
+
for (const key of touched) {
|
|
414
|
+
const record = store.entries[key];
|
|
415
|
+
if (record)
|
|
416
|
+
trimLogs(record, limits, now);
|
|
197
417
|
}
|
|
418
|
+
// E: the invariant is evaluated on the read path, where it is always current
|
|
419
|
+
// and costs the write path nothing.
|
|
420
|
+
flagIntegrity(store);
|
|
421
|
+
return { store, skippedLines: tail.skippedLines, restarted, tailBytes, logs: tail.reads };
|
|
198
422
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
423
|
+
/**
|
|
424
|
+
* The checkpointed read every consumer uses. Snapshot plus the bytes appended
|
|
425
|
+
* past it, republished once the tail is worth re-checkpointing.
|
|
426
|
+
*/
|
|
427
|
+
function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
|
|
428
|
+
const snapshot = readUsageSnapshot(storePath);
|
|
429
|
+
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
430
|
+
if ((0, learning_usage_events_1.listEventLogs)(eventDir).length === 0)
|
|
431
|
+
return snapshot;
|
|
432
|
+
const fold = (base) => foldUsageEvents({
|
|
433
|
+
eventDir,
|
|
434
|
+
limits: options.limits,
|
|
435
|
+
now: options.now,
|
|
436
|
+
base,
|
|
437
|
+
checkpoints: base.foldedThroughBytes ?? {},
|
|
438
|
+
});
|
|
439
|
+
let folded;
|
|
440
|
+
try {
|
|
441
|
+
folded = fold(snapshot);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
|
|
445
|
+
if (!backup)
|
|
446
|
+
throw error;
|
|
447
|
+
folded = fold(backup);
|
|
448
|
+
}
|
|
449
|
+
const mode = options.refreshSnapshot ?? 'auto';
|
|
450
|
+
const shouldRefresh = mode === 'always'
|
|
451
|
+
|| (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
|
|
452
|
+
if (shouldRefresh) {
|
|
453
|
+
folded.store.updatedAt = (options.now ?? new Date()).toISOString();
|
|
454
|
+
writeUsageSnapshot(folded.store, storePath);
|
|
455
|
+
}
|
|
456
|
+
return folded.store;
|
|
202
457
|
}
|
|
203
458
|
// ── Mutation helpers ─────────────────────────────────────────────────────────
|
|
204
459
|
function emptyOutcomeCounts() {
|
|
@@ -277,67 +532,288 @@ function withinRetention(date, retentionDays, now) {
|
|
|
277
532
|
return true;
|
|
278
533
|
return (now.getTime() - parsed) / 86_400_000 <= retentionDays;
|
|
279
534
|
}
|
|
535
|
+
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
|
|
536
|
+
function isAmbientJob(job) {
|
|
537
|
+
return exports.AMBIENT_JOB_LABELS.includes(job ?? '');
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Issue #1752 D3: bound the offer log without letting session traffic evict the
|
|
541
|
+
* only records that can answer R7.
|
|
542
|
+
*
|
|
543
|
+
* Measured cause: `fraim_connect` records one offer per entry per session under
|
|
544
|
+
* the label `session`, and a plain newest-50 rule then evicted every
|
|
545
|
+
* job-labelled record within about a day. 2067 of the 2354 retained offer records
|
|
546
|
+
* in the live store carried `session` or `__freeform__`, and only three real job
|
|
547
|
+
* names appeared at all, which is why `classify --job <real-job>` could almost
|
|
548
|
+
* never find a match.
|
|
549
|
+
*
|
|
550
|
+
* Job-labelled records get the first `jobOfferReserve` slots; the rest of the
|
|
551
|
+
* budget goes to the newest of everything else.
|
|
552
|
+
*/
|
|
553
|
+
function selectRetainedOffers(log, limits) {
|
|
554
|
+
if (log.length <= limits.offerLogLimit)
|
|
555
|
+
return log;
|
|
556
|
+
const jobLabelled = log.filter((r) => !isAmbientJob(r.job));
|
|
557
|
+
const ambient = log.filter((r) => isAmbientJob(r.job));
|
|
558
|
+
const reserve = Math.min(limits.jobOfferReserve, limits.offerLogLimit);
|
|
559
|
+
const guaranteed = jobLabelled.slice(-Math.min(jobLabelled.length, reserve));
|
|
560
|
+
const remaining = limits.offerLogLimit - guaranteed.length;
|
|
561
|
+
const pool = [...jobLabelled.slice(0, jobLabelled.length - guaranteed.length), ...ambient].sort(byDate);
|
|
562
|
+
const filler = remaining > 0 ? pool.slice(-remaining) : [];
|
|
563
|
+
return [...guaranteed, ...filler].sort(byDate).slice(-limits.offerLogLimit);
|
|
564
|
+
}
|
|
280
565
|
/**
|
|
281
566
|
* Trim one entry's logs: drop records outside the retention window, then keep the
|
|
282
567
|
* newest N. Aggregates are never touched, so R16a's second criterion holds.
|
|
283
568
|
*/
|
|
284
569
|
function trimLogs(record, limits, now) {
|
|
285
|
-
|
|
286
|
-
record.offered.log = record.offered.log
|
|
287
|
-
.filter((r) => withinRetention(r.date, limits.retentionDays, now))
|
|
288
|
-
.sort(byDate)
|
|
289
|
-
.slice(-limits.offerLogLimit);
|
|
570
|
+
record.offered.log = selectRetainedOffers(record.offered.log.filter((r) => withinRetention(r.date, limits.retentionDays, now)).sort(byDate), limits);
|
|
290
571
|
record.fired.log = record.fired.log
|
|
291
572
|
.filter((r) => withinRetention(r.date, limits.retentionDays, now))
|
|
292
573
|
.sort(byDate)
|
|
293
574
|
.slice(-limits.firingLogLimit);
|
|
294
575
|
}
|
|
576
|
+
function offerEvent(offer) {
|
|
577
|
+
return {
|
|
578
|
+
kind: 'offer',
|
|
579
|
+
key: offer.key,
|
|
580
|
+
family: offer.family,
|
|
581
|
+
title: offer.title,
|
|
582
|
+
fileType: offer.fileType,
|
|
583
|
+
level: offer.level,
|
|
584
|
+
aboveThreshold: Boolean(offer.aboveThreshold),
|
|
585
|
+
job: offer.job,
|
|
586
|
+
date: offer.date,
|
|
587
|
+
agent: offer.agent ?? null,
|
|
588
|
+
model: normalizeModelName(offer.model),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function firingEvent(firing) {
|
|
592
|
+
return {
|
|
593
|
+
kind: 'firing',
|
|
594
|
+
key: firing.key,
|
|
595
|
+
family: firing.family,
|
|
596
|
+
title: firing.title,
|
|
597
|
+
fileType: firing.fileType,
|
|
598
|
+
level: firing.level,
|
|
599
|
+
outcome: firing.outcome,
|
|
600
|
+
note: sanitizeNote(firing.note),
|
|
601
|
+
job: firing.job,
|
|
602
|
+
date: firing.date,
|
|
603
|
+
agent: firing.agent ?? null,
|
|
604
|
+
model: normalizeModelName(firing.model),
|
|
605
|
+
source: firing.source ?? 'attested',
|
|
606
|
+
sourceRef: firing.sourceRef,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Apply one logged event to the record set. Returns the key it touched, or null
|
|
611
|
+
* when the event is unusable or already counted.
|
|
612
|
+
*
|
|
613
|
+
* Firing idempotency lives here rather than on the write path, so it holds
|
|
614
|
+
* however many times an event was appended: `(key, sourceRef)` is the complete
|
|
615
|
+
* identity of an attestation, which is the same fact the migration's exact firing
|
|
616
|
+
* recovery rests on.
|
|
617
|
+
*/
|
|
618
|
+
function applyEvent(store, event, limits, now) {
|
|
619
|
+
const kind = event.kind;
|
|
620
|
+
const key = typeof event.key === 'string' ? event.key : '';
|
|
621
|
+
const date = typeof event.date === 'string' ? event.date : '';
|
|
622
|
+
if (!key || !isValidDate(date))
|
|
623
|
+
return null;
|
|
624
|
+
const family = (event.family === 'rule' ? 'rule' : 'learning');
|
|
625
|
+
const fileType = typeof event.fileType === 'string' ? event.fileType : undefined;
|
|
626
|
+
const level = typeof event.level === 'string' ? event.level : undefined;
|
|
627
|
+
const model = normalizeModelName(event.model);
|
|
628
|
+
const job = typeof event.job === 'string' ? event.job : 'unknown';
|
|
629
|
+
if (kind === 'offer')
|
|
630
|
+
return applyOfferEvent(store, event, { key, date, family, fileType, level, model, job });
|
|
631
|
+
if (kind === 'firing')
|
|
632
|
+
return applyFiringEvent(store, event, { key, date, family, fileType, level, model, job });
|
|
633
|
+
// An event kind this version does not know is skipped, so a log written by a
|
|
634
|
+
// newer version degrades to what this one understands rather than failing.
|
|
635
|
+
void limits;
|
|
636
|
+
void now;
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
function applyOfferEvent(store, event, common) {
|
|
640
|
+
const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
|
|
641
|
+
rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
|
|
642
|
+
record.offered.total += 1;
|
|
643
|
+
if (event.aboveThreshold)
|
|
644
|
+
record.offered.aboveThreshold += 1;
|
|
645
|
+
if (common.model)
|
|
646
|
+
record.offered.byModel[common.model] = (record.offered.byModel[common.model] ?? 0) + 1;
|
|
647
|
+
record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, common.date);
|
|
648
|
+
record.offered.log.push({
|
|
649
|
+
date: common.date,
|
|
650
|
+
job: common.job,
|
|
651
|
+
agent: event.agent ?? null,
|
|
652
|
+
model: common.model,
|
|
653
|
+
aboveThreshold: Boolean(event.aboveThreshold),
|
|
654
|
+
});
|
|
655
|
+
// The era only ever moves earlier, so the scoped invariant's boundary is
|
|
656
|
+
// monotonic and a late-syncing log cannot shrink the window it applies to.
|
|
657
|
+
store.offersEraStart = minDate(store.offersEraStart, common.date);
|
|
658
|
+
return common.key;
|
|
659
|
+
}
|
|
660
|
+
function applyFiringEvent(store, event, common) {
|
|
661
|
+
const outcome = event.outcome;
|
|
662
|
+
if (!exports.FIRING_OUTCOMES.includes(outcome))
|
|
663
|
+
return null;
|
|
664
|
+
const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
|
|
665
|
+
// Idempotency lives here rather than on the write path, so it holds however
|
|
666
|
+
// many times an event was appended. `(key, sourceRef)` is the complete identity
|
|
667
|
+
// of an attestation, which is the same fact the migration's exact firing
|
|
668
|
+
// recovery rests on.
|
|
669
|
+
const sourceRef = typeof event.sourceRef === 'string' ? event.sourceRef : '';
|
|
670
|
+
if (sourceRef && record.fired.seenSources.includes(sourceRef))
|
|
671
|
+
return null;
|
|
672
|
+
rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
|
|
673
|
+
record.fired.total += 1;
|
|
674
|
+
record.fired.byOutcome[outcome] = (record.fired.byOutcome[outcome] ?? 0) + 1;
|
|
675
|
+
record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, common.date);
|
|
676
|
+
if (exports.CLOCK_ADVANCING_OUTCOMES.includes(outcome)) {
|
|
677
|
+
record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, common.date);
|
|
678
|
+
record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, common.date);
|
|
679
|
+
if (common.model)
|
|
680
|
+
record.fired.byModel[common.model] = (record.fired.byModel[common.model] ?? 0) + 1;
|
|
681
|
+
}
|
|
682
|
+
record.fired.log.push({
|
|
683
|
+
date: common.date,
|
|
684
|
+
outcome,
|
|
685
|
+
job: common.job,
|
|
686
|
+
agent: event.agent ?? null,
|
|
687
|
+
model: common.model,
|
|
688
|
+
note: sanitizeNote(String(event.note ?? '')),
|
|
689
|
+
source: event.source === 'backfill-legacy' ? 'backfill-legacy' : 'attested',
|
|
690
|
+
});
|
|
691
|
+
if (sourceRef)
|
|
692
|
+
record.fired.seenSources.push(sourceRef);
|
|
693
|
+
return common.key;
|
|
694
|
+
}
|
|
695
|
+
// ── The scoped integrity invariant (issue #1752 E) ──────────────────────────
|
|
696
|
+
/**
|
|
697
|
+
* How many of this entry's attested firings fall inside the offers era.
|
|
698
|
+
*
|
|
699
|
+
* The invariant the issue asks for, `fired <= offered`, is unsound as literally
|
|
700
|
+
* stated: `runBackfill` writes firings attested by 299 retrospectives that
|
|
701
|
+
* predate offer recording entirely, and those have no offer by construction.
|
|
702
|
+
* Scoped to attested firings dated at or after the store's first recorded offer,
|
|
703
|
+
* it holds and means something.
|
|
704
|
+
*
|
|
705
|
+
* Two bases, because the check has two jobs with opposite failure budgets.
|
|
706
|
+
*
|
|
707
|
+
* 'log' Counts only the firings the bounded log still holds. It cannot
|
|
708
|
+
* over-count, so it can never refuse a correct write. This is what
|
|
709
|
+
* the CLI throws on.
|
|
710
|
+
* 'aggregate' Starts from `fired.total` and subtracts the firings the log
|
|
711
|
+
* shows are exempt. It sees an entry whose detail has aged out,
|
|
712
|
+
* which is the shape all 58 violating entries in the live store
|
|
713
|
+
* have, and it can over-count when the log no longer shows the
|
|
714
|
+
* exemptions. That is acceptable for a report and not for a
|
|
715
|
+
* refusal, so this is what the read path flags with and `verify`
|
|
716
|
+
* lists.
|
|
717
|
+
*/
|
|
718
|
+
function attestedFiringsInOffersEra(record, offersEraStart, basis) {
|
|
719
|
+
if (!offersEraStart)
|
|
720
|
+
return 0;
|
|
721
|
+
const inLog = record.fired.log.filter((r) => r.source === 'attested' && r.date >= offersEraStart).length;
|
|
722
|
+
if (basis === 'log')
|
|
723
|
+
return inLog;
|
|
724
|
+
const exemptInLog = record.fired.log.filter((r) => r.source === 'backfill-legacy' || r.date < offersEraStart).length;
|
|
725
|
+
return Math.max(inLog, record.fired.total - exemptInLog);
|
|
726
|
+
}
|
|
727
|
+
function integrityViolation(record, offersEraStart, basis = 'aggregate') {
|
|
728
|
+
// An entry with no offer record at all is not an accounting overflow; it is the
|
|
729
|
+
// question defect 2 is about, and it has several innocent causes: the offers
|
|
730
|
+
// were recorded under the entry's other key before #1752 A2 merged them, the
|
|
731
|
+
// entry is a rule or a demoted lesson the offer path does not enumerate, or the
|
|
732
|
+
// offers are sitting in another machine's event log that has not synced yet.
|
|
733
|
+
// Throwing on any of those would block a correct attestation on a sync
|
|
734
|
+
// artefact. The overflow this invariant exists to catch is the one the issue
|
|
735
|
+
// measured: entries showing `offered: 1, fired: 10`, which all have an offer.
|
|
736
|
+
if (record.offered.total === 0)
|
|
737
|
+
return null;
|
|
738
|
+
const inEra = attestedFiringsInOffersEra(record, offersEraStart, basis);
|
|
739
|
+
if (inEra <= record.offered.total)
|
|
740
|
+
return null;
|
|
741
|
+
return {
|
|
742
|
+
flag: 'fired-exceeds-offered',
|
|
743
|
+
count: inEra - record.offered.total,
|
|
744
|
+
detail: `${inEra} attested firings dated on or after ${offersEraStart} against ${record.offered.total} recorded offers. An entry cannot fire without being offered, so one of the two counters is wrong for this entry.`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function flagIntegrity(store) {
|
|
748
|
+
for (const record of Object.values(store.entries)) {
|
|
749
|
+
const violation = integrityViolation(record, store.offersEraStart);
|
|
750
|
+
if (violation)
|
|
751
|
+
record.integrity = violation;
|
|
752
|
+
else if (record.integrity)
|
|
753
|
+
delete record.integrity;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Materialize the snapshot when there is none.
|
|
758
|
+
*
|
|
759
|
+
* Before the first consolidation, readers publish snapshots, but a workspace that
|
|
760
|
+
* has only ever been written to would have no snapshot at all, so every reader
|
|
761
|
+
* would pay a cold fold and anything inspecting the store as a file would find
|
|
762
|
+
* nothing there. Bounded: it only runs when the file is absent, and at that point
|
|
763
|
+
* the fold is over a log that has just been created. A failure is swallowed, like
|
|
764
|
+
* every other snapshot write.
|
|
765
|
+
*/
|
|
766
|
+
function ensureSnapshotExists(storePath, eventDir, limits, now) {
|
|
767
|
+
try {
|
|
768
|
+
if ((0, fs_1.existsSync)(storePath))
|
|
769
|
+
return;
|
|
770
|
+
readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Initial cache publication can retry; consolidated history has a separate backup.
|
|
774
|
+
}
|
|
775
|
+
}
|
|
295
776
|
/**
|
|
296
777
|
* R1 and R2: write one offer record per listed entry, without any agent
|
|
297
778
|
* involvement and without depending on the agent reading the file.
|
|
779
|
+
*
|
|
780
|
+
* Issue #1752 B: this appends and does not read. The read-modify-write it
|
|
781
|
+
* replaces lost 1589 of 1600 writes under eight concurrent processes on a plain
|
|
782
|
+
* local disk (spike S1b), which is the dominant loss path behind both the
|
|
783
|
+
* impossible counters and the four conflict copies. Nothing here can throw on an
|
|
784
|
+
* integrity violation either: R2 requires that a usage failure never changes what
|
|
785
|
+
* the agent is given, so the violation is flagged by the read path instead.
|
|
298
786
|
*/
|
|
299
787
|
function recordOffers(offers, options) {
|
|
300
788
|
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
rememberTitle(record, offer.title);
|
|
309
|
-
record.offered.total += 1;
|
|
310
|
-
if (offer.aboveThreshold)
|
|
311
|
-
record.offered.aboveThreshold += 1;
|
|
312
|
-
if (offer.model)
|
|
313
|
-
record.offered.byModel[offer.model] = (record.offered.byModel[offer.model] ?? 0) + 1;
|
|
314
|
-
record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, offer.date);
|
|
315
|
-
record.offered.log.push({
|
|
316
|
-
date: offer.date,
|
|
317
|
-
job: offer.job,
|
|
318
|
-
agent: offer.agent ?? null,
|
|
319
|
-
model: offer.model ?? null,
|
|
320
|
-
aboveThreshold: Boolean(offer.aboveThreshold),
|
|
321
|
-
});
|
|
322
|
-
trimLogs(record, options.limits, now);
|
|
323
|
-
recorded += 1;
|
|
324
|
-
}
|
|
325
|
-
if (recorded > 0) {
|
|
326
|
-
store.updatedAt = now.toISOString();
|
|
327
|
-
writeUsageStore(store, storePath);
|
|
328
|
-
}
|
|
329
|
-
return { recorded, entries: Object.keys(store.entries).length };
|
|
789
|
+
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
790
|
+
const usable = offers.filter((offer) => offer.key && isValidDate(offer.date));
|
|
791
|
+
if (usable.length === 0)
|
|
792
|
+
return { recorded: 0, entries: 0 };
|
|
793
|
+
(0, learning_usage_events_1.appendEvents)(eventDir, usable.map(offerEvent), options.machineId);
|
|
794
|
+
ensureSnapshotExists(storePath, eventDir, options.limits, options.now ?? new Date());
|
|
795
|
+
return { recorded: usable.length, entries: new Set(usable.map((o) => o.key)).size };
|
|
330
796
|
}
|
|
331
797
|
/**
|
|
332
798
|
* R4 and R6: write the attested firings. Idempotent per (entry, attestation
|
|
333
799
|
* source) so a re-run, or a backfill that overlaps a live attestation, cannot
|
|
334
800
|
* inflate a count.
|
|
801
|
+
*
|
|
802
|
+
* Unlike the offer path this reads first, because it has two questions only the
|
|
803
|
+
* current record can answer: whether this attestation has already contributed,
|
|
804
|
+
* and whether the scoped invariant is about to be violated. It still writes by
|
|
805
|
+
* appending, and the fold applies the same `(key, sourceRef)` idempotency, so a
|
|
806
|
+
* racing duplicate append is folded once rather than counted twice.
|
|
807
|
+
*
|
|
808
|
+
* The invariant throws here. This is a CLI path with an operator watching, and a
|
|
809
|
+
* violation means a real accounting bug rather than a cosmetic one.
|
|
335
810
|
*/
|
|
336
811
|
function recordFirings(firings, options) {
|
|
337
812
|
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
813
|
+
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
338
814
|
const now = options.now ?? new Date();
|
|
339
|
-
const store = readUsageStore(storePath);
|
|
340
|
-
|
|
815
|
+
const store = readUsageStore(storePath, { limits: options.limits, now, eventDir });
|
|
816
|
+
const events = [];
|
|
341
817
|
let alreadyRecorded = 0;
|
|
342
818
|
for (const firing of firings) {
|
|
343
819
|
if (!exports.FIRING_OUTCOMES.includes(firing.outcome)) {
|
|
@@ -345,40 +821,51 @@ function recordFirings(firings, options) {
|
|
|
345
821
|
}
|
|
346
822
|
if (!firing.key || !isValidDate(firing.date))
|
|
347
823
|
continue;
|
|
348
|
-
const
|
|
349
|
-
if (firing.sourceRef &&
|
|
824
|
+
const existing = store.entries[firing.key];
|
|
825
|
+
if (firing.sourceRef && existing?.fired.seenSources.includes(firing.sourceRef)) {
|
|
350
826
|
alreadyRecorded += 1;
|
|
351
827
|
continue;
|
|
352
828
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, firing.date);
|
|
359
|
-
record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, firing.date);
|
|
360
|
-
if (firing.model)
|
|
361
|
-
record.fired.byModel[firing.model] = (record.fired.byModel[firing.model] ?? 0) + 1;
|
|
829
|
+
events.push(firingEvent(firing));
|
|
830
|
+
}
|
|
831
|
+
if (events.length > 0) {
|
|
832
|
+
if ((options.integrityMode ?? 'throw') === 'throw') {
|
|
833
|
+
assertScopedIntegrity(store, events, options.limits, now);
|
|
362
834
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
source: firing.source ?? 'attested',
|
|
371
|
-
});
|
|
372
|
-
if (firing.sourceRef)
|
|
373
|
-
record.fired.seenSources.push(firing.sourceRef);
|
|
374
|
-
trimLogs(record, options.limits, now);
|
|
375
|
-
recorded += 1;
|
|
835
|
+
(0, learning_usage_events_1.appendEvents)(eventDir, events, options.machineId);
|
|
836
|
+
// Unlike the offer path, this republishes the snapshot. `record-firings` runs
|
|
837
|
+
// from the CLI once per retrospective, so the fold is cheap, and it makes the
|
|
838
|
+
// store file current the moment an operator's write returns rather than at
|
|
839
|
+
// whatever point a later reader happens to refresh it. The offer path stays
|
|
840
|
+
// append-only, because that is the one that runs on every `fraim_connect`.
|
|
841
|
+
readUsageStore(storePath, { limits: options.limits, now, eventDir, refreshSnapshot: 'always' });
|
|
376
842
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
843
|
+
return { recorded: events.length, alreadyRecorded };
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* E, CLI form: refuse to write a batch that would put an entry into the
|
|
847
|
+
* impossible state the issue reports on 58 of its 223 entries.
|
|
848
|
+
*/
|
|
849
|
+
function assertScopedIntegrity(store, events, limits, now) {
|
|
850
|
+
const projected = {
|
|
851
|
+
version: store.version,
|
|
852
|
+
updatedAt: store.updatedAt,
|
|
853
|
+
entries: JSON.parse(JSON.stringify(store.entries)),
|
|
854
|
+
foldedThroughBytes: store.foldedThroughBytes,
|
|
855
|
+
offersEraStart: store.offersEraStart,
|
|
856
|
+
};
|
|
857
|
+
for (const event of events)
|
|
858
|
+
applyEvent(projected, event, limits, now);
|
|
859
|
+
for (const event of events) {
|
|
860
|
+
const record = projected.entries[event.key];
|
|
861
|
+
if (!record)
|
|
862
|
+
continue;
|
|
863
|
+
// 'log' basis: a refusal must never rest on a number that can over-count.
|
|
864
|
+
const violation = integrityViolation(record, projected.offersEraStart, 'log');
|
|
865
|
+
if (violation) {
|
|
866
|
+
throw new Error(`Refusing to record firings for ${event.key}: ${violation.detail} Run \`learning-usage verify\` for the full integrity picture before forcing this through.`);
|
|
867
|
+
}
|
|
380
868
|
}
|
|
381
|
-
return { recorded, alreadyRecorded };
|
|
382
869
|
}
|
|
383
870
|
/**
|
|
384
871
|
* R5: record that an entry's title changed, so a firing written before the edit
|
|
@@ -387,19 +874,28 @@ function recordFirings(firings, options) {
|
|
|
387
874
|
*/
|
|
388
875
|
function noteTitleChange(key, newTitle, options) {
|
|
389
876
|
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
390
|
-
const
|
|
877
|
+
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
878
|
+
const store = readUsageStore(storePath, { limits: options.limits, now: options.now, eventDir });
|
|
391
879
|
const record = store.entries[key];
|
|
392
880
|
if (!record)
|
|
393
881
|
return;
|
|
394
882
|
rememberTitle(record, newTitle);
|
|
395
883
|
store.updatedAt = (options.now ?? new Date()).toISOString();
|
|
396
|
-
|
|
884
|
+
writeUsageSnapshot(store, storePath);
|
|
397
885
|
}
|
|
398
|
-
/**
|
|
886
|
+
/**
|
|
887
|
+
* Apply retention and log bounds across the whole store, and compact the event
|
|
888
|
+
* log behind it.
|
|
889
|
+
*
|
|
890
|
+
* Compaction is a requirement rather than an optimization: S4 measured a cold
|
|
891
|
+
* full fold at 84 ms on today's volume but 794 ms at ten times it, and the
|
|
892
|
+
* checkpoint only makes a cold fold rare, not bounded.
|
|
893
|
+
*/
|
|
399
894
|
function pruneUsageStore(limits, options = {}) {
|
|
400
895
|
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
896
|
+
const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
|
|
401
897
|
const now = options.now ?? new Date();
|
|
402
|
-
const store = readUsageStore(storePath);
|
|
898
|
+
const store = readUsageStore(storePath, { limits, now, eventDir });
|
|
403
899
|
let prunedOfferRecords = 0;
|
|
404
900
|
let prunedFiringRecords = 0;
|
|
405
901
|
for (const record of Object.values(store.entries)) {
|
|
@@ -409,9 +905,51 @@ function pruneUsageStore(limits, options = {}) {
|
|
|
409
905
|
prunedOfferRecords += offersBefore - record.offered.log.length;
|
|
410
906
|
prunedFiringRecords += firingsBefore - record.fired.log.length;
|
|
411
907
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
908
|
+
const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
|
|
909
|
+
return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
|
|
910
|
+
}
|
|
911
|
+
/** Save totals and source bookmarks in both copies, then clear processed journals.
|
|
912
|
+
* Requires an interval without concurrent writers or sync activity.
|
|
913
|
+
*/
|
|
914
|
+
function compactUsageEvents(options) {
|
|
915
|
+
const now = options.now ?? new Date();
|
|
916
|
+
const store = options.store
|
|
917
|
+
?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
|
|
918
|
+
for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
|
|
919
|
+
if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
|
|
920
|
+
throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const entry of Object.values(store.entries))
|
|
924
|
+
trimLogs(entry, options.limits, now);
|
|
925
|
+
store.consolidated = true;
|
|
926
|
+
store.updatedAt = now.toISOString();
|
|
927
|
+
// Publish the recoverable checkpoint first, then the normal snapshot. No
|
|
928
|
+
// journal is cleared unless both writes succeed. The backup is never written
|
|
929
|
+
// by ordinary readers, so a torn cache refresh cannot destroy both copies.
|
|
930
|
+
const checkpoint = resolveUsageCheckpointPath(options.snapshotPath);
|
|
931
|
+
const temp = checkpoint + '.tmp';
|
|
932
|
+
try {
|
|
933
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
|
|
934
|
+
(0, fs_1.writeFileSync)(temp, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', flush: true });
|
|
935
|
+
(0, fs_1.renameSync)(temp, checkpoint);
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
try {
|
|
939
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
940
|
+
}
|
|
941
|
+
catch { /* Best effort. */ }
|
|
942
|
+
throw new Error('Cannot consolidate usage events: checkpoint backup could not be saved.');
|
|
943
|
+
}
|
|
944
|
+
if (!writeUsageSnapshot(store, options.snapshotPath)) {
|
|
945
|
+
throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
|
|
415
946
|
}
|
|
416
|
-
return
|
|
947
|
+
return (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
|
|
948
|
+
if (event.kind !== 'offer' && event.kind !== 'firing')
|
|
949
|
+
return true;
|
|
950
|
+
const date = typeof event.date === 'string' ? event.date : '';
|
|
951
|
+
if (typeof event.key !== 'string' || !event.key || !isValidDate(date))
|
|
952
|
+
return true;
|
|
953
|
+
return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
|
|
954
|
+
}, store.foldedThroughBytes ?? {});
|
|
417
955
|
}
|