artshelf 0.13.0 → 0.14.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 +33 -0
- package/README.md +17 -8
- package/SPEC.md +92 -42
- package/dist/src/commands/ledgers.js +88 -1
- package/dist/src/ledger.js +141 -22
- package/dist/src/registry-prune.js +259 -0
- package/dist/src/registry.js +27 -0
- package/dist/src/renderers/doctor.js +11 -1
- package/dist/src/renderers/review.js +24 -2
- package/dist/src/renderers/status.js +10 -2
- package/dist/src/shared/help-text.js +30 -1
- package/docs/agent-clean.html +7 -6
- package/docs/agent-monitor.html +16 -8
- package/docs/agent-review.html +8 -3
- package/docs/agent-usage.html +3 -3
- package/docs/agent-usage.md +5 -4
- package/docs/install.html +11 -2
- package/docs/reference.html +41 -10
- package/package.json +1 -1
- package/skills/artshelf/SKILL.md +21 -23
package/dist/src/ledger.js
CHANGED
|
@@ -553,41 +553,94 @@ export function executeCleanupPlan(ledgerPath, planId) {
|
|
|
553
553
|
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
554
554
|
assertCleanupPlanExecutable(plan, planId, ledgerPath);
|
|
555
555
|
const trashRoot = join(dirname(ledgerPath), "trash", planId);
|
|
556
|
+
const receiptPath = receiptPathFor(ledgerPath, planId);
|
|
556
557
|
return withLedgerLock(ledgerPath, () => {
|
|
558
|
+
const existingReceipt = existsSync(receiptPath) ? readCleanupReceiptIfValid(receiptPath) : null;
|
|
559
|
+
const planReceipt = existingReceipt?.planId === planId ? existingReceipt : null;
|
|
560
|
+
const priorResultById = new Map((planReceipt?.results ?? []).map((result) => [result.id, result]));
|
|
557
561
|
const records = readLedger(ledgerPath);
|
|
562
|
+
if (planReceipt?.completedAt) {
|
|
563
|
+
if (!records.some((record) => record.status === "active" && isMatchingArtshelfArtifact(record, receiptPath, ["artshelf", "cleanup-receipt", planId]))) {
|
|
564
|
+
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
565
|
+
reason: `Artshelf cleanup receipt for plan ${planId}`,
|
|
566
|
+
ttl: "30d",
|
|
567
|
+
kind: "run-artifact",
|
|
568
|
+
cleanup: "review",
|
|
569
|
+
labels: ["artshelf", "cleanup-receipt", planId]
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return { planId, receiptPath, results: planReceipt.results };
|
|
573
|
+
}
|
|
558
574
|
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
559
|
-
const
|
|
560
|
-
|
|
575
|
+
const executedAt = toIso(now());
|
|
576
|
+
const results = plan.entries.map((entry) => {
|
|
577
|
+
const prior = priorResultById.get(entry.id);
|
|
578
|
+
if (prior && isTerminalCleanupResult(prior)) {
|
|
579
|
+
return withCleanupResultExecutedAt(prior, cleanupResultExecutedAt(prior, planReceipt, executedAt));
|
|
580
|
+
}
|
|
581
|
+
return { id: entry.id, action: entry.action, status: "pending", path: entry.path };
|
|
582
|
+
});
|
|
583
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
584
|
+
for (const [index, entry] of plan.entries.entries()) {
|
|
561
585
|
const record = recordsById.get(entry.id);
|
|
586
|
+
const prior = priorResultById.get(entry.id);
|
|
587
|
+
const priorExecutedAt = prior && isTerminalCleanupResult(prior)
|
|
588
|
+
? cleanupResultExecutedAt(prior, planReceipt, executedAt)
|
|
589
|
+
: executedAt;
|
|
562
590
|
if (!record) {
|
|
563
|
-
results
|
|
591
|
+
results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "record is missing from ledger" };
|
|
592
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
564
593
|
continue;
|
|
565
594
|
}
|
|
566
595
|
if (record.status !== "active") {
|
|
567
|
-
|
|
596
|
+
if (prior && priorCleanupResultMatchesLedger(record, prior, { planId, receiptPath, executedAt: priorExecutedAt })) {
|
|
597
|
+
results[index] = withCleanupResultExecutedAt(prior, priorExecutedAt);
|
|
598
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: `record is ${record.status}` };
|
|
602
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
568
603
|
continue;
|
|
569
604
|
}
|
|
570
|
-
if (
|
|
571
|
-
results
|
|
605
|
+
if (prior && isTerminalCleanupResult(prior)) {
|
|
606
|
+
results[index] = withCleanupResultExecutedAt(prior, priorExecutedAt);
|
|
607
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
572
608
|
continue;
|
|
573
609
|
}
|
|
574
|
-
if (entry.action === "
|
|
575
|
-
|
|
610
|
+
if (entry.action === "trash") {
|
|
611
|
+
const target = join(trashRoot, `${entry.id}-${basename(entry.path)}`);
|
|
612
|
+
if (existsSync(target)) {
|
|
613
|
+
results[index] = { id: entry.id, action: entry.action, status: "trashed", path: entry.path, target, executedAt };
|
|
614
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (existsSync(entry.path)) {
|
|
618
|
+
mkdirSync(trashRoot, { recursive: true });
|
|
619
|
+
renameSync(entry.path, target);
|
|
620
|
+
results[index] = { id: entry.id, action: entry.action, status: "trashed", path: entry.path, target, executedAt };
|
|
621
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" };
|
|
625
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (!existsSync(entry.path)) {
|
|
629
|
+
results[index] = { id: entry.id, action: entry.action, status: "skipped", path: entry.path, reason: "path is missing" };
|
|
630
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
576
631
|
continue;
|
|
577
632
|
}
|
|
578
|
-
if (entry.action === "
|
|
579
|
-
results
|
|
633
|
+
if (entry.action === "delete") {
|
|
634
|
+
results[index] = { id: entry.id, action: entry.action, status: "refused", path: entry.path, reason: "delete is disabled in v1", executedAt };
|
|
635
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
580
636
|
continue;
|
|
581
637
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
renameSync(entry.path, target);
|
|
585
|
-
results.push({ id: entry.id, action: entry.action, status: "trashed", path: entry.path, target });
|
|
638
|
+
results[index] = { id: entry.id, action: entry.action, status: "review-required", path: entry.path, executedAt };
|
|
639
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: cleanupReceiptExecutedAt(results, executedAt), status: "started", results });
|
|
586
640
|
}
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt, results });
|
|
641
|
+
const receiptExecutedAt = cleanupReceiptExecutedAt(results, executedAt);
|
|
642
|
+
updateLedgerAfterCleanup(ledgerPath, records, { planId, receiptPath, executedAt: receiptExecutedAt, results });
|
|
643
|
+
writeCleanupReceipt(receiptPath, { planId, executedAt: receiptExecutedAt, completedAt: toIso(now()), results });
|
|
591
644
|
registerArtshelfArtifact(ledgerPath, receiptPath, {
|
|
592
645
|
reason: `Artshelf cleanup receipt for plan ${planId}`,
|
|
593
646
|
ttl: "30d",
|
|
@@ -598,6 +651,69 @@ export function executeCleanupPlan(ledgerPath, planId) {
|
|
|
598
651
|
return { planId, receiptPath, results };
|
|
599
652
|
});
|
|
600
653
|
}
|
|
654
|
+
function priorCleanupResultMatchesLedger(record, result, receipt) {
|
|
655
|
+
if (record.cleanupPlanId !== receipt.planId)
|
|
656
|
+
return false;
|
|
657
|
+
if (record.receiptPath !== receipt.receiptPath)
|
|
658
|
+
return false;
|
|
659
|
+
if (record.cleanedAt !== receipt.executedAt)
|
|
660
|
+
return false;
|
|
661
|
+
if (result.status === "trashed") {
|
|
662
|
+
return record.status === "trashed" && !!result.target && record.targetPath === result.target;
|
|
663
|
+
}
|
|
664
|
+
if (result.status === "review-required") {
|
|
665
|
+
return record.status === "review-required";
|
|
666
|
+
}
|
|
667
|
+
if (result.status === "refused") {
|
|
668
|
+
return record.status === "cleanup-refused" && (!result.reason || record.cleanupReason === result.reason);
|
|
669
|
+
}
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
function isTerminalCleanupResult(result) {
|
|
673
|
+
return result.status === "trashed" || result.status === "review-required" || result.status === "refused";
|
|
674
|
+
}
|
|
675
|
+
function cleanupResultExecutedAt(result, receipt, fallback) {
|
|
676
|
+
if (typeof result.executedAt === "string")
|
|
677
|
+
return result.executedAt;
|
|
678
|
+
if (isTerminalCleanupResult(result) && typeof receipt?.executedAt === "string")
|
|
679
|
+
return receipt.executedAt;
|
|
680
|
+
return fallback;
|
|
681
|
+
}
|
|
682
|
+
function withCleanupResultExecutedAt(result, executedAt) {
|
|
683
|
+
return { ...result, executedAt };
|
|
684
|
+
}
|
|
685
|
+
function cleanupReceiptExecutedAt(results, fallback) {
|
|
686
|
+
const terminalExecutedAt = results
|
|
687
|
+
.filter(isTerminalCleanupResult)
|
|
688
|
+
.map((result) => result.executedAt)
|
|
689
|
+
.filter((value) => typeof value === "string");
|
|
690
|
+
const firstExecutedAt = terminalExecutedAt[0];
|
|
691
|
+
if (firstExecutedAt && terminalExecutedAt.every((value) => value === firstExecutedAt)) {
|
|
692
|
+
return firstExecutedAt;
|
|
693
|
+
}
|
|
694
|
+
return fallback;
|
|
695
|
+
}
|
|
696
|
+
function readCleanupReceiptIfValid(receiptPath) {
|
|
697
|
+
try {
|
|
698
|
+
return readCleanupReceipt(receiptPath);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function readCleanupReceipt(receiptPath) {
|
|
705
|
+
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
706
|
+
return {
|
|
707
|
+
...(typeof receipt.planId === "string" ? { planId: receipt.planId } : {}),
|
|
708
|
+
...(typeof receipt.executedAt === "string" ? { executedAt: receipt.executedAt } : {}),
|
|
709
|
+
...(typeof receipt.completedAt === "string" ? { completedAt: receipt.completedAt } : {}),
|
|
710
|
+
...(typeof receipt.status === "string" ? { status: receipt.status } : {}),
|
|
711
|
+
results: Array.isArray(receipt.results) ? receipt.results : []
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function writeCleanupReceipt(receiptPath, receipt) {
|
|
715
|
+
writeJson(receiptPath, receipt);
|
|
716
|
+
}
|
|
601
717
|
// Exported so the reconcile plan layer (src/reconcile.ts) registers its dry-run plan
|
|
602
718
|
// artifacts through the same upsert-by-path-and-labels path that cleanup plans use,
|
|
603
719
|
// keeping plan files tracked and reused under a stable plan id.
|
|
@@ -712,31 +828,34 @@ function updateLedgerAfterCleanup(ledgerPath, records, receipt) {
|
|
|
712
828
|
if (!result)
|
|
713
829
|
return record;
|
|
714
830
|
if (result.status === "trashed") {
|
|
831
|
+
const cleanedAt = result.executedAt ?? receipt.executedAt;
|
|
715
832
|
return {
|
|
716
833
|
...record,
|
|
717
834
|
status: "trashed",
|
|
718
835
|
cleanupPlanId: receipt.planId,
|
|
719
836
|
receiptPath: receipt.receiptPath,
|
|
720
|
-
cleanedAt
|
|
837
|
+
cleanedAt,
|
|
721
838
|
...(result.target ? { targetPath: result.target } : {})
|
|
722
839
|
};
|
|
723
840
|
}
|
|
724
841
|
if (result.status === "review-required") {
|
|
842
|
+
const cleanedAt = result.executedAt ?? receipt.executedAt;
|
|
725
843
|
return {
|
|
726
844
|
...record,
|
|
727
845
|
status: "review-required",
|
|
728
846
|
cleanupPlanId: receipt.planId,
|
|
729
847
|
receiptPath: receipt.receiptPath,
|
|
730
|
-
cleanedAt
|
|
848
|
+
cleanedAt
|
|
731
849
|
};
|
|
732
850
|
}
|
|
733
851
|
if (result.status === "refused") {
|
|
852
|
+
const cleanedAt = result.executedAt ?? receipt.executedAt;
|
|
734
853
|
return {
|
|
735
854
|
...record,
|
|
736
855
|
status: "cleanup-refused",
|
|
737
856
|
cleanupPlanId: receipt.planId,
|
|
738
857
|
receiptPath: receipt.receiptPath,
|
|
739
|
-
cleanedAt
|
|
858
|
+
cleanedAt,
|
|
740
859
|
...(result.reason ? { cleanupReason: result.reason } : {})
|
|
741
860
|
};
|
|
742
861
|
}
|
|
@@ -909,5 +1028,5 @@ function assertCleanupPlanExecutable(plan, planId, ledgerPath) {
|
|
|
909
1028
|
}
|
|
910
1029
|
function writeJson(path, value) {
|
|
911
1030
|
mkdirSync(dirname(path), { recursive: true });
|
|
912
|
-
|
|
1031
|
+
atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
913
1032
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { assertSafeGeneratedId } from "./ledger.js";
|
|
5
|
+
import { withPathLock } from "./locks.js";
|
|
6
|
+
import { listRegisteredLedgers, normalizeRegistryPath, removeRegisteredLedgers } from "./registry.js";
|
|
7
|
+
import { now, toIso } from "./time.js";
|
|
8
|
+
// Classify the registry into prune findings (read-only). A registration is prunable
|
|
9
|
+
// when its ledger file is missing — the same "missing/stale" signal `ledgers list`
|
|
10
|
+
// reports via existence of the ledger path. Registrations whose resolved path appears
|
|
11
|
+
// more than once are ambiguous: pruning one would silently drop a sibling, so they are
|
|
12
|
+
// blocked for manual resolution instead of pruned. Present ledger files yield nothing.
|
|
13
|
+
export function classifyRegistryPruneFindings(registryPath) {
|
|
14
|
+
const entries = listRegisteredLedgers(normalizeRegistryPath(registryPath));
|
|
15
|
+
const pathCounts = new Map();
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
pathCounts.set(entry.path, (pathCounts.get(entry.path) ?? 0) + 1);
|
|
18
|
+
}
|
|
19
|
+
const findings = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if ((pathCounts.get(entry.path) ?? 0) > 1) {
|
|
22
|
+
findings.push(finding(entry, "blocked", "ambiguous duplicate registry path; resolve manually before pruning"));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (existsSync(entry.path))
|
|
26
|
+
continue;
|
|
27
|
+
findings.push(finding(entry, "prune", "registered ledger file is missing"));
|
|
28
|
+
}
|
|
29
|
+
return findings;
|
|
30
|
+
}
|
|
31
|
+
// Build the registry-prune plan without persisting anything (dry-run preview). Fully
|
|
32
|
+
// read-only: it classifies the registry and returns the plan a `--dry-run` would
|
|
33
|
+
// create, but never writes a plan file or mutates the registry. A plan with no
|
|
34
|
+
// actionable entries collapses to the not-created shape so callers can render
|
|
35
|
+
// "nothing to prune" the same way cleanup and reconcile do.
|
|
36
|
+
export function previewRegistryPrunePlan(registryPath) {
|
|
37
|
+
const plan = buildRegistryPrunePlan(normalizeRegistryPath(registryPath));
|
|
38
|
+
return plan.entries.length === 0 ? noCreatedRegistryPrunePlan(plan) : plan;
|
|
39
|
+
}
|
|
40
|
+
// Create (or reuse) a reviewed registry-prune plan (dry-run). This is the only part of
|
|
41
|
+
// dry-run that writes, and it only writes the plan file — never the registry. When an
|
|
42
|
+
// earlier plan already covers the same prunable entries it is reused verbatim (stable
|
|
43
|
+
// plan id), and when nothing is actionable no plan artifact is created at all, keeping
|
|
44
|
+
// dry-run side-effect-free in that case. The plan file lives next to the registry under
|
|
45
|
+
// `registry-prune-plans/` so a later `--execute` can discover it by exact plan id.
|
|
46
|
+
export function createRegistryPrunePlan(registryPath) {
|
|
47
|
+
const normalized = normalizeRegistryPath(registryPath);
|
|
48
|
+
const plan = buildRegistryPrunePlan(normalized);
|
|
49
|
+
if (plan.entries.length === 0)
|
|
50
|
+
return noCreatedRegistryPrunePlan(plan);
|
|
51
|
+
const existing = matchingExistingRegistryPrunePlan(normalized, plan);
|
|
52
|
+
const reviewed = existing ? { ...plan, planId: existing.planId, planPath: existing.planPath } : plan;
|
|
53
|
+
if (!reviewed.planPath)
|
|
54
|
+
throw new Error("registry prune plan path was not created");
|
|
55
|
+
writeRegistryPrunePlanFile(reviewed.planPath, reviewed);
|
|
56
|
+
return reviewed;
|
|
57
|
+
}
|
|
58
|
+
// Apply a reviewed registry-prune plan (NGX-481 `ledgers prune --execute`). This is the
|
|
59
|
+
// only mutating registry-prune entrypoint and it is deliberately conservative:
|
|
60
|
+
// * It refuses up front when the plan id is missing, the registry is absent, the plan
|
|
61
|
+
// file is absent, or the plan file's declared id/registry does not match the scoped
|
|
62
|
+
// request (no fresh plan, no `--all`; it binds to one exact reviewed plan id against
|
|
63
|
+
// one exact registry path).
|
|
64
|
+
// * Inside one registry lock it re-classifies the live registry and only removes a
|
|
65
|
+
// planned entry that still classifies as prunable; entries whose ledger file
|
|
66
|
+
// reappeared or whose path became an ambiguous duplicate are skipped, not removed.
|
|
67
|
+
// * It writes a rollback copy of the registry before mutating and a receipt after,
|
|
68
|
+
// then verifies the removed registrations are actually gone.
|
|
69
|
+
export function executeRegistryPrunePlan(registryPath, planId) {
|
|
70
|
+
if (!planId)
|
|
71
|
+
throw new Error("ledgers prune --execute requires --plan-id");
|
|
72
|
+
const normalized = normalizeRegistryPath(registryPath);
|
|
73
|
+
if (!existsSync(normalized))
|
|
74
|
+
throw new Error(`Registry not found: ${normalized}`);
|
|
75
|
+
const planPath = registryPrunePlanPath(normalized, planId);
|
|
76
|
+
if (!existsSync(planPath))
|
|
77
|
+
throw new Error(`Registry prune plan not found: ${planId}`);
|
|
78
|
+
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
79
|
+
assertRegistryPrunePlanExecutable(plan, planId, normalized);
|
|
80
|
+
const receiptPath = registryPruneReceiptPath(normalized, planId);
|
|
81
|
+
const rollbackPath = registryPruneRollbackPath(normalized, planId);
|
|
82
|
+
return withPathLock(normalized, () => {
|
|
83
|
+
const existingReceipt = readExistingRegistryPruneReceipt(receiptPath, planId, normalized);
|
|
84
|
+
if (existingReceipt)
|
|
85
|
+
return existingReceipt;
|
|
86
|
+
const liveByKey = new Map(classifyRegistryPruneFindings(normalized).map((item) => [pruneKey(item.name, item.path), item]));
|
|
87
|
+
const removable = [];
|
|
88
|
+
const skipped = [];
|
|
89
|
+
for (const entry of plan.entries) {
|
|
90
|
+
if (liveByKey.get(pruneKey(entry.name, entry.path))?.status === "prune")
|
|
91
|
+
removable.push(entry);
|
|
92
|
+
else
|
|
93
|
+
skipped.push(removal(entry.name, entry.path, entry.scope));
|
|
94
|
+
}
|
|
95
|
+
let removedEntries = [];
|
|
96
|
+
const receiptRollbackPath = removable.length > 0 ? rollbackPath : null;
|
|
97
|
+
if (removable.length > 0) {
|
|
98
|
+
copyRegistrySnapshot(normalized, rollbackPath);
|
|
99
|
+
removedEntries = removeRegisteredLedgers(normalized, removable.map((entry) => ({ name: entry.name, path: entry.path })));
|
|
100
|
+
}
|
|
101
|
+
const removed = removedEntries.map((entry) => removal(entry.name, entry.path, entry.scope));
|
|
102
|
+
const verification = verifyRegistryPrune(normalized, removed);
|
|
103
|
+
const receipt = {
|
|
104
|
+
planId,
|
|
105
|
+
registryPath: normalized,
|
|
106
|
+
executedAt: toIso(now()),
|
|
107
|
+
rollbackPath: receiptRollbackPath,
|
|
108
|
+
removed,
|
|
109
|
+
skipped,
|
|
110
|
+
verification,
|
|
111
|
+
receiptPath
|
|
112
|
+
};
|
|
113
|
+
writeRegistryPruneReceiptFile(receiptPath, receipt);
|
|
114
|
+
return receipt;
|
|
115
|
+
}, "Artshelf ledger registry");
|
|
116
|
+
}
|
|
117
|
+
function finding(entry, status, reason) {
|
|
118
|
+
return { name: entry.name, path: entry.path, scope: entry.scope, status, reason };
|
|
119
|
+
}
|
|
120
|
+
function buildRegistryPrunePlan(registryPath) {
|
|
121
|
+
const generatedAt = now();
|
|
122
|
+
const findings = classifyRegistryPruneFindings(registryPath);
|
|
123
|
+
const entries = findings.filter((item) => item.status === "prune").map(planEntry);
|
|
124
|
+
const skipped = findings.filter((item) => item.status === "blocked").map(planEntry);
|
|
125
|
+
const planId = makeRegistryPrunePlanId(generatedAt);
|
|
126
|
+
return {
|
|
127
|
+
planId,
|
|
128
|
+
generatedAt: toIso(generatedAt),
|
|
129
|
+
registryPath,
|
|
130
|
+
entries,
|
|
131
|
+
skipped,
|
|
132
|
+
planPath: registryPrunePlanPath(registryPath, planId)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function planEntry(item) {
|
|
136
|
+
return { name: item.name, path: item.path, scope: item.scope, reason: item.reason };
|
|
137
|
+
}
|
|
138
|
+
function noCreatedRegistryPrunePlan(plan) {
|
|
139
|
+
return { ...plan, planId: "not-created", planPath: null };
|
|
140
|
+
}
|
|
141
|
+
// Reuse an unexecuted earlier plan whose prunable entries match this one's, so repeated
|
|
142
|
+
// dry-runs converge on a single stable plan id (mirrors cleanup/reconcile plan reuse).
|
|
143
|
+
// Only the structural entry fields are fingerprinted; volatile fields (generatedAt) and
|
|
144
|
+
// the review-only skipped list do not affect reuse.
|
|
145
|
+
function matchingExistingRegistryPrunePlan(registryPath, plan) {
|
|
146
|
+
const plansDir = join(dirname(registryPath), "registry-prune-plans");
|
|
147
|
+
if (!existsSync(plansDir))
|
|
148
|
+
return null;
|
|
149
|
+
const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
|
|
150
|
+
for (const filename of filenames) {
|
|
151
|
+
const planPath = join(plansDir, filename);
|
|
152
|
+
try {
|
|
153
|
+
const candidate = JSON.parse(readFileSync(planPath, "utf8"));
|
|
154
|
+
if (candidate.registryPath !== registryPath)
|
|
155
|
+
continue;
|
|
156
|
+
if (registryPrunePlanFingerprint(candidate) !== registryPrunePlanFingerprint(plan))
|
|
157
|
+
continue;
|
|
158
|
+
if (existsSync(registryPruneReceiptPath(registryPath, candidate.planId)))
|
|
159
|
+
continue;
|
|
160
|
+
return { ...candidate, planPath };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function registryPrunePlanFingerprint(plan) {
|
|
169
|
+
return JSON.stringify(plan.entries.map((entry) => ({ name: entry.name, path: entry.path, scope: entry.scope })));
|
|
170
|
+
}
|
|
171
|
+
function writeRegistryPrunePlanFile(planPath, plan) {
|
|
172
|
+
mkdirSync(dirname(planPath), { recursive: true });
|
|
173
|
+
writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
|
|
174
|
+
}
|
|
175
|
+
function makeRegistryPrunePlanId(date) {
|
|
176
|
+
return `registry-prune_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
|
|
177
|
+
}
|
|
178
|
+
function registryPrunePlanPath(registryPath, planId) {
|
|
179
|
+
assertSafeGeneratedId(planId, "registry prune plan id");
|
|
180
|
+
return join(dirname(registryPath), "registry-prune-plans", `${planId}.json`);
|
|
181
|
+
}
|
|
182
|
+
// Bind a loaded registry-prune plan to the request before any registry mutation,
|
|
183
|
+
// mirroring reconcile's assertReconcilePlanExecutable: the plan must declare the
|
|
184
|
+
// requested id, belong to the executing registry, and carry well-formed entries.
|
|
185
|
+
function assertRegistryPrunePlanExecutable(plan, planId, registryPath) {
|
|
186
|
+
if (plan.planId !== planId) {
|
|
187
|
+
throw new Error(`Registry prune plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
|
|
188
|
+
}
|
|
189
|
+
if (plan.registryPath !== registryPath) {
|
|
190
|
+
throw new Error(`Registry prune plan registry mismatch: plan was created for ${plan.registryPath}, executing ${registryPath}`);
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(plan.entries)) {
|
|
193
|
+
throw new Error(`Registry prune plan entries are malformed: ${planId}`);
|
|
194
|
+
}
|
|
195
|
+
for (const entry of plan.entries) {
|
|
196
|
+
if (!entry || typeof entry.name !== "string" || typeof entry.path !== "string") {
|
|
197
|
+
throw new Error(`Registry prune plan entries are malformed: ${planId}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Re-scan the registry after mutation and confirm every removed registration is gone.
|
|
202
|
+
// `ok` stays true only when none of them resurface; `remainingPrunable` counts any
|
|
203
|
+
// prunable registrations left registry-wide so the receipt reflects whether the
|
|
204
|
+
// registry is fully clean or other plans still have work.
|
|
205
|
+
function verifyRegistryPrune(registryPath, removed) {
|
|
206
|
+
const live = classifyRegistryPruneFindings(registryPath);
|
|
207
|
+
const stillPresent = removed.filter((entry) => live.some((item) => item.name === entry.name && item.path === entry.path));
|
|
208
|
+
const remainingPrunable = live.filter((item) => item.status === "prune").length;
|
|
209
|
+
return {
|
|
210
|
+
ok: stillPresent.length === 0,
|
|
211
|
+
remainingPrunable,
|
|
212
|
+
detail: stillPresent.length === 0
|
|
213
|
+
? "removed registrations are gone; registry re-scan is clean of them"
|
|
214
|
+
: `still registered after prune: ${stillPresent.map((entry) => entry.name).join(", ")}`
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// Snapshot the registry verbatim before mutation so the receipt's rollbackPath points
|
|
218
|
+
// at a restorable copy. The registry is always UTF-8 JSON, so a read/write round-trip
|
|
219
|
+
// reproduces it byte-for-byte and keeps file I/O consistent with the rest of the code.
|
|
220
|
+
function copyRegistrySnapshot(registryPath, rollbackPath) {
|
|
221
|
+
mkdirSync(dirname(rollbackPath), { recursive: true });
|
|
222
|
+
writeFileSync(rollbackPath, readFileSync(registryPath, "utf8"));
|
|
223
|
+
}
|
|
224
|
+
function readExistingRegistryPruneReceipt(receiptPath, planId, registryPath) {
|
|
225
|
+
if (!existsSync(receiptPath))
|
|
226
|
+
return null;
|
|
227
|
+
let receipt;
|
|
228
|
+
try {
|
|
229
|
+
receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error(`Registry prune receipt already exists but is unreadable: ${receiptPath}`);
|
|
233
|
+
}
|
|
234
|
+
if (receipt.planId !== planId || receipt.registryPath !== registryPath || receipt.receiptPath !== receiptPath) {
|
|
235
|
+
throw new Error(`Registry prune receipt already exists for a different execution: ${receiptPath}`);
|
|
236
|
+
}
|
|
237
|
+
if (!Array.isArray(receipt.removed) || !Array.isArray(receipt.skipped) || !receipt.verification) {
|
|
238
|
+
throw new Error(`Registry prune receipt already exists but is malformed: ${receiptPath}`);
|
|
239
|
+
}
|
|
240
|
+
return receipt;
|
|
241
|
+
}
|
|
242
|
+
function removal(name, path, scope) {
|
|
243
|
+
return { name, path, scope };
|
|
244
|
+
}
|
|
245
|
+
function pruneKey(name, path) {
|
|
246
|
+
return JSON.stringify([name, path]);
|
|
247
|
+
}
|
|
248
|
+
function registryPruneReceiptPath(registryPath, planId) {
|
|
249
|
+
assertSafeGeneratedId(planId, "registry prune plan id");
|
|
250
|
+
return join(dirname(registryPath), "registry-prune-receipts", `${planId}.json`);
|
|
251
|
+
}
|
|
252
|
+
function registryPruneRollbackPath(registryPath, planId) {
|
|
253
|
+
assertSafeGeneratedId(planId, "registry prune plan id");
|
|
254
|
+
return join(dirname(registryPath), "registry-prune-rollbacks", `${planId}.json`);
|
|
255
|
+
}
|
|
256
|
+
function writeRegistryPruneReceiptFile(receiptPath, receipt) {
|
|
257
|
+
mkdirSync(dirname(receiptPath), { recursive: true });
|
|
258
|
+
writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
259
|
+
}
|
package/dist/src/registry.js
CHANGED
|
@@ -51,6 +51,33 @@ export function registerLedger(input) {
|
|
|
51
51
|
return entry;
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
+
// Remove registrations matching the given (name, path) targets, returning the entries
|
|
55
|
+
// actually removed. The approval-gated registry prune execute composes this under its
|
|
56
|
+
// own registry lock (re-entrant), so classification, rollback copy, mutation, and
|
|
57
|
+
// verification all stay inside one critical section. Matching on both name and path
|
|
58
|
+
// keeps removal precise when two registrations happen to share a path. The registry is
|
|
59
|
+
// only rewritten when something is actually removed, so a no-op target list is inert.
|
|
60
|
+
export function removeRegisteredLedgers(registryPath, targets) {
|
|
61
|
+
const normalized = normalizeRegistryPath(registryPath);
|
|
62
|
+
return withRegistryLock(normalized, () => {
|
|
63
|
+
const registry = readRegistry(normalized);
|
|
64
|
+
const wanted = new Set(targets.map((target) => removalKey(target.name, resolve(target.path))));
|
|
65
|
+
const removed = [];
|
|
66
|
+
const kept = [];
|
|
67
|
+
for (const entry of registry.ledgers) {
|
|
68
|
+
if (wanted.has(removalKey(entry.name, entry.path)))
|
|
69
|
+
removed.push(entry);
|
|
70
|
+
else
|
|
71
|
+
kept.push(entry);
|
|
72
|
+
}
|
|
73
|
+
if (removed.length > 0)
|
|
74
|
+
writeRegistry(normalized, { version: 1, ledgers: kept });
|
|
75
|
+
return removed;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function removalKey(name, path) {
|
|
79
|
+
return JSON.stringify([name, path]);
|
|
80
|
+
}
|
|
54
81
|
function writeRegistry(registryPath, registry) {
|
|
55
82
|
mkdirSync(dirname(registryPath), { recursive: true });
|
|
56
83
|
const tmpPath = `${registryPath}.${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
@@ -5,7 +5,17 @@ function doctorAttention(summary) {
|
|
|
5
5
|
}
|
|
6
6
|
function doctorNextAction(blockers, summary, registryPath) {
|
|
7
7
|
if (blockers.length > 0) {
|
|
8
|
-
|
|
8
|
+
const fixes = [];
|
|
9
|
+
if (summary.stale > 0) {
|
|
10
|
+
fixes.push(`run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`);
|
|
11
|
+
}
|
|
12
|
+
if (summary.invalid > 0) {
|
|
13
|
+
fixes.push(`repair ${summary.invalid} invalid ledger file(s) above`);
|
|
14
|
+
}
|
|
15
|
+
if (fixes.length === 0) {
|
|
16
|
+
return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
|
|
17
|
+
}
|
|
18
|
+
return `${fixes.join("; ")}, then re-run \`artshelf doctor\``;
|
|
9
19
|
}
|
|
10
20
|
if (summary.warnings > 0) {
|
|
11
21
|
return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf reconcile --dry-run --all --registry ${registryPath}\` to prepare reconcile-ready approvals, then run \`artshelf review --all --registry ${registryPath}\`; nothing is auto-executed`;
|
|
@@ -11,6 +11,14 @@ export function reviewNextAction(summary, scope, ledgerPath, registryPath) {
|
|
|
11
11
|
const broken = summary.invalid + summary.stale;
|
|
12
12
|
const review = statusCommand(scope, "review", ledgerPath);
|
|
13
13
|
if (broken > 0) {
|
|
14
|
+
if (scope === "all" && summary.stale > 0 && registryPath) {
|
|
15
|
+
const fixes = [
|
|
16
|
+
`run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`
|
|
17
|
+
];
|
|
18
|
+
if (summary.invalid > 0)
|
|
19
|
+
fixes.push(`repair ${summary.invalid} invalid ledger(s) above (re-register or fix the file)`);
|
|
20
|
+
return `${fixes.join("; ")}, then re-run \`${review}\``;
|
|
21
|
+
}
|
|
14
22
|
const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
|
|
15
23
|
return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
|
|
16
24
|
}
|
|
@@ -45,7 +53,7 @@ export function printReview(results) {
|
|
|
45
53
|
process.stdout.write(`ledger: ${result.ledger.path}\n`);
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
|
-
function buildReviewDecisions(results, scope) {
|
|
56
|
+
function buildReviewDecisions(results, scope, registryPath) {
|
|
49
57
|
const readyForApproval = [];
|
|
50
58
|
const needsReviewFirst = [];
|
|
51
59
|
const blocked = [];
|
|
@@ -54,6 +62,20 @@ function buildReviewDecisions(results, scope) {
|
|
|
54
62
|
const { ledger, validate, due } = result;
|
|
55
63
|
if (!validate.ok) {
|
|
56
64
|
const status = result.ledgerExists ? "invalid" : "missing";
|
|
65
|
+
// A missing registered ledger is repaired through the approval-gated registry-prune
|
|
66
|
+
// flow rather than hand-editing the registry; an invalid-but-present file still needs
|
|
67
|
+
// a manual re-register/fix.
|
|
68
|
+
if (scope === "all" && status === "missing" && registryPath) {
|
|
69
|
+
blocked.push({
|
|
70
|
+
label: `Prune ${ledger.name} registration (missing)`,
|
|
71
|
+
itemIds: [],
|
|
72
|
+
actionType: "fix-registry",
|
|
73
|
+
approvalTarget: null,
|
|
74
|
+
reason: validate.errors[0] ?? "the registered ledger file is missing",
|
|
75
|
+
nextStep: `run \`artshelf ledgers prune --dry-run --registry ${registryPath} --json\` to review removing it, then approve \`approve artshelf ledgers prune registry ${registryPath} plan <plan-id>\``
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
57
79
|
const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
|
|
58
80
|
blocked.push({
|
|
59
81
|
label: `Repair ${ledger.name} ledger (${status})`,
|
|
@@ -177,7 +199,7 @@ function reviewCounts(summary) {
|
|
|
177
199
|
};
|
|
178
200
|
}
|
|
179
201
|
export function buildReviewAgentPacketAll(results, summary, registry) {
|
|
180
|
-
const groups = buildReviewDecisions(results, "all");
|
|
202
|
+
const groups = buildReviewDecisions(results, "all", registry.path);
|
|
181
203
|
return {
|
|
182
204
|
schemaVersion: 1,
|
|
183
205
|
command: "review",
|
|
@@ -14,9 +14,17 @@ export function statusCommand(scope, command, ledgerPath) {
|
|
|
14
14
|
return `artshelf ${command} --all`;
|
|
15
15
|
return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
|
|
16
16
|
}
|
|
17
|
-
function statusNextAction(blockers, counts, scope, ledgerPath, registryPath) {
|
|
17
|
+
function statusNextAction(blockers, counts, scope, ledgerPath, registryPath, stale = 0, invalid = 0) {
|
|
18
18
|
if (blockers.length > 0) {
|
|
19
19
|
const verify = statusCommand(scope, "status", ledgerPath);
|
|
20
|
+
if (scope === "all" && stale > 0 && registryPath) {
|
|
21
|
+
const fixes = [
|
|
22
|
+
`run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${stale} missing/stale registration(s)`
|
|
23
|
+
];
|
|
24
|
+
if (invalid > 0)
|
|
25
|
+
fixes.push(`repair ${invalid} invalid ledger(s) above`);
|
|
26
|
+
return `${fixes.join("; ")}, then re-run \`${verify}\``;
|
|
27
|
+
}
|
|
20
28
|
return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
|
|
21
29
|
}
|
|
22
30
|
const review = statusCommand(scope, "review", ledgerPath);
|
|
@@ -59,7 +67,7 @@ export function buildStatusAgentPacketAll(report) {
|
|
|
59
67
|
counts,
|
|
60
68
|
attention: statusAttention(counts),
|
|
61
69
|
blockers,
|
|
62
|
-
nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath),
|
|
70
|
+
nextAction: statusNextAction(blockers, counts, "all", undefined, report.registryPath, report.totals.stale, report.totals.invalid),
|
|
63
71
|
verification: `artshelf status --all --agent --registry ${report.registryPath}`
|
|
64
72
|
};
|
|
65
73
|
}
|