artshelf 0.10.2 → 0.11.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 +25 -0
- package/README.md +3 -0
- package/SPEC.md +24 -3
- package/dist/src/ledger.js +224 -182
- package/dist/src/locks.js +73 -0
- package/dist/src/registry.js +3 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -79,6 +79,31 @@
|
|
|
79
79
|
cache, `ARTSHELF_NO_UPDATE_CHECK_TTL_MS` overrides the no-update/failed TTL
|
|
80
80
|
(falling back to `ARTSHELF_UPDATE_CHECK_TTL_MS` for compatibility), and a
|
|
81
81
|
non-numeric TTL value falls back to the default instead of disabling expiry.
|
|
82
|
+
- Made concurrent ledger and registry writes safe: ledger mutations now take the
|
|
83
|
+
same cross-process advisory lock as the registry (extracted into a shared
|
|
84
|
+
`withPathLock` helper in `src/locks.ts`), and ledger appends and rewrites commit
|
|
85
|
+
through a unique temp file and an atomic rename, so overlapping `put`,
|
|
86
|
+
`resolve`, and cleanup runs no longer drop records or leave a partially written
|
|
87
|
+
ledger.
|
|
88
|
+
- Hardened `cleanup --execute` to reject unsafe plan ids and bind the loaded plan
|
|
89
|
+
to the request before any filesystem mutation: the plan's `planId` must match
|
|
90
|
+
the requested id, its `ledgerPath` must match the executing ledger, and its
|
|
91
|
+
entries must be well-formed, so mismatched or malformed plans are refused before
|
|
92
|
+
moving files or writing a receipt — the plan-id-bound posture trash purge
|
|
93
|
+
already enforces.
|
|
94
|
+
|
|
95
|
+
## [0.11.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.2...artshelf-v0.11.0) (2026-06-14)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
### Features
|
|
99
|
+
|
|
100
|
+
* **ledger:** add cross-process advisory file lock and unique temp paths for atomic writes (NGX-428) ([0f553e4](https://github.com/calvinnwq/artshelf/commit/0f553e485737cf96390d451f4ae92f52e1abbf2a))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
### Bug Fixes
|
|
104
|
+
|
|
105
|
+
* **cleanup:** reject unsafe plan-ids and mismatched plans before filesystem mutation (NGX-426) ([79debb7](https://github.com/calvinnwq/artshelf/commit/79debb7c3610984a969adea7f93b27ca08150647))
|
|
106
|
+
* **ledger:** make ledger writes atomic and concurrency-safe and reject unsafe cleanup plans ([ac98c4e](https://github.com/calvinnwq/artshelf/commit/ac98c4eaf917b695e166f2ca7c40b6759d6e5f53))
|
|
82
107
|
|
|
83
108
|
## [0.10.2](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.1...artshelf-v0.10.2) (2026-06-13)
|
|
84
109
|
|
package/README.md
CHANGED
|
@@ -113,6 +113,9 @@ destructive deletion.
|
|
|
113
113
|
- **No fresh-plan-then-execute shortcut** — review the plan, then run that plan.
|
|
114
114
|
- **Trash before delete** — `cleanup=delete` stays refused; physical deletion
|
|
115
115
|
needs its own reviewed trash purge. No silent deletion, ever.
|
|
116
|
+
- **Durable, concurrency-safe writes** — ledger and registry mutations take a
|
|
117
|
+
cross-process lock and commit atomically, so overlapping commands never lose
|
|
118
|
+
records or leave a half-written ledger.
|
|
116
119
|
- **`--json` on every command**, so agents can act on structured output.
|
|
117
120
|
- **`--agent` on `review`/`status`/`doctor`**, a compact, token-efficient
|
|
118
121
|
decision packet for agents, while the default render stays human-scannable.
|
package/SPEC.md
CHANGED
|
@@ -423,8 +423,15 @@ artshelf cleanup --execute --plan-id <id> [--ledger <path>] --json
|
|
|
423
423
|
|
|
424
424
|
Rules:
|
|
425
425
|
|
|
426
|
-
- Requires `--plan-id
|
|
426
|
+
- Requires `--plan-id`, and refuses an unsafe plan id (anything outside
|
|
427
|
+
`[A-Za-z0-9_-]`, such as a value containing path separators or `..`) before
|
|
428
|
+
touching the filesystem.
|
|
427
429
|
- Refuses to generate a fresh live cleanup set during execute.
|
|
430
|
+
- Binds the loaded plan to the request before any mutation: the plan file's
|
|
431
|
+
`planId` must match the requested id, its `ledgerPath` must match the executing
|
|
432
|
+
ledger, and its entries must be well-formed. A mismatched or malformed plan is
|
|
433
|
+
refused without moving files or writing a receipt, mirroring the live-record
|
|
434
|
+
re-checks `trash purge --execute` performs.
|
|
428
435
|
- Writes a cleanup receipt and appends or refreshes an Artshelf-owned ledger record
|
|
429
436
|
for that receipt with `owner=artshelf`, `kind=run-artifact`, `ttl=30d`,
|
|
430
437
|
`cleanup=review`, and labels including `artshelf`, `cleanup-receipt`, and the
|
|
@@ -532,6 +539,16 @@ Default behavior:
|
|
|
532
539
|
- Otherwise write user-global.
|
|
533
540
|
- Allow `--ledger <path>` for explicit tests and unusual workflows.
|
|
534
541
|
|
|
542
|
+
Write durability:
|
|
543
|
+
|
|
544
|
+
- Every mutation of a ledger or the registry runs under a cross-process advisory
|
|
545
|
+
lock keyed on the target file, so overlapping `artshelf` processes serialize
|
|
546
|
+
their writes instead of racing. The lock is re-entrant within a process and
|
|
547
|
+
reclaims a stale lock left by a crashed holder.
|
|
548
|
+
- Ledger writes — both single-record appends and full rewrites — land through a
|
|
549
|
+
unique temp file and an atomic rename, so an interrupted write cannot truncate
|
|
550
|
+
the ledger or lose already-recorded entries.
|
|
551
|
+
|
|
535
552
|
V1 also supports a user-level registry of known ledgers:
|
|
536
553
|
|
|
537
554
|
- registry: `~/.artshelf/ledgers.json`
|
|
@@ -784,6 +801,8 @@ human review.
|
|
|
784
801
|
- CLI can find existing records by path/owner/label/status and get records by id.
|
|
785
802
|
- CLI can mark records manually resolved with a required reason.
|
|
786
803
|
- CLI validates ledger shape.
|
|
804
|
+
- Concurrent ledger and registry writes are serialized with a cross-process lock
|
|
805
|
+
and committed atomically, so overlapping commands do not lose records.
|
|
787
806
|
- CLI reports machine and registry health through `artshelf doctor`, exiting
|
|
788
807
|
non-zero when the registry or a registered ledger is broken.
|
|
789
808
|
- CLI reports a read-only daily dashboard through `artshelf status`, with
|
|
@@ -795,7 +814,8 @@ human review.
|
|
|
795
814
|
entries; no-op dry-runs do not write plan files.
|
|
796
815
|
- Cleanup dry-run and execute register the plan/receipt artifacts that Artshelf
|
|
797
816
|
creates.
|
|
798
|
-
- Cleanup execute refuses to run without a plan id
|
|
817
|
+
- Cleanup execute refuses to run without a plan id, and refuses an unsafe,
|
|
818
|
+
mismatched, or malformed plan before moving files or writing a receipt.
|
|
799
819
|
- Cleanup execute writes a receipt.
|
|
800
820
|
- CLI can list trashed records (single ledger or `--all`) and purge them through
|
|
801
821
|
an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
|
|
@@ -807,7 +827,8 @@ human review.
|
|
|
807
827
|
JSON decision packet for agents that takes precedence over `--json`.
|
|
808
828
|
- Tests cover record/list/find/get/status-filter/due/validate/resolve/registry,
|
|
809
829
|
`artshelf doctor`, the `artshelf status` dashboard, `--all` review, stale-registry,
|
|
810
|
-
dry-run, global-dry-run, execute-plan,
|
|
830
|
+
dry-run, global-dry-run, execute-plan, cleanup plan-id validation, concurrent
|
|
831
|
+
ledger writes, and trash list/purge behavior.
|
|
811
832
|
|
|
812
833
|
## Deferred
|
|
813
834
|
|
package/dist/src/ledger.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { withPathLock } from "./locks.js";
|
|
5
6
|
import { addTtl, assertIsoDate, ageOf, now, ttlToMs, toIso } from "./time.js";
|
|
6
7
|
const KINDS = new Set([
|
|
7
8
|
"scratch",
|
|
@@ -136,25 +137,27 @@ export function resolveRecord(ledgerPath, input) {
|
|
|
136
137
|
if (!input.reason || input.reason.trim().length === 0)
|
|
137
138
|
throw new Error("Missing required --reason");
|
|
138
139
|
const status = assertResolveStatus(input.status);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
140
|
+
return withLedgerLock(ledgerPath, () => {
|
|
141
|
+
const records = readLedger(ledgerPath);
|
|
142
|
+
const index = records.findIndex((record) => record.id === input.id);
|
|
143
|
+
if (index === -1)
|
|
144
|
+
throw new Error(`Artshelf record not found: ${input.id}`);
|
|
145
|
+
const current = records[index];
|
|
146
|
+
if (!current)
|
|
147
|
+
throw new Error(`Artshelf record not found: ${input.id}`);
|
|
148
|
+
if (current.status === "resolved") {
|
|
149
|
+
throw new Error(`Artshelf record is already resolved: ${input.id}`);
|
|
150
|
+
}
|
|
151
|
+
const updated = {
|
|
152
|
+
...current,
|
|
153
|
+
status,
|
|
154
|
+
resolvedAt: toIso(now()),
|
|
155
|
+
resolutionReason: input.reason.trim()
|
|
156
|
+
};
|
|
157
|
+
records[index] = updated;
|
|
158
|
+
writeLedger(ledgerPath, records);
|
|
159
|
+
return updated;
|
|
160
|
+
});
|
|
158
161
|
}
|
|
159
162
|
export function dueEntries(records, at = now()) {
|
|
160
163
|
return records.filter((record) => record.status === "active").map((record) => {
|
|
@@ -305,115 +308,117 @@ export function executeTrashPurgePlan(ledgerPath, purgePlanId) {
|
|
|
305
308
|
if (!existsSync(planPath))
|
|
306
309
|
throw new Error(`Trash purge plan not found: ${purgePlanId}`);
|
|
307
310
|
const receiptPath = trashPurgeReceiptPath(ledgerPath, purgePlanId);
|
|
308
|
-
const existingReceipt = existsSync(receiptPath) ? readTrashPurgeReceipt(receiptPath) : null;
|
|
309
|
-
if (existingReceipt?.completedAt)
|
|
310
|
-
throw new Error(`Trash purge receipt already exists: ${purgePlanId}`);
|
|
311
311
|
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (record.targetPath !== entry.targetPath ||
|
|
332
|
-
record.cleanedAt !== entry.cleanedAt ||
|
|
333
|
-
record.receiptPath !== entry.receiptPath ||
|
|
334
|
-
record.cleanupPlanId !== entry.cleanupPlanId) {
|
|
335
|
-
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "plan entry no longer matches ledger record" });
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const targetPath = resolve(entry.targetPath);
|
|
339
|
-
const expectedPlanTrashRoot = resolve(trashRoot, record.cleanupPlanId);
|
|
340
|
-
if (!isPathWithin(trashRoot, targetPath)) {
|
|
341
|
-
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is outside Artshelf trash" });
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
if (!isStrictPathWithin(expectedPlanTrashRoot, targetPath)) {
|
|
345
|
-
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is not a trashed artifact path" });
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (!pathExistsForPurge(entry.targetPath)) {
|
|
349
|
-
if (existingResult?.status === "deleting") {
|
|
350
|
-
results = upsertTrashPurgeResult(results, { id: entry.id, status: "purged", targetPath });
|
|
312
|
+
return withLedgerLock(ledgerPath, () => {
|
|
313
|
+
const existingReceipt = existsSync(receiptPath) ? readTrashPurgeReceipt(receiptPath) : null;
|
|
314
|
+
if (existingReceipt?.completedAt)
|
|
315
|
+
throw new Error(`Trash purge receipt already exists: ${purgePlanId}`);
|
|
316
|
+
const records = readLedger(ledgerPath);
|
|
317
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
318
|
+
const trashRoot = resolve(dirname(ledgerPath), "trash");
|
|
319
|
+
const executedAt = existingReceipt?.executedAt ?? toIso(now());
|
|
320
|
+
let results = existingReceipt?.results ?? [];
|
|
321
|
+
const candidates = [];
|
|
322
|
+
for (const entry of plan.entries) {
|
|
323
|
+
const existingResult = results.find((result) => result.id === entry.id);
|
|
324
|
+
if (existingResult && ["failed", "purged", "skipped"].includes(existingResult.status))
|
|
325
|
+
continue;
|
|
326
|
+
const record = recordsById.get(entry.id);
|
|
327
|
+
if (!record) {
|
|
328
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "record is missing from ledger" });
|
|
351
329
|
continue;
|
|
352
330
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
if (resolvesOutsideLedgerTrash(dirname(ledgerPath), trashRoot, expectedPlanTrashRoot, targetPath)) {
|
|
358
|
-
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target resolves outside Artshelf trash" });
|
|
331
|
+
if (record.status !== "trashed") {
|
|
332
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `record is ${record.status}` });
|
|
359
333
|
continue;
|
|
360
334
|
}
|
|
335
|
+
if (record.targetPath !== entry.targetPath ||
|
|
336
|
+
record.cleanedAt !== entry.cleanedAt ||
|
|
337
|
+
record.receiptPath !== entry.receiptPath ||
|
|
338
|
+
record.cleanupPlanId !== entry.cleanupPlanId) {
|
|
339
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "plan entry no longer matches ledger record" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const targetPath = resolve(entry.targetPath);
|
|
343
|
+
const expectedPlanTrashRoot = resolve(trashRoot, record.cleanupPlanId);
|
|
344
|
+
if (!isPathWithin(trashRoot, targetPath)) {
|
|
345
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is outside Artshelf trash" });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!isStrictPathWithin(expectedPlanTrashRoot, targetPath)) {
|
|
349
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is not a trashed artifact path" });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!pathExistsForPurge(entry.targetPath)) {
|
|
353
|
+
if (existingResult?.status === "deleting") {
|
|
354
|
+
results = upsertTrashPurgeResult(results, { id: entry.id, status: "purged", targetPath });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is missing" });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
if (resolvesOutsideLedgerTrash(dirname(ledgerPath), trashRoot, expectedPlanTrashRoot, targetPath)) {
|
|
362
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target resolves outside Artshelf trash" });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
368
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `target cannot be validated: ${reason}` });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
candidates.push({ id: entry.id, targetPath });
|
|
361
372
|
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
364
|
-
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `target cannot be validated: ${reason}` });
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
candidates.push({ id: entry.id, targetPath });
|
|
368
|
-
}
|
|
369
|
-
writeTrashPurgeReceipt(receiptPath, {
|
|
370
|
-
purgePlanId,
|
|
371
|
-
executedAt,
|
|
372
|
-
status: "started",
|
|
373
|
-
results: [
|
|
374
|
-
...results,
|
|
375
|
-
...candidates.map((candidate) => ({ id: candidate.id, status: "pending", targetPath: candidate.targetPath }))
|
|
376
|
-
]
|
|
377
|
-
});
|
|
378
|
-
for (const candidate of candidates) {
|
|
379
|
-
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "deleting", targetPath: candidate.targetPath });
|
|
380
373
|
writeTrashPurgeReceipt(receiptPath, {
|
|
381
374
|
purgePlanId,
|
|
382
375
|
executedAt,
|
|
383
376
|
status: "started",
|
|
384
377
|
results: [
|
|
385
378
|
...results,
|
|
386
|
-
...
|
|
379
|
+
...candidates.map((candidate) => ({ id: candidate.id, status: "pending", targetPath: candidate.targetPath }))
|
|
387
380
|
]
|
|
388
381
|
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
382
|
+
for (const candidate of candidates) {
|
|
383
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "deleting", targetPath: candidate.targetPath });
|
|
384
|
+
writeTrashPurgeReceipt(receiptPath, {
|
|
385
|
+
purgePlanId,
|
|
386
|
+
executedAt,
|
|
387
|
+
status: "started",
|
|
388
|
+
results: [
|
|
389
|
+
...results,
|
|
390
|
+
...pendingTrashPurgeResults(candidates, results)
|
|
391
|
+
]
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
rmSync(candidate.targetPath, { recursive: true, force: true });
|
|
395
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "purged", targetPath: candidate.targetPath });
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
399
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "failed", targetPath: candidate.targetPath, reason });
|
|
400
|
+
}
|
|
401
|
+
writeTrashPurgeReceipt(receiptPath, {
|
|
402
|
+
purgePlanId,
|
|
403
|
+
executedAt,
|
|
404
|
+
status: "started",
|
|
405
|
+
results: [
|
|
406
|
+
...results,
|
|
407
|
+
...pendingTrashPurgeResults(candidates, results)
|
|
408
|
+
]
|
|
409
|
+
});
|
|
396
410
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
]
|
|
411
|
+
updateLedgerAfterTrashPurge(ledgerPath, records, { purgePlanId, receiptPath, executedAt, results });
|
|
412
|
+
writeTrashPurgeReceipt(receiptPath, { purgePlanId, executedAt, completedAt: toIso(now()), results });
|
|
413
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
414
|
+
reason: `Artshelf trash purge receipt for plan ${purgePlanId}`,
|
|
415
|
+
ttl: "30d",
|
|
416
|
+
kind: "run-artifact",
|
|
417
|
+
cleanup: "review",
|
|
418
|
+
labels: ["artshelf", "trash-purge-receipt", purgePlanId]
|
|
405
419
|
});
|
|
406
|
-
|
|
407
|
-
updateLedgerAfterTrashPurge(ledgerPath, records, { purgePlanId, receiptPath, executedAt, results });
|
|
408
|
-
writeTrashPurgeReceipt(receiptPath, { purgePlanId, executedAt, completedAt: toIso(now()), results });
|
|
409
|
-
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
410
|
-
reason: `Artshelf trash purge receipt for plan ${purgePlanId}`,
|
|
411
|
-
ttl: "30d",
|
|
412
|
-
kind: "run-artifact",
|
|
413
|
-
cleanup: "review",
|
|
414
|
-
labels: ["artshelf", "trash-purge-receipt", purgePlanId]
|
|
420
|
+
return { purgePlanId, receiptPath, results };
|
|
415
421
|
});
|
|
416
|
-
return { purgePlanId, receiptPath, results };
|
|
417
422
|
}
|
|
418
423
|
function readTrashPurgeReceipt(receiptPath) {
|
|
419
424
|
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
@@ -537,49 +542,52 @@ export function executeCleanupPlan(ledgerPath, planId) {
|
|
|
537
542
|
if (!existsSync(planPath))
|
|
538
543
|
throw new Error(`Cleanup plan not found: ${planId}`);
|
|
539
544
|
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
545
|
+
assertCleanupPlanExecutable(plan, planId, ledgerPath);
|
|
540
546
|
const trashRoot = join(dirname(ledgerPath), "trash", planId);
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
547
|
+
return withLedgerLock(ledgerPath, () => {
|
|
548
|
+
const records = readLedger(ledgerPath);
|
|
549
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
550
|
+
const results = [];
|
|
551
|
+
for (const entry of plan.entries) {
|
|
552
|
+
const record = recordsById.get(entry.id);
|
|
553
|
+
if (!record) {
|
|
554
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (record.status !== "active") {
|
|
558
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!existsSync(entry.path)) {
|
|
562
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (entry.action === "delete") {
|
|
566
|
+
results.push({ id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1" });
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (entry.action === "review") {
|
|
570
|
+
results.push({ id: entry.id, action: entry.action, status: "review-required", path: entry.path });
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
mkdirSync(trashRoot, { recursive: true });
|
|
574
|
+
const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
|
|
575
|
+
renameSync(entry.path, target);
|
|
576
|
+
results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
|
|
577
|
+
}
|
|
578
|
+
const receiptPath = receiptPathFor(ledgerPath, planId);
|
|
579
|
+
const executedAt = toIso(now());
|
|
580
|
+
writeJson(receiptPath, { planId, executedAt, results });
|
|
581
|
+
updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
|
|
582
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
583
|
+
reason: `Artshelf cleanup receipt for plan ${planId}`,
|
|
584
|
+
ttl: "30d",
|
|
585
|
+
kind: "run-artifact",
|
|
586
|
+
cleanup: "review",
|
|
587
|
+
labels: ["artshelf", "cleanup-receipt", planId]
|
|
588
|
+
});
|
|
589
|
+
return { planId, receiptPath, results };
|
|
581
590
|
});
|
|
582
|
-
return { planId, receiptPath, results };
|
|
583
591
|
}
|
|
584
592
|
function registerArtshelfArtifact(ledgerPath, path, input) {
|
|
585
593
|
const prepared = prepareRecord({
|
|
@@ -591,29 +599,31 @@ function registerArtshelfArtifact(ledgerPath, path, input) {
|
|
|
591
599
|
owner: "artshelf",
|
|
592
600
|
labels: input.labels
|
|
593
601
|
});
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
record
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
602
|
+
withLedgerLock(ledgerPath, () => {
|
|
603
|
+
const records = readLedger(ledgerPath);
|
|
604
|
+
const index = records.findIndex((record) => (isMatchingArtshelfArtifact(record, path, input.labels) &&
|
|
605
|
+
record.status === "active" &&
|
|
606
|
+
record.path === path));
|
|
607
|
+
if (index === -1) {
|
|
608
|
+
appendPreparedRecord(ledgerPath, prepared);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const current = records[index];
|
|
612
|
+
if (!current)
|
|
613
|
+
return;
|
|
614
|
+
records[index] = {
|
|
615
|
+
...current,
|
|
616
|
+
reason: prepared.reason,
|
|
617
|
+
createdAt: prepared.createdAt,
|
|
618
|
+
...(prepared.retainUntil ? { retainUntil: prepared.retainUntil } : {}),
|
|
619
|
+
retention: prepared.retention,
|
|
620
|
+
kind: prepared.kind,
|
|
621
|
+
cleanup: prepared.cleanup,
|
|
622
|
+
owner: prepared.owner,
|
|
623
|
+
labels: prepared.labels
|
|
624
|
+
};
|
|
625
|
+
writeLedger(ledgerPath, records);
|
|
626
|
+
});
|
|
617
627
|
}
|
|
618
628
|
function isMatchingArtshelfArtifact(record, path, labels) {
|
|
619
629
|
if (record.path !== path)
|
|
@@ -659,16 +669,26 @@ function cleanupPlanEntriesFingerprint(plan) {
|
|
|
659
669
|
dueStatus: entry.dueStatus
|
|
660
670
|
})));
|
|
661
671
|
}
|
|
672
|
+
function withLedgerLock(ledgerPath, fn) {
|
|
673
|
+
return withPathLock(ledgerPath, fn, "Artshelf ledger");
|
|
674
|
+
}
|
|
675
|
+
function atomicWriteFileSync(targetPath, content) {
|
|
676
|
+
const tmpPath = `${targetPath}.${Date.now().toString(36)}-${randomBytes(4).toString("hex")}.tmp`;
|
|
677
|
+
writeFileSync(tmpPath, content);
|
|
678
|
+
renameSync(tmpPath, targetPath);
|
|
679
|
+
}
|
|
662
680
|
function appendRecord(ledgerPath, record) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
681
|
+
withLedgerLock(ledgerPath, () => {
|
|
682
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
683
|
+
const previous = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : "";
|
|
684
|
+
atomicWriteFileSync(ledgerPath, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${JSON.stringify(record)}\n`);
|
|
685
|
+
});
|
|
666
686
|
}
|
|
667
687
|
function writeLedger(ledgerPath, records) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
688
|
+
withLedgerLock(ledgerPath, () => {
|
|
689
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
690
|
+
atomicWriteFileSync(ledgerPath, records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
|
|
691
|
+
});
|
|
672
692
|
}
|
|
673
693
|
function updateLedgerAfterCleanup(ledgerPath, records, receipt) {
|
|
674
694
|
const resultById = new Map(receipt.results.map((result) => [result.id, result]));
|
|
@@ -802,6 +822,7 @@ function makePurgePlanId(date) {
|
|
|
802
822
|
return `purge_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
|
|
803
823
|
}
|
|
804
824
|
function cleanupPlanPath(ledgerPath, planId) {
|
|
825
|
+
assertSafeGeneratedId(planId, "cleanup plan id");
|
|
805
826
|
return join(dirname(ledgerPath), "plans", `${planId}.json`);
|
|
806
827
|
}
|
|
807
828
|
function trashPurgePlanPath(ledgerPath, purgePlanId) {
|
|
@@ -809,6 +830,7 @@ function trashPurgePlanPath(ledgerPath, purgePlanId) {
|
|
|
809
830
|
return join(dirname(ledgerPath), "purge-plans", `${purgePlanId}.json`);
|
|
810
831
|
}
|
|
811
832
|
function receiptPathFor(ledgerPath, planId) {
|
|
833
|
+
assertSafeGeneratedId(planId, "cleanup plan id");
|
|
812
834
|
return join(dirname(ledgerPath), "receipts", `${planId}.json`);
|
|
813
835
|
}
|
|
814
836
|
function trashPurgeReceiptPath(ledgerPath, purgePlanId) {
|
|
@@ -850,6 +872,26 @@ function assertSafeGeneratedId(value, label) {
|
|
|
850
872
|
throw new Error(`Invalid ${label}: ${value}`);
|
|
851
873
|
}
|
|
852
874
|
}
|
|
875
|
+
// Bind a loaded cleanup plan to the request before any filesystem mutation: the
|
|
876
|
+
// plan must declare the requested id, belong to the executing ledger, and carry
|
|
877
|
+
// executable-looking entries. This keeps `cleanup --execute` plan-id bound, the
|
|
878
|
+
// same posture trash purge already enforces against ledger record metadata.
|
|
879
|
+
function assertCleanupPlanExecutable(plan, planId, ledgerPath) {
|
|
880
|
+
if (plan.planId !== planId) {
|
|
881
|
+
throw new Error(`Cleanup plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
|
|
882
|
+
}
|
|
883
|
+
if (plan.ledgerPath !== ledgerPath) {
|
|
884
|
+
throw new Error(`Cleanup plan ledger mismatch: plan was created for ${plan.ledgerPath}, executing ${ledgerPath}`);
|
|
885
|
+
}
|
|
886
|
+
if (!Array.isArray(plan.entries)) {
|
|
887
|
+
throw new Error(`Cleanup plan entries are malformed: ${planId}`);
|
|
888
|
+
}
|
|
889
|
+
for (const entry of plan.entries) {
|
|
890
|
+
if (!entry || typeof entry.id !== "string" || typeof entry.path !== "string" || !CLEANUP_ACTIONS.has(entry.action)) {
|
|
891
|
+
throw new Error(`Cleanup plan entries are malformed: ${planId}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
853
895
|
function writeJson(path, value) {
|
|
854
896
|
mkdirSync(dirname(path), { recursive: true });
|
|
855
897
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
const heldLocks = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Run `fn` while holding a cross-process advisory lock for `targetPath`.
|
|
6
|
+
*
|
|
7
|
+
* The lock is a sibling `${targetPath}.lock` directory; `mkdir` is atomic across
|
|
8
|
+
* processes, so only one holder proceeds at a time. A stale lock is reclaimed
|
|
9
|
+
* after `staleAfterMs`, and acquisition gives up after the deadline so a crashed
|
|
10
|
+
* holder cannot block forever.
|
|
11
|
+
*
|
|
12
|
+
* Locks are re-entrant within a single process: nested calls for the same path
|
|
13
|
+
* reuse the existing lock instead of deadlocking on the directory they created.
|
|
14
|
+
*/
|
|
15
|
+
export function withPathLock(targetPath, fn, label = "Artshelf") {
|
|
16
|
+
const key = resolve(targetPath);
|
|
17
|
+
const depth = heldLocks.get(key) ?? 0;
|
|
18
|
+
if (depth > 0) {
|
|
19
|
+
heldLocks.set(key, depth + 1);
|
|
20
|
+
try {
|
|
21
|
+
return fn();
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
const next = (heldLocks.get(key) ?? 1) - 1;
|
|
25
|
+
if (next > 0)
|
|
26
|
+
heldLocks.set(key, next);
|
|
27
|
+
else
|
|
28
|
+
heldLocks.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(dirname(key), { recursive: true });
|
|
32
|
+
const lockPath = `${key}.lock`;
|
|
33
|
+
const deadline = Date.now() + 5000;
|
|
34
|
+
const staleAfterMs = 30_000;
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(lockPath);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "EEXIST")
|
|
42
|
+
throw error;
|
|
43
|
+
if (isStaleLock(lockPath, staleAfterMs)) {
|
|
44
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (Date.now() > deadline)
|
|
48
|
+
throw new Error(`Timed out waiting for ${label} lock: ${key}`);
|
|
49
|
+
sleep(25);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
heldLocks.set(key, 1);
|
|
53
|
+
try {
|
|
54
|
+
return fn();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
heldLocks.delete(key);
|
|
58
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function sleep(ms) {
|
|
62
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
63
|
+
}
|
|
64
|
+
function isStaleLock(lockPath, staleAfterMs) {
|
|
65
|
+
try {
|
|
66
|
+
return Date.now() - statSync(lockPath).mtimeMs > staleAfterMs;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code === "ENOENT")
|
|
70
|
+
return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/src/registry.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { withPathLock } from "./locks.js";
|
|
4
5
|
import { now, toIso } from "./time.js";
|
|
5
6
|
export function defaultRegistryPath() {
|
|
6
7
|
return process.env.ARTSHELF_REGISTRY ?? process.env.SHELF_REGISTRY ?? join(homedir(), ".artshelf", "ledgers.json");
|
|
@@ -57,46 +58,7 @@ function writeRegistry(registryPath, registry) {
|
|
|
57
58
|
renameSync(tmpPath, registryPath);
|
|
58
59
|
}
|
|
59
60
|
function withRegistryLock(registryPath, fn) {
|
|
60
|
-
|
|
61
|
-
const lockPath = `${registryPath}.lock`;
|
|
62
|
-
const deadline = Date.now() + 5000;
|
|
63
|
-
const staleAfterMs = 30_000;
|
|
64
|
-
while (true) {
|
|
65
|
-
try {
|
|
66
|
-
mkdirSync(lockPath);
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
if (error.code !== "EEXIST")
|
|
71
|
-
throw error;
|
|
72
|
-
if (isStaleLock(lockPath, staleAfterMs)) {
|
|
73
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
if (Date.now() > deadline)
|
|
77
|
-
throw new Error(`Timed out waiting for Artshelf ledger registry lock: ${registryPath}`);
|
|
78
|
-
sleep(25);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
try {
|
|
82
|
-
return fn();
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
function sleep(ms) {
|
|
89
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
90
|
-
}
|
|
91
|
-
function isStaleLock(lockPath, staleAfterMs) {
|
|
92
|
-
try {
|
|
93
|
-
return Date.now() - statSync(lockPath).mtimeMs > staleAfterMs;
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
if (error.code === "ENOENT")
|
|
97
|
-
return false;
|
|
98
|
-
throw error;
|
|
99
|
-
}
|
|
61
|
+
return withPathLock(registryPath, fn, "Artshelf ledger registry");
|
|
100
62
|
}
|
|
101
63
|
function normalizeEntry(entry) {
|
|
102
64
|
if (!entry.name || !entry.path || !entry.scope || !entry.createdAt || !entry.updatedAt) {
|