artshelf 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,706 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { assertSafeGeneratedId, getRecord, readLedger, registerArtshelfArtifact, writeLedger } from "./ledger.js";
5
+ import { withPathLock } from "./locks.js";
6
+ import { addTtl, assertIsoDate, now, toIso } from "./time.js";
7
+ const DISPOSE_ACTIONS = new Set(["trash-resolve", "resolve-only", "snooze", "keep"]);
8
+ const ARTSHELF_STATUSES = new Set(["active", "review-required", "trashed", "cleanup-refused", "resolved"]);
9
+ // Classify one disposition request against the live ledger (NGX-483). This is the
10
+ // read-only safety engine the dry-run/execute workflow builds on: it loads the record,
11
+ // checks the requested action is applicable and safe, and returns either the actionable
12
+ // plan entry or a block reason with evidence. It never writes a plan or touches the
13
+ // filesystem. The returned entry carries no trash target yet; that is plan-id scoped and
14
+ // is filled in once a plan id exists.
15
+ export function classifyDisposition(ledgerPath, request) {
16
+ if (!DISPOSE_ACTIONS.has(request.action)) {
17
+ return blocked("unknown-action", `unknown dispose action: ${request.action}`);
18
+ }
19
+ const record = getRecord(readLedger(ledgerPath), request.id);
20
+ const subjectPath = record.path;
21
+ const subject = snapshotSubject(subjectPath);
22
+ const reason = (request.reason ?? "").trim();
23
+ if (request.action === "trash-resolve") {
24
+ if (record.status === "resolved")
25
+ return blocked("already-resolved", "record is already resolved");
26
+ if (record.status === "trashed") {
27
+ return blocked("already-trashed", "record is already trashed; permanent removal is `trash purge`, ledger cleanup is resolve-only");
28
+ }
29
+ if (subject.existence === "missing") {
30
+ return blocked("missing-subject-path", `recorded path is missing: ${subjectPath}; use resolve-only to close the ledger record`);
31
+ }
32
+ return ok(buildEntry(record, "trash-resolve", reason || defaultReason("trash-resolve"), subjectPath, subject));
33
+ }
34
+ if (request.action === "resolve-only") {
35
+ if (record.status === "resolved")
36
+ return blocked("already-resolved", "record is already resolved");
37
+ if (record.status === "trashed") {
38
+ return blocked("already-trashed", "record is already trashed; use trash purge for physical removal or reconcile stale trash after the target is gone");
39
+ }
40
+ if (!reason)
41
+ return blocked("missing-reason", "resolve-only requires a reason");
42
+ return ok(buildEntry(record, "resolve-only", reason, subjectPath, subject));
43
+ }
44
+ if (request.action === "snooze") {
45
+ if (isTerminal(record.status))
46
+ return blocked("terminal-record", `cannot snooze a ${record.status} record`);
47
+ if (request.ttl && request.retainUntil) {
48
+ return blocked("ambiguous-snooze-horizon", "choose exactly one of --ttl or --retain-until");
49
+ }
50
+ if (!request.ttl && !request.retainUntil) {
51
+ return blocked("missing-snooze-horizon", "snooze requires --ttl or --retain-until");
52
+ }
53
+ const snooze = buildSnoozeRetention(request);
54
+ return ok(buildEntry(record, "snooze", reason || defaultReason("snooze"), subjectPath, subject, snooze));
55
+ }
56
+ // keep
57
+ if (isTerminal(record.status))
58
+ return blocked("terminal-record", `cannot keep a ${record.status} record`);
59
+ return ok(buildEntry(record, "keep", reason || defaultReason("keep"), subjectPath, subject));
60
+ }
61
+ // Build the dispose plan without persisting anything (dry-run preview). Fully read-only:
62
+ // it classifies the request and returns the plan a `--dry-run` would create, but never
63
+ // writes a plan file or touches the ledger.
64
+ export function previewDisposePlan(ledgerPath, request) {
65
+ return buildDisposePlan(ledgerPath, request);
66
+ }
67
+ // Create (or reuse) a reviewed dispose plan (dry-run). This is the only part of dry-run
68
+ // that writes: it persists the plan JSON and registers it as an artshelf-owned artifact
69
+ // so a later `--execute` can bind to an exact reviewed plan id. When an earlier plan
70
+ // already covers the same request it is reused verbatim (stable plan id, target rebound
71
+ // to that id), and when the request is blocked no plan artifact is created at all.
72
+ export function createDisposePlan(ledgerPath, request) {
73
+ const plan = buildDisposePlan(ledgerPath, request);
74
+ if (!plan.entry)
75
+ return plan;
76
+ const existing = matchingExistingDisposePlan(ledgerPath, plan);
77
+ if (existing)
78
+ return existing;
79
+ if (!plan.planPath)
80
+ throw new Error("dispose plan path was not created");
81
+ writeDisposePlanFile(plan.planPath, plan);
82
+ registerArtshelfArtifact(ledgerPath, plan.planPath, {
83
+ reason: `Artshelf dispose dry-run plan ${plan.planId}`,
84
+ ttl: "14d",
85
+ kind: "run-artifact",
86
+ cleanup: "trash",
87
+ labels: ["artshelf", "dispose-plan", plan.planId]
88
+ });
89
+ return plan;
90
+ }
91
+ // Apply a reviewed dispose plan (NGX-483 `dispose --execute`). This is the only mutating
92
+ // dispose entrypoint and it is deliberately conservative:
93
+ // * It refuses up front when the plan id is missing, the plan file is absent, or the
94
+ // plan's declared id/ledger does not match the scoped request (no fresh-plan-then-
95
+ // execute; the command layer enforces that, this binds to one exact reviewed plan id).
96
+ // * A rerun of a plan this row already executed is idempotent (re-derived from the row,
97
+ // never re-moved or re-stamped).
98
+ // * Before mutating it re-snapshots the live subject and refuses (skips) the entry when
99
+ // the record status moved on or the subject drifted from the reviewed snapshot, and
100
+ // for trash-resolve it refuses a target a foreign artifact already occupies.
101
+ // A receipt is written before (intent) and after (verified outcome) the mutation so an
102
+ // executed disposition stays auditable, and is registered as an artshelf-owned artifact.
103
+ export function executeDisposePlan(ledgerPath, planId) {
104
+ if (!planId)
105
+ throw new Error("dispose --execute requires --plan-id");
106
+ const planPath = disposePlanPath(ledgerPath, planId);
107
+ if (!existsSync(planPath))
108
+ throw new Error(`Dispose plan not found: ${planId}`);
109
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
110
+ const entry = assertDisposePlanExecutable(plan, planId, ledgerPath);
111
+ const receiptPath = disposeReceiptPath(ledgerPath, planId);
112
+ return withPathLock(ledgerPath, () => {
113
+ const completedReceipt = existsSync(receiptPath) ? readCompletedDisposeReceipt(receiptPath, planId) : null;
114
+ if (completedReceipt) {
115
+ registerDisposeReceipt(ledgerPath, receiptPath, planId);
116
+ return {
117
+ planId,
118
+ receiptPath,
119
+ executedAt: completedReceipt.executedAt,
120
+ result: completedReceipt.result
121
+ };
122
+ }
123
+ const records = readLedger(ledgerPath);
124
+ const index = records.findIndex((record) => record.id === entry.id);
125
+ const record = index >= 0 ? records[index] : undefined;
126
+ if (record?.disposePlanId === planId) {
127
+ const replayReceiptPath = record.disposeReceiptPath ?? receiptPath;
128
+ const receipt = existsSync(replayReceiptPath) ? readCompletedDisposeReceipt(replayReceiptPath, planId) : null;
129
+ const started = receipt ? null : readStartedDisposeReceipt(replayReceiptPath, planId);
130
+ const result = receipt?.result ?? appliedResultFromRecord(entry, record);
131
+ const executedAt = receipt?.executedAt ?? record.disposedAt ?? started?.executedAt ?? toIso(now());
132
+ if (!receipt) {
133
+ writeDisposeReceipt(replayReceiptPath, { planId, ledgerPath, executedAt, status: "completed", result });
134
+ }
135
+ registerDisposeReceipt(ledgerPath, replayReceiptPath, planId);
136
+ return {
137
+ planId,
138
+ receiptPath: replayReceiptPath,
139
+ executedAt,
140
+ result
141
+ };
142
+ }
143
+ const started = readStartedDisposeReceipt(receiptPath, planId);
144
+ const executedAt = started?.executedAt ?? toIso(now());
145
+ const audit = { planId, receiptPath, executedAt };
146
+ // Announce intent before any mutation so an interrupted move leaves a breadcrumb.
147
+ writeDisposeReceipt(receiptPath, { planId, ledgerPath, executedAt, status: "started", action: entry.action, target: entry.targetPath ?? null });
148
+ const outcome = applyDisposeEntry(records, index, entry, audit);
149
+ if (outcome.records)
150
+ writeLedger(ledgerPath, outcome.records);
151
+ writeDisposeReceipt(receiptPath, { planId, ledgerPath, executedAt, status: "completed", result: outcome.result });
152
+ registerDisposeReceipt(ledgerPath, receiptPath, planId);
153
+ return { planId, receiptPath, executedAt, result: outcome.result };
154
+ }, "Artshelf ledger");
155
+ }
156
+ // Decide and apply the disposition for one reviewed entry against the live ledger. The
157
+ // guard order matters: idempotency (this plan already ran) is checked before drift, since
158
+ // a completed trash-resolve legitimately leaves the subject missing.
159
+ function applyDisposeEntry(records, index, entry, audit) {
160
+ const record = index >= 0 ? records[index] : undefined;
161
+ if (!record) {
162
+ return refusal(entry, "record is missing from ledger", entry.status, snapshotSubject(entry.subjectPath), null);
163
+ }
164
+ if (record.disposePlanId === audit.planId) {
165
+ return { records: null, result: appliedResultFromRecord(entry, record) };
166
+ }
167
+ const live = snapshotSubject(entry.subjectPath);
168
+ if (record.path !== entry.path || record.path !== entry.subjectPath) {
169
+ return refusal(entry, "live ledger path no longer matches the reviewed plan", record.status, live, null);
170
+ }
171
+ if (entry.action === "trash-resolve" && record.status === entry.status && canResumeTrashResolve(entry, live)) {
172
+ return applyTrashResolveFromTarget(records, index, record, entry, audit);
173
+ }
174
+ if (record.status !== entry.status || subjectDrifted(entry.subject, live)) {
175
+ return refusal(entry, "live ledger state no longer matches the reviewed plan", record.status, live, null);
176
+ }
177
+ if (entry.action === "trash-resolve")
178
+ return applyTrashResolve(records, index, record, entry, audit);
179
+ if (entry.action === "resolve-only")
180
+ return applyResolve(records, index, record, entry, audit, live);
181
+ if (entry.action === "snooze")
182
+ return applySnooze(records, index, record, entry, audit, live);
183
+ return applyKeep(records, index, record, entry, audit, live);
184
+ }
185
+ // trash-resolve moves the subject into the plan-scoped trash target, then resolves the row.
186
+ // Because the target path embeds the unique plan id, a foreign file at that exact path is a
187
+ // genuine conflict; the prior-run case (target present, subject already moved away) is
188
+ // handled by the idempotency guard upstream, so here a present target means a conflict.
189
+ function applyTrashResolve(records, index, record, entry, audit) {
190
+ const target = entry.targetPath;
191
+ if (existsSync(target)) {
192
+ return refusal(entry, `target path already exists: ${target}`, record.status, snapshotSubject(entry.subjectPath), target);
193
+ }
194
+ mkdirSync(dirname(target), { recursive: true });
195
+ renameSync(entry.subjectPath, target);
196
+ const updated = {
197
+ ...record,
198
+ status: "trashed",
199
+ targetPath: target,
200
+ previousPath: entry.subjectPath,
201
+ ...disposeStamp(entry, audit)
202
+ };
203
+ return applied(records, index, updated, {
204
+ action: "trash-resolve",
205
+ status: "trashed",
206
+ reason: entry.reason,
207
+ previousPath: entry.subjectPath,
208
+ targetPath: target,
209
+ retention: null,
210
+ retainUntil: null,
211
+ verification: verify(entry, "trashed", target)
212
+ });
213
+ }
214
+ function applyTrashResolveFromTarget(records, index, record, entry, audit) {
215
+ const target = entry.targetPath;
216
+ const updated = {
217
+ ...record,
218
+ status: "trashed",
219
+ targetPath: target,
220
+ previousPath: entry.subjectPath,
221
+ ...disposeStamp(entry, audit)
222
+ };
223
+ return applied(records, index, updated, {
224
+ action: "trash-resolve",
225
+ status: "trashed",
226
+ reason: entry.reason,
227
+ previousPath: entry.subjectPath,
228
+ targetPath: target,
229
+ retention: null,
230
+ retainUntil: null,
231
+ verification: verify(entry, "trashed", target)
232
+ });
233
+ }
234
+ // resolve-only closes the ledger row without touching the filesystem.
235
+ function applyResolve(records, index, record, entry, audit, live) {
236
+ const updated = {
237
+ ...record,
238
+ status: "resolved",
239
+ resolvedAt: audit.executedAt,
240
+ resolutionReason: entry.reason,
241
+ ...disposeStamp(entry, audit)
242
+ };
243
+ return applied(records, index, updated, {
244
+ action: "resolve-only",
245
+ status: "resolved",
246
+ reason: entry.reason,
247
+ previousPath: null,
248
+ targetPath: null,
249
+ retention: null,
250
+ retainUntil: null,
251
+ verification: verifyLive("resolved", live)
252
+ });
253
+ }
254
+ // snooze extends the retention horizon (applied verbatim from the reviewed plan, never
255
+ // recomputed) and leaves the row active and the file in place.
256
+ function applySnooze(records, index, record, entry, audit, live) {
257
+ const retention = entry.retention;
258
+ const retainUntil = entry.retainUntil;
259
+ const updated = {
260
+ ...record,
261
+ retention,
262
+ retainUntil,
263
+ ...disposeStamp(entry, audit)
264
+ };
265
+ return applied(records, index, updated, {
266
+ action: "snooze",
267
+ status: "snoozed",
268
+ reason: entry.reason,
269
+ previousPath: null,
270
+ targetPath: null,
271
+ retention,
272
+ retainUntil,
273
+ verification: verifyLive(updated.status, live)
274
+ });
275
+ }
276
+ // keep stamps the reviewed-and-kept audit on the row, preserving its status and retention
277
+ // verbatim. Due and inspect classification consume the audit stamp to keep the reviewed
278
+ // decision quiet while the same active record remains present.
279
+ function applyKeep(records, index, record, entry, audit, live) {
280
+ const updated = { ...record, ...disposeStamp(entry, audit) };
281
+ return applied(records, index, updated, {
282
+ action: "keep",
283
+ status: "kept",
284
+ reason: entry.reason,
285
+ previousPath: null,
286
+ targetPath: null,
287
+ retention: null,
288
+ retainUntil: null,
289
+ verification: verifyLive(updated.status, live)
290
+ });
291
+ }
292
+ function disposeStamp(entry, audit) {
293
+ return {
294
+ disposePlanId: audit.planId,
295
+ disposeReceiptPath: audit.receiptPath,
296
+ disposedAt: audit.executedAt,
297
+ disposeAction: entry.action,
298
+ disposeReason: entry.reason
299
+ };
300
+ }
301
+ // Splice the mutated record into the ledger and pair it with the execution result.
302
+ function applied(records, index, updated, result) {
303
+ const next = records.slice();
304
+ next[index] = updated;
305
+ return { records: next, result: { id: updated.id, ...result } };
306
+ }
307
+ function refusal(entry, reason, recordStatus, live, target) {
308
+ return {
309
+ records: null,
310
+ result: {
311
+ id: entry.id,
312
+ action: entry.action,
313
+ status: "skipped",
314
+ reason,
315
+ previousPath: null,
316
+ targetPath: null,
317
+ retention: null,
318
+ retainUntil: null,
319
+ verification: {
320
+ recordStatus,
321
+ subjectPresent: live.existence === "present",
322
+ targetPresent: entry.action === "trash-resolve" ? (target ? existsSync(target) : false) : null
323
+ }
324
+ }
325
+ };
326
+ }
327
+ // Re-derive the result of a plan this row already executed, reading the on-disk reality so
328
+ // an idempotent rerun reports the same outcome without mutating anything.
329
+ function appliedResultFromRecord(entry, record) {
330
+ const action = record.disposeAction ?? entry.action;
331
+ const target = action === "trash-resolve" ? (record.targetPath ?? null) : null;
332
+ return {
333
+ id: entry.id,
334
+ action,
335
+ status: resultStatusFor(action),
336
+ reason: record.disposeReason ?? entry.reason,
337
+ previousPath: record.previousPath ?? null,
338
+ targetPath: target,
339
+ retention: action === "snooze" ? (record.retention ?? null) : null,
340
+ retainUntil: action === "snooze" ? (record.retainUntil ?? null) : null,
341
+ verification: {
342
+ recordStatus: record.status,
343
+ subjectPresent: existsSync(entry.subjectPath),
344
+ targetPresent: action === "trash-resolve" ? (target ? existsSync(target) : false) : null
345
+ }
346
+ };
347
+ }
348
+ function resultStatusFor(action) {
349
+ if (action === "trash-resolve")
350
+ return "trashed";
351
+ if (action === "snooze")
352
+ return "snoozed";
353
+ if (action === "keep")
354
+ return "kept";
355
+ return "resolved";
356
+ }
357
+ // Verify a trash-resolve outcome: the subject is gone from its recorded path and present
358
+ // at the trash target.
359
+ function verify(entry, recordStatus, target) {
360
+ return {
361
+ recordStatus,
362
+ subjectPresent: existsSync(entry.subjectPath),
363
+ targetPresent: existsSync(target)
364
+ };
365
+ }
366
+ // Verify a non-moving outcome (resolve-only/snooze/keep): the subject stays where it was.
367
+ function verifyLive(recordStatus, live) {
368
+ return { recordStatus, subjectPresent: live.existence === "present", targetPresent: null };
369
+ }
370
+ // Two subject snapshots agree only when existence, node kind, byte size, and fingerprint
371
+ // all match; any drift since the reviewed dry-run refuses the plan.
372
+ function subjectDrifted(reviewed, live) {
373
+ return reviewed.existence !== live.existence || reviewed.nodeKind !== live.nodeKind || reviewed.byteSize !== live.byteSize || reviewed.fingerprint !== live.fingerprint;
374
+ }
375
+ function canResumeTrashResolve(entry, live) {
376
+ if (live.existence !== "missing" || !entry.targetPath || !existsSync(entry.targetPath))
377
+ return false;
378
+ return !subjectDrifted(entry.subject, snapshotSubject(entry.targetPath));
379
+ }
380
+ // Bind a loaded dispose plan to the request before any mutation, mirroring reconcile's
381
+ // assertReconcilePlanExecutable: the plan must declare the requested id, belong to the
382
+ // executing ledger, and carry a single well-formed actionable entry.
383
+ function assertDisposePlanExecutable(plan, planId, ledgerPath) {
384
+ if (plan.planId !== planId) {
385
+ throw new Error(`Dispose plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
386
+ }
387
+ if (plan.ledgerPath !== ledgerPath) {
388
+ throw new Error(`Dispose plan ledger mismatch: plan was created for ${plan.ledgerPath}, executing ${ledgerPath}`);
389
+ }
390
+ const entry = plan.entry;
391
+ if (!entry ||
392
+ typeof entry.id !== "string" ||
393
+ !DISPOSE_ACTIONS.has(entry.action) ||
394
+ typeof entry.status !== "string" ||
395
+ !ARTSHELF_STATUSES.has(entry.status) ||
396
+ typeof entry.path !== "string" ||
397
+ typeof entry.subjectPath !== "string" ||
398
+ typeof entry.reason !== "string" ||
399
+ !isSubjectSnapshot(entry.subject)) {
400
+ throw new Error(`Dispose plan entry is malformed: ${planId}`);
401
+ }
402
+ if (entry.action === "trash-resolve") {
403
+ const expectedTarget = disposeTrashTarget(ledgerPath, planId, entry.id, entry.subjectPath);
404
+ if (entry.targetPath !== expectedTarget) {
405
+ throw new Error(`Dispose plan target path mismatch: expected ${expectedTarget}`);
406
+ }
407
+ }
408
+ else if (entry.targetPath !== undefined) {
409
+ throw new Error(`Dispose plan entry is malformed: ${planId}`);
410
+ }
411
+ if (entry.action === "snooze") {
412
+ if (!isSnoozeRetention(entry.retention) || typeof entry.retainUntil !== "string") {
413
+ throw new Error(`Dispose plan entry is malformed: ${planId}`);
414
+ }
415
+ assertIsoDate(entry.retainUntil, "dispose plan retainUntil");
416
+ }
417
+ else if (entry.retention !== undefined || entry.retainUntil !== undefined) {
418
+ throw new Error(`Dispose plan entry is malformed: ${planId}`);
419
+ }
420
+ return entry;
421
+ }
422
+ function isSubjectSnapshot(value) {
423
+ if (!value || typeof value !== "object")
424
+ return false;
425
+ const subject = value;
426
+ const validExistence = subject.existence === "present" || subject.existence === "missing";
427
+ const validNodeKind = subject.nodeKind === "file" || subject.nodeKind === "directory" || subject.nodeKind === "other" || subject.nodeKind === null;
428
+ const validByteSize = subject.byteSize === null || (typeof subject.byteSize === "number" && Number.isFinite(subject.byteSize) && subject.byteSize >= 0);
429
+ const validFingerprint = subject.fingerprint === null || typeof subject.fingerprint === "string";
430
+ return validExistence && validNodeKind && validByteSize && validFingerprint;
431
+ }
432
+ function isSnoozeRetention(value) {
433
+ if (!value || typeof value !== "object")
434
+ return false;
435
+ const retention = value;
436
+ if (retention.mode === "ttl") {
437
+ if (typeof retention.ttl !== "string")
438
+ return false;
439
+ try {
440
+ addTtl(new Date(0), retention.ttl);
441
+ return true;
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ }
447
+ if (retention.mode === "retain-until") {
448
+ if (typeof retention.retainUntil !== "string")
449
+ return false;
450
+ try {
451
+ assertIsoDate(retention.retainUntil, "dispose plan retainUntil");
452
+ return true;
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ }
458
+ return false;
459
+ }
460
+ function disposeReceiptPath(ledgerPath, planId) {
461
+ assertSafeGeneratedId(planId, "dispose plan id");
462
+ return join(dirname(ledgerPath), "dispose-receipts", `${planId}.json`);
463
+ }
464
+ function writeDisposeReceipt(receiptPath, value) {
465
+ mkdirSync(dirname(receiptPath), { recursive: true });
466
+ writeFileSync(receiptPath, `${JSON.stringify(value, null, 2)}\n`);
467
+ }
468
+ function readCompletedDisposeReceipt(receiptPath, planId) {
469
+ try {
470
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
471
+ if (receipt.planId !== planId || receipt.status !== "completed" || typeof receipt.executedAt !== "string" || !isDisposeResult(receipt.result)) {
472
+ return null;
473
+ }
474
+ return { executedAt: receipt.executedAt, result: receipt.result };
475
+ }
476
+ catch {
477
+ return null;
478
+ }
479
+ }
480
+ function readStartedDisposeReceipt(receiptPath, planId) {
481
+ if (!existsSync(receiptPath))
482
+ return null;
483
+ try {
484
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
485
+ if (receipt.planId !== planId || receipt.status !== "started" || typeof receipt.executedAt !== "string")
486
+ return null;
487
+ return { executedAt: receipt.executedAt };
488
+ }
489
+ catch {
490
+ return null;
491
+ }
492
+ }
493
+ function registerDisposeReceipt(ledgerPath, receiptPath, planId) {
494
+ const registered = readLedger(ledgerPath).some((record) => (record.status === "active" &&
495
+ record.path === receiptPath &&
496
+ record.labels.includes("dispose-receipt") &&
497
+ record.labels.includes(planId) &&
498
+ (record.owner === "artshelf" || record.owner === "shelf")));
499
+ if (registered)
500
+ return;
501
+ registerArtshelfArtifact(ledgerPath, receiptPath, {
502
+ reason: `Artshelf dispose receipt for plan ${planId}`,
503
+ ttl: "30d",
504
+ kind: "run-artifact",
505
+ cleanup: "review",
506
+ labels: ["artshelf", "dispose-receipt", planId]
507
+ });
508
+ }
509
+ function isDisposeResult(value) {
510
+ if (!value || typeof value !== "object")
511
+ return false;
512
+ const result = value;
513
+ return (typeof result.id === "string" &&
514
+ typeof result.action === "string" &&
515
+ DISPOSE_ACTIONS.has(result.action) &&
516
+ typeof result.status === "string" &&
517
+ typeof result.reason === "string" &&
518
+ (typeof result.previousPath === "string" || result.previousPath === null) &&
519
+ (typeof result.targetPath === "string" || result.targetPath === null) &&
520
+ Boolean(result.verification));
521
+ }
522
+ function buildDisposePlan(ledgerPath, request) {
523
+ const generatedAt = now();
524
+ const finding = classifyDisposition(ledgerPath, request);
525
+ const base = {
526
+ generatedAt: toIso(generatedAt),
527
+ ledgerPath,
528
+ request: { id: request.id, action: request.action }
529
+ };
530
+ if (!finding.ok) {
531
+ return {
532
+ planId: "not-created",
533
+ ...base,
534
+ entry: null,
535
+ blocked: { id: request.id, action: request.action, reason: finding.reason, detail: finding.detail },
536
+ planPath: null
537
+ };
538
+ }
539
+ const planId = makeDisposePlanId(generatedAt);
540
+ return {
541
+ planId,
542
+ ...base,
543
+ entry: scopeTarget(finding.entry, planId, ledgerPath),
544
+ blocked: null,
545
+ planPath: disposePlanPath(ledgerPath, planId)
546
+ };
547
+ }
548
+ function buildEntry(record, action, reason, subjectPath, subject, snooze) {
549
+ return {
550
+ id: record.id,
551
+ action,
552
+ status: record.status,
553
+ path: record.path,
554
+ subjectPath,
555
+ reason,
556
+ subject,
557
+ ...(snooze ? { retention: snooze.retention, retainUntil: snooze.retainUntil } : {})
558
+ };
559
+ }
560
+ // Attach the plan-id-scoped trash target to a trash-resolve entry; every other action
561
+ // leaves the subject in place, so it carries no target. Recomputing here (rather than in
562
+ // classify) keeps the target bound to whichever plan id finally owns the entry, including
563
+ // a reused plan id.
564
+ function scopeTarget(entry, planId, ledgerPath) {
565
+ if (entry.action !== "trash-resolve")
566
+ return entry;
567
+ return { ...entry, targetPath: disposeTrashTarget(ledgerPath, planId, entry.id, entry.subjectPath) };
568
+ }
569
+ function buildSnoozeRetention(request) {
570
+ if (request.ttl) {
571
+ return { retention: { mode: "ttl", ttl: request.ttl }, retainUntil: toIso(addTtl(now(), request.ttl)) };
572
+ }
573
+ const retainUntil = assertIsoDate(request.retainUntil, "--retain-until");
574
+ return { retention: { mode: "retain-until", retainUntil }, retainUntil };
575
+ }
576
+ function snapshotSubject(path) {
577
+ let stat;
578
+ try {
579
+ stat = lstatSync(path);
580
+ }
581
+ catch {
582
+ return { existence: "missing", nodeKind: null, byteSize: null, fingerprint: null };
583
+ }
584
+ if (stat.isSymbolicLink())
585
+ return { existence: "present", nodeKind: "other", byteSize: null, fingerprint: fingerprintSubject(path, "other") };
586
+ if (stat.isFile())
587
+ return { existence: "present", nodeKind: "file", byteSize: stat.size, fingerprint: fingerprintSubject(path, "file") };
588
+ if (stat.isDirectory())
589
+ return { existence: "present", nodeKind: "directory", byteSize: null, fingerprint: fingerprintSubject(path, "directory") };
590
+ return { existence: "present", nodeKind: "other", byteSize: null, fingerprint: fingerprintSubject(path, "other") };
591
+ }
592
+ function fingerprintSubject(path, nodeKind) {
593
+ try {
594
+ if (nodeKind === "file")
595
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
596
+ if (nodeKind === "directory")
597
+ return fingerprintDirectory(path);
598
+ return createHash("sha256").update(readlinkSync(path)).digest("hex");
599
+ }
600
+ catch {
601
+ return null;
602
+ }
603
+ }
604
+ function fingerprintDirectory(path) {
605
+ const hash = createHash("sha256");
606
+ hash.update("directory\0");
607
+ for (const name of readdirSync(path).sort()) {
608
+ const childPath = join(path, name);
609
+ const stat = lstatSync(childPath);
610
+ hash.update(name);
611
+ hash.update("\0");
612
+ if (stat.isFile()) {
613
+ hash.update("file\0");
614
+ hash.update(String(stat.size));
615
+ hash.update("\0");
616
+ hash.update(readFileSync(childPath));
617
+ continue;
618
+ }
619
+ if (stat.isDirectory()) {
620
+ hash.update("directory\0");
621
+ hash.update(fingerprintDirectory(childPath));
622
+ continue;
623
+ }
624
+ if (stat.isSymbolicLink()) {
625
+ hash.update("symlink\0");
626
+ hash.update(readlinkSync(childPath));
627
+ continue;
628
+ }
629
+ hash.update("other\0");
630
+ }
631
+ return hash.digest("hex");
632
+ }
633
+ function isTerminal(status) {
634
+ return status === "resolved" || status === "trashed";
635
+ }
636
+ function defaultReason(action) {
637
+ return `${action} via approved dispose plan`;
638
+ }
639
+ function ok(entry) {
640
+ return { ok: true, entry };
641
+ }
642
+ function blocked(reason, detail) {
643
+ return { ok: false, reason, detail };
644
+ }
645
+ // Reuse an earlier plan whose request fingerprint matches this one's so repeated dry-runs
646
+ // converge on a single stable plan id (mirrors cleanup/reconcile plan reuse). Volatile
647
+ // fields (generatedAt, the plan-id-scoped target, and the absolute retainUntil) are not
648
+ // fingerprinted, so the same logical request reuses its plan across clock ticks.
649
+ function matchingExistingDisposePlan(ledgerPath, plan) {
650
+ const plansDir = join(dirname(ledgerPath), "dispose-plans");
651
+ if (!existsSync(plansDir))
652
+ return null;
653
+ const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
654
+ for (const filename of filenames) {
655
+ const planPath = join(plansDir, filename);
656
+ try {
657
+ const candidate = JSON.parse(readFileSync(planPath, "utf8"));
658
+ if (candidate.ledgerPath !== ledgerPath)
659
+ continue;
660
+ if (completedDisposeReceiptExists(ledgerPath, candidate.planId))
661
+ continue;
662
+ if (disposePlanFingerprint(candidate) !== disposePlanFingerprint(plan))
663
+ continue;
664
+ return { ...candidate, planPath };
665
+ }
666
+ catch {
667
+ continue;
668
+ }
669
+ }
670
+ return null;
671
+ }
672
+ function disposePlanFingerprint(plan) {
673
+ if (!plan.entry)
674
+ return "";
675
+ return JSON.stringify({
676
+ id: plan.entry.id,
677
+ action: plan.entry.action,
678
+ status: plan.entry.status,
679
+ reason: plan.entry.reason,
680
+ path: plan.entry.path,
681
+ subjectPath: plan.entry.subjectPath,
682
+ subject: plan.entry.subject,
683
+ retention: plan.entry.retention ?? null,
684
+ retainUntil: plan.entry.retainUntil ?? null
685
+ });
686
+ }
687
+ function completedDisposeReceiptExists(ledgerPath, planId) {
688
+ const receiptPath = disposeReceiptPath(ledgerPath, planId);
689
+ return existsSync(receiptPath) && readCompletedDisposeReceipt(receiptPath, planId) !== null;
690
+ }
691
+ function writeDisposePlanFile(planPath, plan) {
692
+ mkdirSync(dirname(planPath), { recursive: true });
693
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
694
+ }
695
+ function makeDisposePlanId(date) {
696
+ return `dispose_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
697
+ }
698
+ function disposePlanPath(ledgerPath, planId) {
699
+ assertSafeGeneratedId(planId, "dispose plan id");
700
+ return join(dirname(ledgerPath), "dispose-plans", `${planId}.json`);
701
+ }
702
+ function disposeTrashTarget(ledgerPath, planId, id, subjectPath) {
703
+ assertSafeGeneratedId(planId, "dispose plan id");
704
+ assertSafeGeneratedId(id, "dispose record id");
705
+ return join(dirname(ledgerPath), "trash", planId, `${id}-${basename(subjectPath)}`);
706
+ }