@tryinget/pi-agent-vent 0.2.2 → 0.3.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.
@@ -0,0 +1,606 @@
1
+ /**
2
+ summary: "Vent store retention candidates, archive, history, restore, and fail-closed guards; split from vent-store.test.js."
3
+ read_when:
4
+ - "You change retention candidates, archive, history, restore, and fail-closed guards behavior."
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import test from "node:test";
12
+ import {
13
+ appendReviewEvent,
14
+ appendVentRecord,
15
+ archiveRecurrenceGroup,
16
+ buildRetentionCandidates,
17
+ buildRetentionHistory,
18
+ buildRetentionPreview,
19
+ createReviewEvent,
20
+ createVentRecord,
21
+ formatRetentionCandidates,
22
+ formatRetentionHistory,
23
+ loadDiagnosticState,
24
+ readRetentionEvents,
25
+ readVentRecords,
26
+ restoreRetentionBackup,
27
+ } from "../src/vent-store.js";
28
+
29
+ test("retention candidates list reviewed groups without tokens or mutation", () => {
30
+ const fresh = createVentRecord({
31
+ summary: "Fresh retention group",
32
+ category: "workflow",
33
+ recurrenceKey: "fresh-retention",
34
+ });
35
+ const acknowledged = createVentRecord({
36
+ summary: "Acknowledged retention group token=abc123",
37
+ category: "tool_failure",
38
+ recurrenceKey: "ack-retention",
39
+ tags: ["Retention"],
40
+ tool: "pi reload",
41
+ packageName: "@tryinget/pi-agent-vent",
42
+ });
43
+ const dismissed = createVentRecord({
44
+ summary: "Dismissed retention group",
45
+ category: "documentation",
46
+ recurrenceKey: "dismissed-retention",
47
+ tags: ["Retention"],
48
+ });
49
+ const reviews = [
50
+ createReviewEvent({
51
+ recurrenceKey: acknowledged.recurrenceKey,
52
+ state: "acknowledged",
53
+ note: "reviewed token=abc123 locally",
54
+ }),
55
+ createReviewEvent({ recurrenceKey: dismissed.recurrenceKey, state: "dismissed" }),
56
+ ];
57
+
58
+ const candidates = buildRetentionCandidates({
59
+ records: [fresh, acknowledged, dismissed],
60
+ reviewEvents: reviews,
61
+ filters: { tags: ["retention"], tool: "pi reload" },
62
+ limit: 10,
63
+ now: "2026-05-22T00:00:00.000Z",
64
+ });
65
+ assert.equal(candidates.stateFilter, "reviewed");
66
+ assert.equal(candidates.groupCount, 3);
67
+ assert.equal(candidates.matchingGroupCount, 1);
68
+ assert.equal(candidates.candidateCount, 1);
69
+ assert.equal(candidates.items[0].reviewState, "acknowledged");
70
+ assert.equal(candidates.filters.tool, "pi-reload");
71
+
72
+ const text = formatRetentionCandidates(candidates);
73
+ assert.match(text, /Agent vent retention candidates/);
74
+ assert.match(text, /read-only planning view/);
75
+ assert.match(text, /preview to request archive token: \/agent_vent retention preview/);
76
+ assert.match(text, /reviewed token=\[REDACTED\] locally/);
77
+ assert.match(
78
+ text,
79
+ /export this outcome bucket: \/agent_vent export markdown acknowledged tool=pi-reload tag=retention/,
80
+ );
81
+ assert.match(text, /No archive, restore, AK task, GitHub issue, incident, evidence/);
82
+ assert.match(text, /not owner routing/);
83
+ assert.doesNotMatch(text, /archive:[a-f0-9]/);
84
+ assert.doesNotMatch(text, /abc123|was archived|was filed|owner was assigned/);
85
+
86
+ const allCandidates = buildRetentionCandidates({
87
+ records: [fresh, acknowledged],
88
+ reviewEvents: reviews,
89
+ state: "all",
90
+ });
91
+ assert.equal(allCandidates.candidateCount, 2);
92
+ assert.match(formatRetentionCandidates(allCandidates), /review before archive/);
93
+ assert.throws(
94
+ () => buildRetentionCandidates({ records: [], reviewEvents: [], state: "resolved" }),
95
+ /invalid agent_vent review state/,
96
+ );
97
+ assert.throws(
98
+ () => buildRetentionCandidates({ filters: { category: "../../etc/passwd" } }),
99
+ /invalid agent_vent review filter category/,
100
+ );
101
+ });
102
+
103
+ test("retention archive is confirmation-gated, backed up, receipted, and restorable", () => {
104
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-"));
105
+ const storePath = path.join(dir, "vents.jsonl");
106
+ const reviewPath = path.join(dir, "review-events.jsonl");
107
+ const retentionPath = path.join(dir, "retention-events.jsonl");
108
+ const backupDir = path.join(dir, "backups");
109
+ const keep = createVentRecord(
110
+ { summary: "Keep active", category: "workflow", recurrenceKey: "keep" },
111
+ { id: "keep" },
112
+ );
113
+ const archive = createVentRecord(
114
+ { summary: "Archive reviewed", category: "workflow", recurrenceKey: "archive-me" },
115
+ { id: "archive" },
116
+ );
117
+ appendVentRecord(storePath, keep);
118
+ appendVentRecord(storePath, archive);
119
+ appendReviewEvent(
120
+ reviewPath,
121
+ createReviewEvent({
122
+ recurrenceKey: archive.recurrenceKey,
123
+ state: "dismissed",
124
+ note: "reviewed token=abc123",
125
+ }),
126
+ );
127
+
128
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
129
+ assert.throws(
130
+ () =>
131
+ buildRetentionPreview({
132
+ recurrenceKey: archive.recurrenceKey,
133
+ records: state.records,
134
+ reviewEvents: state.reviewEvents,
135
+ storeHash: state.ventsHash,
136
+ }),
137
+ /requires current store, review, and curation hashes/,
138
+ );
139
+ const preview = buildRetentionPreview({
140
+ recurrenceKey: archive.recurrenceKey,
141
+ records: state.records,
142
+ reviewEvents: state.reviewEvents,
143
+ storeHash: state.ventsHash,
144
+ reviewHash: state.reviewEventsHash,
145
+ curationHash: state.curationEventsHash,
146
+ });
147
+ assert.equal(preview.archivable, true);
148
+ assert.equal(preview.archivedRecordCount, 1);
149
+ assert.match(preview.confirmationToken, /^archive:/);
150
+ assert.throws(
151
+ () =>
152
+ archiveRecurrenceGroup({
153
+ storePath,
154
+ reviewPath,
155
+ retentionPath,
156
+ backupDir,
157
+ recurrenceKey: archive.recurrenceKey,
158
+ confirmationToken: "archive:wrong",
159
+ }),
160
+ /requires exact confirmation token/,
161
+ );
162
+
163
+ const result = archiveRecurrenceGroup({
164
+ storePath,
165
+ reviewPath,
166
+ retentionPath,
167
+ backupDir,
168
+ recurrenceKey: archive.recurrenceKey,
169
+ confirmationToken: preview.confirmationToken,
170
+ note: "archive note password=hunter2",
171
+ });
172
+ assert.equal(readVentRecords(storePath).records.length, 1);
173
+ assert.equal(readVentRecords(storePath).records[0].id, "keep");
174
+ assert.equal(fs.existsSync(result.backupPath), true);
175
+ assert.match(result.restoreConfirmationToken, /^restore:/);
176
+ const retentionEvents = readRetentionEvents(retentionPath).events;
177
+ assert.equal(retentionEvents.length, 1);
178
+ assert.equal(retentionEvents[0].action, "archive");
179
+ assert.match(retentionEvents[0].note, /password=\[REDACTED\]/);
180
+ assert.match(result.boundary, /No AK task, GitHub issue, incident, evidence/);
181
+
182
+ const restore = restoreRetentionBackup({
183
+ storePath,
184
+ retentionPath,
185
+ backupDir,
186
+ backupPath: result.backupPath,
187
+ confirmationToken: result.restoreConfirmationToken,
188
+ });
189
+ assert.equal(restore.restoredRecordCount, 1);
190
+ assert.equal(readVentRecords(storePath).records.length, 2);
191
+ assert.equal(readRetentionEvents(retentionPath).events.length, 2);
192
+ });
193
+
194
+ test("retention history reconstructs restore candidates without trusting escaped receipt paths", () => {
195
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent vent retention history-"));
196
+ const storePath = path.join(dir, "vents.jsonl");
197
+ const reviewPath = path.join(dir, "review-events.jsonl");
198
+ const retentionPath = path.join(dir, "retention-events.jsonl");
199
+ const backupDir = path.join(dir, "backups");
200
+ const record = createVentRecord(
201
+ { summary: "History archive", category: "workflow", recurrenceKey: "history archive" },
202
+ { id: "history" },
203
+ );
204
+ appendVentRecord(storePath, record);
205
+ appendReviewEvent(
206
+ reviewPath,
207
+ createReviewEvent({ recurrenceKey: record.recurrenceKey, state: "acknowledged" }),
208
+ );
209
+
210
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
211
+ const preview = buildRetentionPreview({
212
+ recurrenceKey: record.recurrenceKey,
213
+ records: state.records,
214
+ reviewEvents: state.reviewEvents,
215
+ curationEvents: state.curationEvents,
216
+ storeHash: state.ventsHash,
217
+ reviewHash: state.reviewEventsHash,
218
+ curationHash: state.curationEventsHash,
219
+ });
220
+ const archived = archiveRecurrenceGroup({
221
+ storePath,
222
+ reviewPath,
223
+ retentionPath,
224
+ backupDir,
225
+ recurrenceKey: record.recurrenceKey,
226
+ confirmationToken: preview.confirmationToken,
227
+ });
228
+ const events = readRetentionEvents(retentionPath).events;
229
+ const history = buildRetentionHistory({ retentionEvents: events, backupDir, limit: 5 });
230
+
231
+ assert.equal(history.totalEvents, 1);
232
+ assert.match(history.items[0].restoreConfirmationToken, /^restore:/);
233
+ assert.equal(history.items[0].restoreConfirmationToken, archived.restoreConfirmationToken);
234
+ assert.match(history.items[0].restoreCommand, /retention restore/);
235
+ assert.match(history.items[0].restoreCommand, /".*history-archive.*\.agent-vent-backup\.json"/);
236
+ const text = formatRetentionHistory(history);
237
+ assert.match(text, /read-only receipt projection/i);
238
+ assert.match(text, /rollback candidate: \/agent_vent retention restore/);
239
+ assert.match(text, /stale\/moved\/path-invalid backups fail closed/);
240
+ assert.doesNotMatch(
241
+ text,
242
+ /AK task was created|GitHub issue was created|incident was declared|evidence was recorded|owner was assigned/,
243
+ );
244
+
245
+ const escaped = buildRetentionHistory({
246
+ retentionEvents: [
247
+ {
248
+ ...events[0],
249
+ id: "evil-receipt",
250
+ backupPath: path.join(dir, "outside.agent-vent-backup.json"),
251
+ },
252
+ ],
253
+ backupDir,
254
+ });
255
+ assert.equal(escaped.items[0].restoreCommand, undefined);
256
+ assert.match(
257
+ escaped.items[0].restoreUnavailableReason,
258
+ /outside the configured backup directory/,
259
+ );
260
+ });
261
+
262
+ test("retention archive removes only selected group records when legacy ids collide", () => {
263
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-duplicate-id-"));
264
+ const storePath = path.join(dir, "vents.jsonl");
265
+ const reviewPath = path.join(dir, "review-events.jsonl");
266
+ const retentionPath = path.join(dir, "retention-events.jsonl");
267
+ const backupDir = path.join(dir, "backups");
268
+ const archive = createVentRecord(
269
+ { summary: "Archive duplicate id", category: "bug", recurrenceKey: "archive-duplicate" },
270
+ { id: "duplicate-id" },
271
+ );
272
+ const keep = createVentRecord(
273
+ { summary: "Keep duplicate id", category: "workflow", recurrenceKey: "keep-duplicate" },
274
+ { id: "duplicate-id" },
275
+ );
276
+ appendVentRecord(storePath, archive);
277
+ appendVentRecord(storePath, keep);
278
+ appendReviewEvent(
279
+ reviewPath,
280
+ createReviewEvent({ recurrenceKey: archive.recurrenceKey, state: "acknowledged" }),
281
+ );
282
+
283
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
284
+ const preview = buildRetentionPreview({
285
+ recurrenceKey: archive.recurrenceKey,
286
+ records: state.records,
287
+ reviewEvents: state.reviewEvents,
288
+ storeHash: state.ventsHash,
289
+ reviewHash: state.reviewEventsHash,
290
+ curationHash: state.curationEventsHash,
291
+ });
292
+ archiveRecurrenceGroup({
293
+ storePath,
294
+ reviewPath,
295
+ retentionPath,
296
+ backupDir,
297
+ recurrenceKey: archive.recurrenceKey,
298
+ confirmationToken: preview.confirmationToken,
299
+ });
300
+
301
+ const remaining = readVentRecords(storePath).records;
302
+ assert.equal(remaining.length, 1);
303
+ assert.equal(remaining[0].recurrenceKey, keep.recurrenceKey);
304
+ assert.equal(remaining[0].id, "duplicate-id");
305
+ });
306
+
307
+ test("retention fails closed for unreviewed groups, path escape, and stale restore", () => {
308
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-negative-"));
309
+ const storePath = path.join(dir, "vents.jsonl");
310
+ const reviewPath = path.join(dir, "review-events.jsonl");
311
+ const retentionPath = path.join(dir, "retention-events.jsonl");
312
+ const backupDir = path.join(dir, "backups");
313
+ const record = createVentRecord(
314
+ { summary: "Needs lifecycle", category: "bug", recurrenceKey: "needs-lifecycle" },
315
+ { id: "v1" },
316
+ );
317
+ appendVentRecord(storePath, record);
318
+ const unreviewed = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
319
+ const preview = buildRetentionPreview({
320
+ recurrenceKey: record.recurrenceKey,
321
+ records: unreviewed.records,
322
+ reviewEvents: unreviewed.reviewEvents,
323
+ storeHash: unreviewed.ventsHash,
324
+ reviewHash: unreviewed.reviewEventsHash,
325
+ curationHash: unreviewed.curationEventsHash,
326
+ });
327
+ assert.equal(preview.archivable, false);
328
+ assert.throws(
329
+ () =>
330
+ archiveRecurrenceGroup({
331
+ storePath,
332
+ reviewPath,
333
+ retentionPath,
334
+ backupDir,
335
+ recurrenceKey: record.recurrenceKey,
336
+ confirmationToken: preview.confirmationToken,
337
+ }),
338
+ /before local review/,
339
+ );
340
+
341
+ appendReviewEvent(
342
+ reviewPath,
343
+ createReviewEvent({ recurrenceKey: record.recurrenceKey, state: "acknowledged" }),
344
+ );
345
+ assert.throws(
346
+ () =>
347
+ archiveRecurrenceGroup({
348
+ storePath,
349
+ reviewPath,
350
+ retentionPath,
351
+ backupDir,
352
+ recurrenceKey: record.recurrenceKey,
353
+ confirmationToken: preview.confirmationToken,
354
+ }),
355
+ /requires exact confirmation token/,
356
+ );
357
+ const reviewed = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
358
+ const token = buildRetentionPreview({
359
+ recurrenceKey: record.recurrenceKey,
360
+ records: reviewed.records,
361
+ reviewEvents: reviewed.reviewEvents,
362
+ storeHash: reviewed.ventsHash,
363
+ reviewHash: reviewed.reviewEventsHash,
364
+ curationHash: reviewed.curationEventsHash,
365
+ }).confirmationToken;
366
+ const archived = archiveRecurrenceGroup({
367
+ storePath,
368
+ reviewPath,
369
+ retentionPath,
370
+ backupDir,
371
+ recurrenceKey: record.recurrenceKey,
372
+ confirmationToken: token,
373
+ });
374
+
375
+ assert.throws(
376
+ () =>
377
+ restoreRetentionBackup({
378
+ storePath,
379
+ retentionPath,
380
+ backupDir,
381
+ backupPath: path.join(dir, "outside.agent-vent-backup.json"),
382
+ confirmationToken: archived.restoreConfirmationToken,
383
+ }),
384
+ /backup path must stay inside/,
385
+ );
386
+ appendVentRecord(
387
+ storePath,
388
+ createVentRecord({ summary: "New record after archive", category: "bug" }, { id: "new" }),
389
+ );
390
+ assert.throws(
391
+ () =>
392
+ restoreRetentionBackup({
393
+ storePath,
394
+ retentionPath,
395
+ backupDir,
396
+ backupPath: archived.backupPath,
397
+ confirmationToken: archived.restoreConfirmationToken,
398
+ }),
399
+ /stale backup/,
400
+ );
401
+ });
402
+
403
+ test("retention archive rejects stale confirmation when active store changes", () => {
404
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-stale-token-"));
405
+ const storePath = path.join(dir, "vents.jsonl");
406
+ const reviewPath = path.join(dir, "review-events.jsonl");
407
+ const retentionPath = path.join(dir, "retention-events.jsonl");
408
+ const backupDir = path.join(dir, "backups");
409
+ const record = createVentRecord(
410
+ { summary: "Archive target", category: "bug", recurrenceKey: "target" },
411
+ { id: "target" },
412
+ );
413
+ appendVentRecord(storePath, record);
414
+ appendReviewEvent(
415
+ reviewPath,
416
+ createReviewEvent({ recurrenceKey: record.recurrenceKey, state: "acknowledged" }),
417
+ );
418
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
419
+ const preview = buildRetentionPreview({
420
+ recurrenceKey: record.recurrenceKey,
421
+ records: state.records,
422
+ reviewEvents: state.reviewEvents,
423
+ storeHash: state.ventsHash,
424
+ reviewHash: state.reviewEventsHash,
425
+ curationHash: state.curationEventsHash,
426
+ });
427
+ appendVentRecord(
428
+ storePath,
429
+ createVentRecord({ summary: "Concurrent new record", category: "bug" }, { id: "new" }),
430
+ );
431
+ assert.throws(
432
+ () =>
433
+ archiveRecurrenceGroup({
434
+ storePath,
435
+ reviewPath,
436
+ retentionPath,
437
+ backupDir,
438
+ recurrenceKey: record.recurrenceKey,
439
+ confirmationToken: preview.confirmationToken,
440
+ }),
441
+ /requires exact confirmation token/,
442
+ );
443
+ assert.deepEqual(
444
+ readVentRecords(storePath)
445
+ .records.map((entry) => entry.id)
446
+ .sort(),
447
+ ["new", "target"],
448
+ );
449
+ });
450
+
451
+ test("retention archive rolls active store back when receipt append fails", (context) => {
452
+ if (process.getuid?.() === 0) {
453
+ context.skip("root can bypass file write permissions");
454
+ return;
455
+ }
456
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-receipt-fail-"));
457
+ const storePath = path.join(dir, "vents.jsonl");
458
+ const reviewPath = path.join(dir, "review-events.jsonl");
459
+ const retentionPath = path.join(dir, "retention-events.jsonl");
460
+ const backupDir = path.join(dir, "backups");
461
+ const record = createVentRecord(
462
+ { summary: "Receipt failure target", category: "bug", recurrenceKey: "receipt-fail" },
463
+ { id: "target" },
464
+ );
465
+ appendVentRecord(storePath, record);
466
+ appendReviewEvent(
467
+ reviewPath,
468
+ createReviewEvent({ recurrenceKey: record.recurrenceKey, state: "dismissed" }),
469
+ );
470
+ fs.writeFileSync(retentionPath, "", "utf8");
471
+ fs.chmodSync(retentionPath, 0o400);
472
+ try {
473
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
474
+ const token = buildRetentionPreview({
475
+ recurrenceKey: record.recurrenceKey,
476
+ records: state.records,
477
+ reviewEvents: state.reviewEvents,
478
+ storeHash: state.ventsHash,
479
+ reviewHash: state.reviewEventsHash,
480
+ curationHash: state.curationEventsHash,
481
+ }).confirmationToken;
482
+ assert.throws(
483
+ () =>
484
+ archiveRecurrenceGroup({
485
+ storePath,
486
+ reviewPath,
487
+ retentionPath,
488
+ backupDir,
489
+ recurrenceKey: record.recurrenceKey,
490
+ confirmationToken: token,
491
+ }),
492
+ /EACCES|EPERM|permission/i,
493
+ );
494
+ assert.equal(readVentRecords(storePath).records.length, 1);
495
+ assert.equal(fs.readdirSync(backupDir).length, 0);
496
+ } finally {
497
+ fs.chmodSync(retentionPath, 0o600);
498
+ }
499
+ });
500
+
501
+ test("retention restore rejects symlinked backup directories and tampered restore tokens", () => {
502
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-symlink-backup-"));
503
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-external-"));
504
+ const storePath = path.join(dir, "vents.jsonl");
505
+ const retentionPath = path.join(dir, "retention-events.jsonl");
506
+ const backupDir = path.join(dir, "backups");
507
+ const current = createVentRecord({ summary: "Current", category: "bug" }, { id: "current" });
508
+ const injectedJsonl = `${JSON.stringify(createVentRecord({ summary: "Injected", category: "bug" }, { id: "injected" }))}\n`;
509
+ appendVentRecord(storePath, current);
510
+ const currentText = fs.readFileSync(storePath, "utf8");
511
+ const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
512
+ const backup = {
513
+ schemaVersion: 1,
514
+ artifactType: "agent_vent_retention_backup",
515
+ recurrenceKey: "bug:crafted",
516
+ beforeHash: hash(injectedJsonl),
517
+ afterHash: hash(currentText),
518
+ restoreConfirmationToken: "restore:not-derived",
519
+ ventsJsonl: injectedJsonl,
520
+ };
521
+ fs.writeFileSync(
522
+ path.join(externalDir, "crafted.agent-vent-backup.json"),
523
+ JSON.stringify(backup),
524
+ );
525
+ fs.symlinkSync(externalDir, backupDir);
526
+ assert.throws(
527
+ () =>
528
+ restoreRetentionBackup({
529
+ storePath,
530
+ retentionPath,
531
+ backupDir,
532
+ backupPath: path.join(backupDir, "crafted.agent-vent-backup.json"),
533
+ confirmationToken: "restore:not-derived",
534
+ }),
535
+ /backup directory must be a real directory/,
536
+ );
537
+ fs.rmSync(backupDir);
538
+ fs.mkdirSync(backupDir);
539
+ const localBackupPath = path.join(backupDir, "crafted.agent-vent-backup.json");
540
+ fs.writeFileSync(localBackupPath, JSON.stringify(backup));
541
+ assert.throws(
542
+ () =>
543
+ restoreRetentionBackup({
544
+ storePath,
545
+ retentionPath,
546
+ backupDir,
547
+ backupPath: localBackupPath,
548
+ confirmationToken: "restore:not-derived",
549
+ }),
550
+ /restore token failed integrity check/,
551
+ );
552
+ });
553
+
554
+ test("retention restore fails closed when current store is missing", () => {
555
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-vent-retention-missing-"));
556
+ const storePath = path.join(dir, "vents.jsonl");
557
+ const reviewPath = path.join(dir, "review-events.jsonl");
558
+ const retentionPath = path.join(dir, "retention-events.jsonl");
559
+ const backupDir = path.join(dir, "backups");
560
+ const record = createVentRecord(
561
+ {
562
+ summary: "Archive then remove active store",
563
+ category: "bug",
564
+ recurrenceKey: "missing-store",
565
+ },
566
+ { id: "missing" },
567
+ );
568
+ appendVentRecord(storePath, record);
569
+ appendReviewEvent(
570
+ reviewPath,
571
+ createReviewEvent({ recurrenceKey: record.recurrenceKey, state: "dismissed" }),
572
+ );
573
+ const state = loadDiagnosticState({ storePath, reviewPath, retentionPath, backupDir });
574
+ const token = buildRetentionPreview({
575
+ recurrenceKey: record.recurrenceKey,
576
+ records: state.records,
577
+ reviewEvents: state.reviewEvents,
578
+ storeHash: state.ventsHash,
579
+ reviewHash: state.reviewEventsHash,
580
+ curationHash: state.curationEventsHash,
581
+ }).confirmationToken;
582
+ const archived = archiveRecurrenceGroup({
583
+ storePath,
584
+ reviewPath,
585
+ retentionPath,
586
+ backupDir,
587
+ recurrenceKey: record.recurrenceKey,
588
+ confirmationToken: token,
589
+ });
590
+ fs.rmSync(storePath);
591
+ assert.throws(
592
+ () =>
593
+ restoreRetentionBackup({
594
+ storePath,
595
+ retentionPath,
596
+ backupDir,
597
+ backupPath: archived.backupPath,
598
+ confirmationToken: archived.restoreConfirmationToken,
599
+ }),
600
+ /requires current vents store/,
601
+ );
602
+ });
603
+
604
+ test("record requires non-empty summary", () => {
605
+ assert.throws(() => createVentRecord({ summary: " " }), /requires a non-empty summary/);
606
+ });