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.
@@ -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
- const records = readLedger(ledgerPath);
140
- const index = records.findIndex((record) => record.id === input.id);
141
- if (index === -1)
142
- throw new Error(`Artshelf record not found: ${input.id}`);
143
- const current = records[index];
144
- if (!current)
145
- throw new Error(`Artshelf record not found: ${input.id}`);
146
- if (current.status === "resolved") {
147
- throw new Error(`Artshelf record is already resolved: ${input.id}`);
148
- }
149
- const updated = {
150
- ...current,
151
- status,
152
- resolvedAt: toIso(now()),
153
- resolutionReason: input.reason.trim()
154
- };
155
- records[index] = updated;
156
- writeLedger(ledgerPath, records);
157
- return updated;
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
- const records = readLedger(ledgerPath);
313
- const recordsById = new Map(records.map((record) => [record.id, record]));
314
- const trashRoot = resolve(dirname(ledgerPath), "trash");
315
- const executedAt = existingReceipt?.executedAt ?? toIso(now());
316
- let results = existingReceipt?.results ?? [];
317
- const candidates = [];
318
- for (const entry of plan.entries) {
319
- const existingResult = results.find((result) => result.id === entry.id);
320
- if (existingResult && ["failed", "purged", "skipped"].includes(existingResult.status))
321
- continue;
322
- const record = recordsById.get(entry.id);
323
- if (!record) {
324
- results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "record is missing from ledger" });
325
- continue;
326
- }
327
- if (record.status !== "trashed") {
328
- results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: `record is ${record.status}` });
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
- results.push({ id: entry.id, status: "skipped", targetPath: entry.targetPath, reason: "target is missing" });
354
- continue;
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
- ...pendingTrashPurgeResults(candidates, results)
388
+ ...candidates.map((candidate) => ({ id: candidate.id, status: "pending", targetPath: candidate.targetPath }))
387
389
  ]
388
390
  });
389
- try {
390
- rmSync(candidate.targetPath, { recursive: true, force: true });
391
- results = upsertTrashPurgeResult(results, { id: candidate.id, status: "purged", targetPath: candidate.targetPath });
392
- }
393
- catch (error) {
394
- const reason = error instanceof Error ? error.message : String(error);
395
- results = upsertTrashPurgeResult(results, { id: candidate.id, status: "failed", targetPath: candidate.targetPath, reason });
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
- writeTrashPurgeReceipt(receiptPath, {
398
- purgePlanId,
399
- executedAt,
400
- status: "started",
401
- results: [
402
- ...results,
403
- ...pendingTrashPurgeResults(candidates, results)
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
- const records = readLedger(ledgerPath);
542
- const recordsById = new Map(records.map((record) => [record.id, record]));
543
- const results = [];
544
- for (const entry of plan.entries) {
545
- const record = recordsById.get(entry.id);
546
- if (!record) {
547
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" });
548
- continue;
549
- }
550
- if (record.status !== "active") {
551
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` });
552
- continue;
553
- }
554
- if (!existsSync(entry.path)) {
555
- results.push({ id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" });
556
- continue;
557
- }
558
- if (entry.action === "delete") {
559
- results.push({ id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1" });
560
- continue;
561
- }
562
- if (entry.action === "review") {
563
- results.push({ id: entry.id, action: entry.action, status: "review-required", path: entry.path });
564
- continue;
565
- }
566
- mkdirSync(trashRoot, { recursive: true });
567
- const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
568
- renameSync(entry.path, target);
569
- results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
570
- }
571
- const receiptPath = receiptPathFor(ledgerPath, planId);
572
- const executedAt = toIso(now());
573
- writeJson(receiptPath, { planId, executedAt, results });
574
- updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
575
- registerArtshelfArtifact(ledgerPath, receiptPath, {
576
- reason: `Artshelf cleanup receipt for plan ${planId}`,
577
- ttl: "30d",
578
- kind: "run-artifact",
579
- cleanup: "review",
580
- labels: ["artshelf", "cleanup-receipt", planId]
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
- function registerArtshelfArtifact(ledgerPath, path, input) {
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
- mkdirSync(dirname(ledgerPath), { recursive: true });
664
- const previous = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : "";
665
- writeFileSync(ledgerPath, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${JSON.stringify(record)}\n`);
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
- function writeLedger(ledgerPath, records) {
668
- mkdirSync(dirname(ledgerPath), { recursive: true });
669
- const tmpPath = `${ledgerPath}.tmp`;
670
- writeFileSync(tmpPath, records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : ""));
671
- renameSync(tmpPath, ledgerPath);
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
+ }