artshelf 0.14.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.
- package/CHANGELOG.md +23 -0
- package/README.md +16 -10
- package/SPEC.md +152 -27
- package/dist/src/cli.js +2 -2
- package/dist/src/commands/dispose.js +88 -0
- package/dist/src/commands/get.js +24 -2
- package/dist/src/commands/index.js +4 -0
- package/dist/src/dispose.js +706 -0
- package/dist/src/inspect.js +207 -0
- package/dist/src/ledger.js +33 -22
- package/dist/src/renderers/inspect.js +108 -0
- package/dist/src/shared/flags.js +6 -0
- package/dist/src/shared/help-text.js +40 -0
- package/dist/src/shared/shell-quote.js +6 -0
- package/docs/agent-clean.html +30 -5
- package/docs/agent-monitor.html +2 -1
- package/docs/agent-review.html +37 -0
- package/docs/agent-usage.html +5 -3
- package/docs/agent-usage.md +12 -9
- package/docs/index.html +4 -0
- package/docs/install.html +2 -2
- package/docs/reference.html +43 -6
- package/package.json +1 -1
- package/skills/artshelf/SKILL.md +32 -40
|
@@ -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
|
+
}
|