artshelf 0.10.2 → 0.12.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 +57 -0
- package/README.md +5 -0
- package/SPEC.md +169 -3
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/put.js +1 -1
- package/dist/src/commands/reconcile.js +48 -0
- package/dist/src/commands/shared.js +17 -0
- package/dist/src/ledger.js +245 -188
- package/dist/src/locks.js +73 -0
- package/dist/src/provenance.js +142 -0
- package/dist/src/reconcile.js +332 -0
- package/dist/src/registry.js +3 -41
- package/dist/src/shared/help-text.js +26 -0
- package/docs/reference.html +26 -2
- package/package.json +1 -1
package/dist/src/ledger.js
CHANGED
|
@@ -2,6 +2,8 @@ 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";
|
|
6
|
+
import { computeProvenance, validateProvenance } from "./provenance.js";
|
|
5
7
|
import { addTtl, assertIsoDate, ageOf, now, ttlToMs, toIso } from "./time.js";
|
|
6
8
|
const KINDS = new Set([
|
|
7
9
|
"scratch",
|
|
@@ -25,11 +27,11 @@ export function normalizeLedgerPath(path) {
|
|
|
25
27
|
return resolve(path ?? defaultLedgerPath());
|
|
26
28
|
}
|
|
27
29
|
export function putRecord(ledgerPath, input) {
|
|
28
|
-
const record = prepareRecord(input);
|
|
30
|
+
const record = prepareRecord(input, ledgerPath);
|
|
29
31
|
appendPreparedRecord(ledgerPath, record);
|
|
30
32
|
return record;
|
|
31
33
|
}
|
|
32
|
-
export function prepareRecord(input) {
|
|
34
|
+
export function prepareRecord(input, ledgerPath) {
|
|
33
35
|
const artifactPath = resolve(input.path);
|
|
34
36
|
if (!existsSync(artifactPath)) {
|
|
35
37
|
throw new Error(`Path does not exist: ${input.path}`);
|
|
@@ -56,7 +58,8 @@ export function prepareRecord(input) {
|
|
|
56
58
|
cleanup,
|
|
57
59
|
owner: input.owner ?? "manual",
|
|
58
60
|
labels: input.labels,
|
|
59
|
-
status: "active"
|
|
61
|
+
status: "active",
|
|
62
|
+
provenance: computeProvenance(artifactPath, { ledgerPath })
|
|
60
63
|
};
|
|
61
64
|
return record;
|
|
62
65
|
}
|
|
@@ -136,25 +139,27 @@ export function resolveRecord(ledgerPath, input) {
|
|
|
136
139
|
if (!input.reason || input.reason.trim().length === 0)
|
|
137
140
|
throw new Error("Missing required --reason");
|
|
138
141
|
const status = assertResolveStatus(input.status);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
return withLedgerLock(ledgerPath, () => {
|
|
143
|
+
const records = readLedger(ledgerPath);
|
|
144
|
+
const index = records.findIndex((record) => record.id === input.id);
|
|
145
|
+
if (index === -1)
|
|
146
|
+
throw new Error(`Artshelf record not found: ${input.id}`);
|
|
147
|
+
const current = records[index];
|
|
148
|
+
if (!current)
|
|
149
|
+
throw new Error(`Artshelf record not found: ${input.id}`);
|
|
150
|
+
if (current.status === "resolved") {
|
|
151
|
+
throw new Error(`Artshelf record is already resolved: ${input.id}`);
|
|
152
|
+
}
|
|
153
|
+
const updated = {
|
|
154
|
+
...current,
|
|
155
|
+
status,
|
|
156
|
+
resolvedAt: toIso(now()),
|
|
157
|
+
resolutionReason: input.reason.trim()
|
|
158
|
+
};
|
|
159
|
+
records[index] = updated;
|
|
160
|
+
writeLedger(ledgerPath, records);
|
|
161
|
+
return updated;
|
|
162
|
+
});
|
|
158
163
|
}
|
|
159
164
|
export function dueEntries(records, at = now()) {
|
|
160
165
|
return records.filter((record) => record.status === "active").map((record) => {
|
|
@@ -236,6 +241,13 @@ export function validateLedger(ledgerPath) {
|
|
|
236
241
|
if (!record.resolutionReason)
|
|
237
242
|
errors.push(`${label}: resolved record missing resolutionReason`);
|
|
238
243
|
}
|
|
244
|
+
// Legacy rows simply omit provenance and are left alone; once a row carries
|
|
245
|
+
// provenance it must be well-formed so future reconcile can trust it.
|
|
246
|
+
if ("provenance" in record) {
|
|
247
|
+
for (const problem of validateProvenance(record.provenance)) {
|
|
248
|
+
errors.push(`${label}: ${problem}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
239
251
|
}
|
|
240
252
|
return { ok: errors.length === 0, errors, warnings, entries: records.length };
|
|
241
253
|
}
|
|
@@ -305,115 +317,117 @@ export function executeTrashPurgePlan(ledgerPath, purgePlanId) {
|
|
|
305
317
|
if (!existsSync(planPath))
|
|
306
318
|
throw new Error(`Trash purge plan not found: ${purgePlanId}`);
|
|
307
319
|
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
320
|
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 });
|
|
321
|
+
return withLedgerLock(ledgerPath, () => {
|
|
322
|
+
const existingReceipt = existsSync(receiptPath) ? readTrashPurgeReceipt(receiptPath) : null;
|
|
323
|
+
if (existingReceipt?.completedAt)
|
|
324
|
+
throw new Error(`Trash purge receipt already exists: ${purgePlanId}`);
|
|
325
|
+
const records = readLedger(ledgerPath);
|
|
326
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
327
|
+
const trashRoot = resolve(dirname(ledgerPath), "trash");
|
|
328
|
+
const executedAt = existingReceipt?.executedAt ?? toIso(now());
|
|
329
|
+
let results = existingReceipt?.results ?? [];
|
|
330
|
+
const candidates = [];
|
|
331
|
+
for (const entry of plan.entries) {
|
|
332
|
+
const existingResult = results.find((result) => result.id === entry.id);
|
|
333
|
+
if (existingResult && ["failed", "purged", "skipped"].includes(existingResult.status))
|
|
334
|
+
continue;
|
|
335
|
+
const record = recordsById.get(entry.id);
|
|
336
|
+
if (!record) {
|
|
337
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "record is missing from ledger" });
|
|
351
338
|
continue;
|
|
352
339
|
}
|
|
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" });
|
|
340
|
+
if (record.status !== "trashed") {
|
|
341
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `record is ${record.status}` });
|
|
359
342
|
continue;
|
|
360
343
|
}
|
|
344
|
+
if (record.targetPath !== entry.targetPath ||
|
|
345
|
+
record.cleanedAt !== entry.cleanedAt ||
|
|
346
|
+
record.receiptPath !== entry.receiptPath ||
|
|
347
|
+
record.cleanupPlanId !== entry.cleanupPlanId) {
|
|
348
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "plan entry no longer matches ledger record" });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const targetPath = resolve(entry.targetPath);
|
|
352
|
+
const expectedPlanTrashRoot = resolve(trashRoot, record.cleanupPlanId);
|
|
353
|
+
if (!isPathWithin(trashRoot, targetPath)) {
|
|
354
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is outside Artshelf trash" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!isStrictPathWithin(expectedPlanTrashRoot, targetPath)) {
|
|
358
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is not a trashed artifact path" });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!pathExistsForPurge(entry.targetPath)) {
|
|
362
|
+
if (existingResult?.status === "deleting") {
|
|
363
|
+
results = upsertTrashPurgeResult(results, { id: entry.id, status: "purged", targetPath });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is missing" });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
if (resolvesOutsideLedgerTrash(dirname(ledgerPath), trashRoot, expectedPlanTrashRoot, targetPath)) {
|
|
371
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target resolves outside Artshelf trash" });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
377
|
+
results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `target cannot be validated: ${reason}` });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
candidates.push({ id: entry.id, targetPath });
|
|
361
381
|
}
|
|
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
382
|
writeTrashPurgeReceipt(receiptPath, {
|
|
381
383
|
purgePlanId,
|
|
382
384
|
executedAt,
|
|
383
385
|
status: "started",
|
|
384
386
|
results: [
|
|
385
387
|
...results,
|
|
386
|
-
...
|
|
388
|
+
...candidates.map((candidate) => ({ id: candidate.id, status: "pending", targetPath: candidate.targetPath }))
|
|
387
389
|
]
|
|
388
390
|
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
391
|
+
for (const candidate of candidates) {
|
|
392
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "deleting", targetPath: candidate.targetPath });
|
|
393
|
+
writeTrashPurgeReceipt(receiptPath, {
|
|
394
|
+
purgePlanId,
|
|
395
|
+
executedAt,
|
|
396
|
+
status: "started",
|
|
397
|
+
results: [
|
|
398
|
+
...results,
|
|
399
|
+
...pendingTrashPurgeResults(candidates, results)
|
|
400
|
+
]
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
rmSync(candidate.targetPath, { recursive: true, force: true });
|
|
404
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "purged", targetPath: candidate.targetPath });
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
408
|
+
results = upsertTrashPurgeResult(results, { id: candidate.id, status: "failed", targetPath: candidate.targetPath, reason });
|
|
409
|
+
}
|
|
410
|
+
writeTrashPurgeReceipt(receiptPath, {
|
|
411
|
+
purgePlanId,
|
|
412
|
+
executedAt,
|
|
413
|
+
status: "started",
|
|
414
|
+
results: [
|
|
415
|
+
...results,
|
|
416
|
+
...pendingTrashPurgeResults(candidates, results)
|
|
417
|
+
]
|
|
418
|
+
});
|
|
396
419
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
]
|
|
420
|
+
updateLedgerAfterTrashPurge(ledgerPath, records, { purgePlanId, receiptPath, executedAt, results });
|
|
421
|
+
writeTrashPurgeReceipt(receiptPath, { purgePlanId, executedAt, completedAt: toIso(now()), results });
|
|
422
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
423
|
+
reason: `Artshelf trash purge receipt for plan ${purgePlanId}`,
|
|
424
|
+
ttl: "30d",
|
|
425
|
+
kind: "run-artifact",
|
|
426
|
+
cleanup: "review",
|
|
427
|
+
labels: ["artshelf", "trash-purge-receipt", purgePlanId]
|
|
405
428
|
});
|
|
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]
|
|
429
|
+
return { purgePlanId, receiptPath, results };
|
|
415
430
|
});
|
|
416
|
-
return { purgePlanId, receiptPath, results };
|
|
417
431
|
}
|
|
418
432
|
function readTrashPurgeReceipt(receiptPath) {
|
|
419
433
|
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
@@ -537,51 +551,57 @@ export function executeCleanupPlan(ledgerPath, planId) {
|
|
|
537
551
|
if (!existsSync(planPath))
|
|
538
552
|
throw new Error(`Cleanup plan not found: ${planId}`);
|
|
539
553
|
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
554
|
+
assertCleanupPlanExecutable(plan, planId, ledgerPath);
|
|
540
555
|
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
|
-
|
|
556
|
+
return withLedgerLock(ledgerPath, () => {
|
|
557
|
+
const records = readLedger(ledgerPath);
|
|
558
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
559
|
+
const results = [];
|
|
560
|
+
for (const entry of plan.entries) {
|
|
561
|
+
const record = recordsById.get(entry.id);
|
|
562
|
+
if (!record) {
|
|
563
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (record.status !== "active") {
|
|
567
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (!existsSync(entry.path)) {
|
|
571
|
+
results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (entry.action === "delete") {
|
|
575
|
+
results.push({ id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1" });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (entry.action === "review") {
|
|
579
|
+
results.push({ id: entry.id, action: entry.action, status: "review-required", path: entry.path });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
mkdirSync(trashRoot, { recursive: true });
|
|
583
|
+
const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
|
|
584
|
+
renameSync(entry.path, target);
|
|
585
|
+
results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
|
|
586
|
+
}
|
|
587
|
+
const receiptPath = receiptPathFor(ledgerPath, planId);
|
|
588
|
+
const executedAt = toIso(now());
|
|
589
|
+
writeJson(receiptPath, { planId, executedAt, results });
|
|
590
|
+
updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
|
|
591
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
592
|
+
reason: `Artshelf cleanup receipt for plan ${planId}`,
|
|
593
|
+
ttl: "30d",
|
|
594
|
+
kind: "run-artifact",
|
|
595
|
+
cleanup: "review",
|
|
596
|
+
labels: ["artshelf", "cleanup-receipt", planId]
|
|
597
|
+
});
|
|
598
|
+
return { planId, receiptPath, results };
|
|
581
599
|
});
|
|
582
|
-
return { planId, receiptPath, results };
|
|
583
600
|
}
|
|
584
|
-
|
|
601
|
+
// Exported so the reconcile plan layer (src/reconcile.ts) registers its dry-run plan
|
|
602
|
+
// artifacts through the same upsert-by-path-and-labels path that cleanup plans use,
|
|
603
|
+
// keeping plan files tracked and reused under a stable plan id.
|
|
604
|
+
export function registerArtshelfArtifact(ledgerPath, path, input) {
|
|
585
605
|
const prepared = prepareRecord({
|
|
586
606
|
path,
|
|
587
607
|
reason: input.reason,
|
|
@@ -590,30 +610,32 @@ function registerArtshelfArtifact(ledgerPath, path, input) {
|
|
|
590
610
|
cleanup: input.cleanup,
|
|
591
611
|
owner: "artshelf",
|
|
592
612
|
labels: input.labels
|
|
613
|
+
}, ledgerPath);
|
|
614
|
+
withLedgerLock(ledgerPath, () => {
|
|
615
|
+
const records = readLedger(ledgerPath);
|
|
616
|
+
const index = records.findIndex((record) => (isMatchingArtshelfArtifact(record, path, input.labels) &&
|
|
617
|
+
record.status === "active" &&
|
|
618
|
+
record.path === path));
|
|
619
|
+
if (index === -1) {
|
|
620
|
+
appendPreparedRecord(ledgerPath, prepared);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const current = records[index];
|
|
624
|
+
if (!current)
|
|
625
|
+
return;
|
|
626
|
+
records[index] = {
|
|
627
|
+
...current,
|
|
628
|
+
reason: prepared.reason,
|
|
629
|
+
createdAt: prepared.createdAt,
|
|
630
|
+
...(prepared.retainUntil ? { retainUntil: prepared.retainUntil } : {}),
|
|
631
|
+
retention: prepared.retention,
|
|
632
|
+
kind: prepared.kind,
|
|
633
|
+
cleanup: prepared.cleanup,
|
|
634
|
+
owner: prepared.owner,
|
|
635
|
+
labels: prepared.labels
|
|
636
|
+
};
|
|
637
|
+
writeLedger(ledgerPath, records);
|
|
593
638
|
});
|
|
594
|
-
const records = readLedger(ledgerPath);
|
|
595
|
-
const index = records.findIndex((record) => (isMatchingArtshelfArtifact(record, path, input.labels) &&
|
|
596
|
-
record.status === "active" &&
|
|
597
|
-
record.path === path));
|
|
598
|
-
if (index === -1) {
|
|
599
|
-
appendPreparedRecord(ledgerPath, prepared);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
const current = records[index];
|
|
603
|
-
if (!current)
|
|
604
|
-
return;
|
|
605
|
-
records[index] = {
|
|
606
|
-
...current,
|
|
607
|
-
reason: prepared.reason,
|
|
608
|
-
createdAt: prepared.createdAt,
|
|
609
|
-
...(prepared.retainUntil ? { retainUntil: prepared.retainUntil } : {}),
|
|
610
|
-
retention: prepared.retention,
|
|
611
|
-
kind: prepared.kind,
|
|
612
|
-
cleanup: prepared.cleanup,
|
|
613
|
-
owner: prepared.owner,
|
|
614
|
-
labels: prepared.labels
|
|
615
|
-
};
|
|
616
|
-
writeLedger(ledgerPath, records);
|
|
617
639
|
}
|
|
618
640
|
function isMatchingArtshelfArtifact(record, path, labels) {
|
|
619
641
|
if (record.path !== path)
|
|
@@ -659,16 +681,29 @@ function cleanupPlanEntriesFingerprint(plan) {
|
|
|
659
681
|
dueStatus: entry.dueStatus
|
|
660
682
|
})));
|
|
661
683
|
}
|
|
684
|
+
function withLedgerLock(ledgerPath, fn) {
|
|
685
|
+
return withPathLock(ledgerPath, fn, "Artshelf ledger");
|
|
686
|
+
}
|
|
687
|
+
function atomicWriteFileSync(targetPath, content) {
|
|
688
|
+
const tmpPath = `${targetPath}.${Date.now().toString(36)}-${randomBytes(4).toString("hex")}.tmp`;
|
|
689
|
+
writeFileSync(tmpPath, content);
|
|
690
|
+
renameSync(tmpPath, targetPath);
|
|
691
|
+
}
|
|
662
692
|
function appendRecord(ledgerPath, record) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
693
|
+
withLedgerLock(ledgerPath, () => {
|
|
694
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
695
|
+
const previous = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : "";
|
|
696
|
+
atomicWriteFileSync(ledgerPath, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${JSON.stringify(record)}\n`);
|
|
697
|
+
});
|
|
666
698
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
699
|
+
// Exported so the reconcile execute layer (src/reconcile.ts) persists its mutated
|
|
700
|
+
// records through the canonical JSONL writer + ledger lock instead of duplicating the
|
|
701
|
+
// atomic-write format, keeping the reconcile -> ledger import direction one-way.
|
|
702
|
+
export function writeLedger(ledgerPath, records) {
|
|
703
|
+
withLedgerLock(ledgerPath, () => {
|
|
704
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
705
|
+
atomicWriteFileSync(ledgerPath, records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
|
|
706
|
+
});
|
|
672
707
|
}
|
|
673
708
|
function updateLedgerAfterCleanup(ledgerPath, records, receipt) {
|
|
674
709
|
const resultById = new Map(receipt.results.map((result) => [result.id, result]));
|
|
@@ -802,6 +837,7 @@ function makePurgePlanId(date) {
|
|
|
802
837
|
return `purge_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
|
|
803
838
|
}
|
|
804
839
|
function cleanupPlanPath(ledgerPath, planId) {
|
|
840
|
+
assertSafeGeneratedId(planId, "cleanup plan id");
|
|
805
841
|
return join(dirname(ledgerPath), "plans", `${planId}.json`);
|
|
806
842
|
}
|
|
807
843
|
function trashPurgePlanPath(ledgerPath, purgePlanId) {
|
|
@@ -809,6 +845,7 @@ function trashPurgePlanPath(ledgerPath, purgePlanId) {
|
|
|
809
845
|
return join(dirname(ledgerPath), "purge-plans", `${purgePlanId}.json`);
|
|
810
846
|
}
|
|
811
847
|
function receiptPathFor(ledgerPath, planId) {
|
|
848
|
+
assertSafeGeneratedId(planId, "cleanup plan id");
|
|
812
849
|
return join(dirname(ledgerPath), "receipts", `${planId}.json`);
|
|
813
850
|
}
|
|
814
851
|
function trashPurgeReceiptPath(ledgerPath, purgePlanId) {
|
|
@@ -845,11 +882,31 @@ function pathExistsForPurge(path) {
|
|
|
845
882
|
return false;
|
|
846
883
|
}
|
|
847
884
|
}
|
|
848
|
-
function assertSafeGeneratedId(value, label) {
|
|
885
|
+
export function assertSafeGeneratedId(value, label) {
|
|
849
886
|
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
850
887
|
throw new Error(`Invalid ${label}: ${value}`);
|
|
851
888
|
}
|
|
852
889
|
}
|
|
890
|
+
// Bind a loaded cleanup plan to the request before any filesystem mutation: the
|
|
891
|
+
// plan must declare the requested id, belong to the executing ledger, and carry
|
|
892
|
+
// executable-looking entries. This keeps `cleanup --execute` plan-id bound, the
|
|
893
|
+
// same posture trash purge already enforces against ledger record metadata.
|
|
894
|
+
function assertCleanupPlanExecutable(plan, planId, ledgerPath) {
|
|
895
|
+
if (plan.planId !== planId) {
|
|
896
|
+
throw new Error(`Cleanup plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
|
|
897
|
+
}
|
|
898
|
+
if (plan.ledgerPath !== ledgerPath) {
|
|
899
|
+
throw new Error(`Cleanup plan ledger mismatch: plan was created for ${plan.ledgerPath}, executing ${ledgerPath}`);
|
|
900
|
+
}
|
|
901
|
+
if (!Array.isArray(plan.entries)) {
|
|
902
|
+
throw new Error(`Cleanup plan entries are malformed: ${planId}`);
|
|
903
|
+
}
|
|
904
|
+
for (const entry of plan.entries) {
|
|
905
|
+
if (!entry || typeof entry.id !== "string" || typeof entry.path !== "string" || !CLEANUP_ACTIONS.has(entry.action)) {
|
|
906
|
+
throw new Error(`Cleanup plan entries are malformed: ${planId}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
853
910
|
function writeJson(path, value) {
|
|
854
911
|
mkdirSync(dirname(path), { recursive: true });
|
|
855
912
|
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
|
+
}
|