artshelf 0.13.1 → 0.15.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 +29 -0
- package/README.md +17 -7
- package/SPEC.md +116 -34
- package/dist/src/cli.js +2 -2
- package/dist/src/commands/get.js +24 -2
- package/dist/src/commands/ledgers.js +88 -1
- package/dist/src/inspect.js +204 -0
- 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/inspect.js +108 -0
- package/dist/src/renderers/review.js +24 -2
- package/dist/src/renderers/status.js +10 -2
- package/dist/src/shared/flags.js +4 -0
- package/dist/src/shared/help-text.js +46 -1
- package/dist/src/shared/shell-quote.js +6 -0
- package/docs/agent-monitor.html +16 -8
- package/docs/agent-review.html +43 -3
- package/docs/agent-usage.html +5 -3
- package/docs/agent-usage.md +10 -6
- package/docs/install.html +11 -2
- package/docs/reference.html +51 -12
- package/package.json +1 -1
- package/skills/artshelf/SKILL.md +22 -22
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { shellArg } from "./shared/shell-quote.js";
|
|
5
|
+
import { ageOf, now as currentTime } from "./time.js";
|
|
6
|
+
const DIRECTORY_SIZE_MAX_ENTRIES = 10_000;
|
|
7
|
+
const openDirectorySync = fs.opendirSync;
|
|
8
|
+
export function buildInspectReport(record, options) {
|
|
9
|
+
const at = options.now ?? currentTime();
|
|
10
|
+
const subjectPath = subjectPathOf(record);
|
|
11
|
+
const node = describeNode(subjectPath, record.status === "active");
|
|
12
|
+
const dueState = classifyDueState(record, at, node.existence);
|
|
13
|
+
const { recommendation, nextAction } = recommend(record, node.existence, dueState, options.ledgerPath);
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
id: record.id,
|
|
17
|
+
path: record.path,
|
|
18
|
+
subjectPath,
|
|
19
|
+
kind: record.kind,
|
|
20
|
+
owner: record.owner,
|
|
21
|
+
labels: record.labels,
|
|
22
|
+
status: record.status,
|
|
23
|
+
cleanup: record.cleanup,
|
|
24
|
+
reason: record.reason,
|
|
25
|
+
existence: node.existence,
|
|
26
|
+
nodeKind: node.nodeKind,
|
|
27
|
+
byteSize: node.byteSize,
|
|
28
|
+
byteSizeTruncated: node.byteSizeTruncated,
|
|
29
|
+
age: ageOf(at, record.createdAt),
|
|
30
|
+
retention: record.retention,
|
|
31
|
+
retainUntil: record.retainUntil ?? null,
|
|
32
|
+
dueState,
|
|
33
|
+
recommendation,
|
|
34
|
+
nextAction
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function subjectPathOf(record) {
|
|
38
|
+
if (record.status === "trashed" && record.targetPath)
|
|
39
|
+
return record.targetPath;
|
|
40
|
+
return record.path;
|
|
41
|
+
}
|
|
42
|
+
function describeNode(subjectPath, followSymlinkExistence = false) {
|
|
43
|
+
if (followSymlinkExistence && !existsSync(subjectPath)) {
|
|
44
|
+
return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
|
|
45
|
+
}
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = lstatSync(subjectPath);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
|
|
52
|
+
}
|
|
53
|
+
if (stat.isSymbolicLink())
|
|
54
|
+
return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
|
|
55
|
+
if (stat.isFile())
|
|
56
|
+
return { existence: "present", nodeKind: "file", byteSize: stat.size, byteSizeTruncated: false };
|
|
57
|
+
if (stat.isDirectory()) {
|
|
58
|
+
const size = directorySize(subjectPath);
|
|
59
|
+
return { existence: "present", nodeKind: "directory", byteSize: size.bytes, byteSizeTruncated: size.truncated };
|
|
60
|
+
}
|
|
61
|
+
return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
|
|
62
|
+
}
|
|
63
|
+
// Bounded recursive size: sums regular-file bytes, never follows symlinks, and stops at
|
|
64
|
+
// a fixed entry budget so a pathological tree cannot stall a read-only inspect.
|
|
65
|
+
function directorySize(root) {
|
|
66
|
+
let total = 0;
|
|
67
|
+
let visited = 0;
|
|
68
|
+
let incomplete = false;
|
|
69
|
+
const stack = [root];
|
|
70
|
+
while (stack.length > 0) {
|
|
71
|
+
const dir = stack.pop();
|
|
72
|
+
if (dir === undefined)
|
|
73
|
+
break;
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = openDirectorySync(dir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
incomplete = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
let entry;
|
|
85
|
+
try {
|
|
86
|
+
entry = entries.readSync();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
incomplete = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (entry === null)
|
|
93
|
+
break;
|
|
94
|
+
if (visited >= DIRECTORY_SIZE_MAX_ENTRIES)
|
|
95
|
+
return { bytes: total, truncated: true };
|
|
96
|
+
visited += 1;
|
|
97
|
+
const child = join(dir, entry.name);
|
|
98
|
+
let stat;
|
|
99
|
+
try {
|
|
100
|
+
stat = lstatSync(child);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
incomplete = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (stat.isSymbolicLink())
|
|
107
|
+
continue;
|
|
108
|
+
if (stat.isDirectory()) {
|
|
109
|
+
stack.push(child);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (stat.isFile())
|
|
113
|
+
total += stat.size;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
entries.closeSync();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
incomplete = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { bytes: total, truncated: incomplete };
|
|
126
|
+
}
|
|
127
|
+
// Mirrors ledger due classification, but reuses the existence already observed so a
|
|
128
|
+
// trashed/terminal record never re-stats. Only active records carry a due state.
|
|
129
|
+
function classifyDueState(record, at, existence) {
|
|
130
|
+
if (record.status !== "active")
|
|
131
|
+
return null;
|
|
132
|
+
if (existence === "missing")
|
|
133
|
+
return "missing-path";
|
|
134
|
+
if (record.retention.mode === "manual-review")
|
|
135
|
+
return "manual-review";
|
|
136
|
+
if (!record.retainUntil)
|
|
137
|
+
return "due";
|
|
138
|
+
return new Date(record.retainUntil).getTime() <= at.getTime() ? "due" : "kept";
|
|
139
|
+
}
|
|
140
|
+
function recommend(record, existence, dueState, ledgerPath) {
|
|
141
|
+
const idArg = shellArg(record.id);
|
|
142
|
+
const ledgerArg = shellArg(ledgerPath);
|
|
143
|
+
if (record.status === "resolved") {
|
|
144
|
+
return { recommendation: "keep", nextAction: "Already resolved — no action needed." };
|
|
145
|
+
}
|
|
146
|
+
if (record.status === "trashed") {
|
|
147
|
+
if (existence === "missing") {
|
|
148
|
+
return {
|
|
149
|
+
recommendation: "resolve-only",
|
|
150
|
+
nextAction: `Trashed target is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
recommendation: "keep",
|
|
155
|
+
nextAction: "Already trashed — permanent removal is the separate approval-gated `artshelf trash purge` flow."
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (record.status === "review-required") {
|
|
159
|
+
return {
|
|
160
|
+
recommendation: "blocked",
|
|
161
|
+
nextAction: "A cleanup run flagged this for manual review — inspect the artifact, then resolve or re-plan deliberately."
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (record.status === "cleanup-refused") {
|
|
165
|
+
return {
|
|
166
|
+
recommendation: "blocked",
|
|
167
|
+
nextAction: "A prior cleanup refused this record — handle it manually; Artshelf will not retry automatically."
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (existence === "missing" || dueState === "missing-path") {
|
|
171
|
+
return {
|
|
172
|
+
recommendation: "resolve-only",
|
|
173
|
+
nextAction: `Path is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (dueState === "kept") {
|
|
177
|
+
return {
|
|
178
|
+
recommendation: "snooze",
|
|
179
|
+
nextAction: `Retention holds until ${record.retainUntil ?? "the configured date"} — re-inspect after it expires; nothing is due now.`
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (dueState === "manual-review") {
|
|
183
|
+
return {
|
|
184
|
+
recommendation: "keep",
|
|
185
|
+
nextAction: "Held for manual review — keep it, change its retention, or resolve it explicitly; no cleanup is scheduled."
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (record.cleanup === "trash") {
|
|
189
|
+
return {
|
|
190
|
+
recommendation: "trash-safe",
|
|
191
|
+
nextAction: `Due and disposable — run \`artshelf cleanup --dry-run --ledger ${ledgerArg}\`, then approve the reviewed plan id.`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (record.cleanup === "delete") {
|
|
195
|
+
return {
|
|
196
|
+
recommendation: "blocked",
|
|
197
|
+
nextAction: "Due with cleanup=delete, which Artshelf refuses — switch it to cleanup=trash and plan a cleanup, or resolve it manually."
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
recommendation: "keep",
|
|
202
|
+
nextAction: "Due and held for review — keep it, change its retention, resolve it, or switch cleanup to trash and plan a cleanup."
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -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`;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { shellArg } from "../shared/shell-quote.js";
|
|
2
|
+
import { attentionGlyph } from "./attention.js";
|
|
3
|
+
// Buckets whose next-safe action is something other than "leave it alone" get the
|
|
4
|
+
// attention glyph so a scanned card reads as a decision, not just a status line.
|
|
5
|
+
const ATTENTION_RECOMMENDATIONS = new Set([
|
|
6
|
+
"trash-safe",
|
|
7
|
+
"resolve-only",
|
|
8
|
+
"blocked"
|
|
9
|
+
]);
|
|
10
|
+
export function buildInspectAgentPacket(report, ledgerPath) {
|
|
11
|
+
return {
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
command: "get",
|
|
14
|
+
mode: "inspect",
|
|
15
|
+
ledgerPath,
|
|
16
|
+
inspect: report,
|
|
17
|
+
safety: {
|
|
18
|
+
readOnly: true,
|
|
19
|
+
noFileMoves: true,
|
|
20
|
+
noLedgerMutation: true
|
|
21
|
+
},
|
|
22
|
+
nextAction: report.nextAction,
|
|
23
|
+
verification: `artshelf get ${shellArg(report.id)} --inspect --agent --ledger ${shellArg(ledgerPath)}`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function printInspect(report, ledgerPath) {
|
|
27
|
+
const glyph = attentionGlyph(ATTENTION_RECOMMENDATIONS.has(report.recommendation));
|
|
28
|
+
const labels = report.labels.length > 0 ? report.labels.map(sanitizeHumanLine).join(", ") : "none";
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(`${glyph} ${sanitizeHumanLine(report.id)} [${sanitizeHumanLine(report.kind)}] — ${sanitizeHumanLine(report.recommendation)}`);
|
|
31
|
+
lines.push(`path: ${sanitizeHumanLine(report.path)}`);
|
|
32
|
+
// Trashed records point `path` at the now-empty original; existence and size
|
|
33
|
+
// describe the trash target, so name it explicitly when the two differ.
|
|
34
|
+
if (report.subjectPath !== report.path)
|
|
35
|
+
lines.push(`trash target: ${sanitizeHumanLine(report.subjectPath)}`);
|
|
36
|
+
lines.push(`status: ${sanitizeHumanLine(report.status)} · cleanup: ${sanitizeHumanLine(report.cleanup)} · owner: ${sanitizeHumanLine(report.owner)} · labels: ${labels}`);
|
|
37
|
+
lines.push(`existence: ${sanitizeHumanLine(formatExistence(report))} · age: ${sanitizeHumanLine(report.age)} · retention: ${sanitizeHumanLine(formatRetention(report.retention))} · due: ${sanitizeHumanLine(report.dueState ?? "n/a")}`);
|
|
38
|
+
lines.push(`reason: ${sanitizeHumanLine(report.reason)}`);
|
|
39
|
+
lines.push(`next: ${sanitizeHumanLine(report.nextAction)}`);
|
|
40
|
+
lines.push(`ledger: ${sanitizeHumanLine(ledgerPath)}`);
|
|
41
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
42
|
+
}
|
|
43
|
+
function formatExistence(report) {
|
|
44
|
+
if (report.existence === "missing")
|
|
45
|
+
return "missing";
|
|
46
|
+
if (report.nodeKind === "file" || report.nodeKind === "directory") {
|
|
47
|
+
const size = report.byteSize === null ? "size unavailable" : formatBytes(report.byteSize);
|
|
48
|
+
if (report.nodeKind === "directory" && report.byteSizeTruncated) {
|
|
49
|
+
return `present (directory, at least ${size}; scan capped)`;
|
|
50
|
+
}
|
|
51
|
+
return `present (${report.nodeKind}, ${size})`;
|
|
52
|
+
}
|
|
53
|
+
return "present (other)";
|
|
54
|
+
}
|
|
55
|
+
function formatRetention(retention) {
|
|
56
|
+
if (retention.mode === "ttl")
|
|
57
|
+
return `ttl ${retention.ttl}`;
|
|
58
|
+
if (retention.mode === "retain-until")
|
|
59
|
+
return `retain-until ${retention.retainUntil}`;
|
|
60
|
+
return "manual-review";
|
|
61
|
+
}
|
|
62
|
+
function formatBytes(bytes) {
|
|
63
|
+
if (bytes < 1024)
|
|
64
|
+
return `${bytes} B`;
|
|
65
|
+
if (bytes < 1024 * 1024)
|
|
66
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
67
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
68
|
+
}
|
|
69
|
+
function sanitizeHumanLine(value) {
|
|
70
|
+
let out = "";
|
|
71
|
+
for (const char of value) {
|
|
72
|
+
const code = char.codePointAt(0);
|
|
73
|
+
if (code === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
if (char === "\\") {
|
|
76
|
+
out += "\\\\";
|
|
77
|
+
}
|
|
78
|
+
else if (char === "\n") {
|
|
79
|
+
out += "\\n";
|
|
80
|
+
}
|
|
81
|
+
else if (char === "\r") {
|
|
82
|
+
out += "\\r";
|
|
83
|
+
}
|
|
84
|
+
else if (char === "\t") {
|
|
85
|
+
out += "\\t";
|
|
86
|
+
}
|
|
87
|
+
else if (code < 32 || (code >= 0x7f && code <= 0x9f) || isUnicodeFormatControl(code)) {
|
|
88
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
out += char;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function isUnicodeFormatControl(code) {
|
|
97
|
+
if (code === 0x00ad || code === 0x061c)
|
|
98
|
+
return true;
|
|
99
|
+
if (code >= 0x200b && code <= 0x200f)
|
|
100
|
+
return true;
|
|
101
|
+
if (code >= 0x202a && code <= 0x202e)
|
|
102
|
+
return true;
|
|
103
|
+
if (code >= 0x2060 && code <= 0x206f)
|
|
104
|
+
return true;
|
|
105
|
+
if (code >= 0xfe00 && code <= 0xfe0f)
|
|
106
|
+
return true;
|
|
107
|
+
return code >= 0xfff9 && code <= 0xfffb;
|
|
108
|
+
}
|
|
@@ -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",
|