1// Prune Core
2//
3// Identifies dead nodes, absorbs their essence, trims them.
4//
5// A node is a prune candidate when ALL of these are true:
6// - No visits in dormancyThresholdDays (default 90)
7// - No cascade signals received or originated
8// - No codebook entries referencing it
9// - No contradictions referencing it
10// - No other nodes linking to it in metadata
11// - Not a system node
12// - Not the tree root
13// - Status is "active" (already trimmed/completed nodes are not candidates)
14//
15// The scan writes candidates to metadata.prune.candidates on the tree root.
16// Confirmation trims each one after an optional AI absorption pass.
17
18import log from "../../seed/log.js";
19import { getExtension } from "../loader.js";
20
21let Node = null;
22let Contribution = null;
23let Note = null;
24let logContribution = null;
25let runChat = null;
26let useEnergy = async () => ({ energyUsed: 0 });
27let _metadata = null;
28
29export function setServices({ models, contributions, llm, energy, metadata }) {
30 Node = models.Node;
31 Contribution = models.Contribution;
32 Note = models.Note;
33 logContribution = contributions.logContribution;
34 runChat = llm.runChat;
35 if (energy?.useEnergy) useEnergy = energy.useEnergy;
36 if (metadata) _metadata = metadata;
37}
38
39async function getDormancyDays() {
40 try {
41 const { getLandConfigValue } = await import("../../seed/landConfig.js");
42 return Number(getLandConfigValue("pruneDormancyDays")) || 90;
43 } catch {
44 return 90;
45 }
46}
47
48// ─────────────────────────────────────────────────────────────────────────
49// SCAN: identify dead nodes
50// ─────────────────────────────────────────────────────────────────────────
51
52/**
53 * Scan a tree for prune candidates.
54 * Returns the list and writes it to metadata.prune.candidates on the root.
55 */
56export async function scanForCandidates(rootId, userId) {
57 await useEnergy({ userId, action: "pruneScan" });
58
59 const dormancyMs = (await getDormancyDays()) * 24 * 60 * 60 * 1000;
60 const cutoff = new Date(Date.now() - dormancyMs);
61
62 // Get all nodes in this tree (walk from root, not rootOwner which is a userId)
63 const { getDescendantIds } = await import("../../seed/tree/treeFetch.js");
64 const allIds = await getDescendantIds(rootId, { maxResults: 10000 });
65 const nodes = await Node.find({ _id: { $in: allIds }, status: "active" })
66 .select("_id name parent children systemRole metadata dateCreated")
67 .lean();
68
69 if (nodes.length === 0) return [];
70
71 // Get recent contributions for all nodes in this tree
72 const nodeIds = nodes.map(n => n._id.toString());
73 const recentContribs = await Contribution.find({
74 nodeId: { $in: nodeIds },
75 date: { $gte: cutoff },
76 }).select("nodeId").lean();
77
78 const activeNodeIds = new Set(recentContribs.map(c => c.nodeId.toString()));
79
80 // Get recent notes
81 const recentNotes = await Note.find({
82 nodeId: { $in: nodeIds },
83 dateCreated: { $gte: cutoff },
84 }).select("nodeId").lean();
85
86 for (const n of recentNotes) activeNodeIds.add(n.nodeId.toString());
87
88 // Check optional signal sources
89 const cascadeActivity = await getCascadeActivity(rootId, nodeIds, cutoff);
90 const codebookRefs = await getCodebookReferences(rootId, nodeIds);
91 const contradictionRefs = await getContradictionReferences(rootId, nodeIds);
92
93 // Build candidates list
94 const candidates = [];
95
96 for (const node of nodes) {
97 const id = node._id.toString();
98
99 // Skip root, system nodes
100 if (id === rootId) continue;
101 if (node.systemRole) continue;
102
103 // Skip nodes with recent activity
104 if (activeNodeIds.has(id)) continue;
105
106 // Skip nodes with cascade activity
107 if (cascadeActivity.has(id)) continue;
108
109 // Skip nodes referenced by codebook
110 if (codebookRefs.has(id)) continue;
111
112 // Skip nodes referenced by contradictions
113 if (contradictionRefs.has(id)) continue;
114
115 // Skip nodes with children that are still active
116 const hasActiveChild = (node.children || []).some(childId =>
117 activeNodeIds.has(childId.toString())
118 );
119 if (hasActiveChild) continue;
120
121 candidates.push({
122 nodeId: id,
123 name: node.name,
124 parentId: node.parent?.toString() || null,
125 createdAt: node.dateCreated,
126 childCount: (node.children || []).length,
127 });
128 }
129
130 // Write candidates to root metadata
131 const root = await Node.findById(rootId);
132 if (root) {
133 const pruneMeta = _metadata.getExtMeta(root, "prune");
134 pruneMeta.candidates = candidates;
135 pruneMeta.lastScanAt = new Date().toISOString();
136 pruneMeta.dormancyDays = (await getDormancyDays());
137 await _metadata.setExtMeta(root, "prune", pruneMeta);
138 }
139
140 log.verbose("Prune", `Scanned tree ${rootId}: ${candidates.length} candidate(s) from ${nodes.length} nodes`);
141 return candidates;
142}
143
144// ─────────────────────────────────────────────────────────────────────────
145// CONFIRM: absorb and trim
146// ─────────────────────────────────────────────────────────────────────────
147
148/**
149 * Execute pruning on all candidates. For each:
150 * 1. Ask the AI if anything is worth preserving
151 * 2. If yes, absorb the fact into parent's metadata
152 * 3. Set status to "trimmed"
153 * 4. Log as contribution
154 */
155export async function confirmPrune(rootId, userId, username) {
156 const root = await Node.findById(rootId);
157 if (!root) throw new Error("Tree not found");
158
159 const pruneMeta = _metadata.getExtMeta(root, "prune");
160 const candidates = pruneMeta.candidates || [];
161 if (candidates.length === 0) return { pruned: 0, absorbed: 0 };
162
163 let pruned = 0;
164 let absorbed = 0;
165
166 for (const candidate of candidates) {
167 try {
168 const result = await pruneNode(candidate, rootId, userId, username);
169 pruned++;
170 if (result.absorbed) absorbed++;
171 } catch (err) {
172 log.warn("Prune", `Failed to prune ${candidate.name} (${candidate.nodeId}): ${err.message}`);
173 }
174 }
175
176 // Clear candidates
177 pruneMeta.candidates = [];
178 pruneMeta.lastPruneAt = new Date().toISOString();
179 if (!pruneMeta.history) pruneMeta.history = [];
180 pruneMeta.history.push({
181 date: new Date().toISOString(),
182 pruned,
183 absorbed,
184 userId,
185 });
186 // Cap history
187 if (pruneMeta.history.length > 50) {
188 pruneMeta.history = pruneMeta.history.slice(-50);
189 }
190 await _metadata.setExtMeta(root, "prune", pruneMeta);
191
192 log.info("Prune", `Pruned ${pruned} node(s) from tree ${rootId} (${absorbed} absorbed)`);
193 return { pruned, absorbed };
194}
195
196async function pruneNode(candidate, rootId, userId, username) {
197 const node = await Node.findById(candidate.nodeId)
198 .select("_id name parent status metadata")
199 .lean();
200
201 if (!node || node.status === "trimmed") return { absorbed: false };
202
203 // Energy for absorption check
204 try {
205 await useEnergy({ userId, action: "pruneAbsorb" });
206 } catch {
207 // No energy, skip absorption, just trim
208 await trimNode(candidate.nodeId, userId);
209 return { absorbed: false };
210 }
211
212 // Get the node's content for the AI to evaluate
213 const notes = await Note.find({ nodeId: candidate.nodeId })
214 .select("content")
215 .sort({ dateCreated: -1 })
216 .limit(5)
217 .lean();
218
219 const contentSummary = notes.map(n => n.content?.slice(0, 500)).filter(Boolean).join("\n---\n");
220 let didAbsorb = false;
221
222 if (contentSummary) {
223 // Ask the AI: is anything here worth preserving?
224 try {
225 const result = await runChat({
226 userId,
227 username,
228 message:
229 `This node "${node.name}" is being pruned (no activity in ${(await getDormancyDays())} days). ` +
230 `Here is its content:\n\n${contentSummary.slice(0, 2000)}\n\n` +
231 `Is there one essential fact or insight worth preserving? ` +
232 `If yes, respond with just that fact in one sentence. If no, respond with "nothing".`,
233 mode: "tree:respond",
234 rootId,
235 slot: "prune",
236 });
237
238 const answer = result?.answer?.trim();
239 if (answer && answer.toLowerCase() !== "nothing" && answer.length < 500) {
240 // Absorb into parent
241 const parent = await Node.findById(node.parent);
242 if (parent) {
243 const parentPrune = _metadata.getExtMeta(parent, "prune");
244 if (!parentPrune.absorbed) parentPrune.absorbed = {};
245 parentPrune.absorbed[node.name || candidate.nodeId] = {
246 fact: answer,
247 absorbedAt: new Date().toISOString(),
248 originalNodeId: candidate.nodeId,
249 };
250 await _metadata.setExtMeta(parent, "prune", parentPrune);
251 didAbsorb = true;
252 }
253 }
254 } catch (err) {
255 log.debug("Prune", `Absorption check failed for ${node.name}: ${err.message}`);
256 }
257 }
258
259 // Trim the node
260 await trimNode(candidate.nodeId, userId);
261
262 // Log contribution
263 await logContribution({
264 userId,
265 nodeId: candidate.nodeId,
266 wasAi: true,
267 action: "prune:trimmed",
268 extensionData: {
269 prune: {
270 nodeName: node.name,
271 absorbed: didAbsorb,
272 dormancyDays: (await getDormancyDays()),
273 },
274 },
275 });
276
277 return { absorbed: didAbsorb };
278}
279
280async function trimNode(nodeId, userId) {
281 await Node.updateOne({ _id: nodeId }, { $set: { status: "trimmed" } });
282}
283
284// ─────────────────────────────────────────────────────────────────────────
285// UNDO: restore a pruned node
286// ─────────────────────────────────────────────────────────────────────────
287
288export async function undoPrune(nodeId, userId) {
289 const node = await Node.findById(nodeId).select("status name").lean();
290 if (!node) throw new Error("Node not found");
291 if (node.status !== "trimmed") throw new Error("Node is not trimmed");
292
293 await Node.updateOne({ _id: nodeId }, { $set: { status: "active" } });
294
295 await logContribution({
296 userId,
297 nodeId,
298 wasAi: false,
299 action: "prune:restored",
300 });
301
302 log.verbose("Prune", `Restored pruned node ${node.name} (${nodeId})`);
303 return { restored: true, name: node.name };
304}
305
306// ─────────────────────────────────────────────────────────────────────────
307// PURGE: permanent removal of long-trimmed nodes
308// ─────────────────────────────────────────────────────────────────────────
309
310/**
311 * Purge nodes that have been trimmed past the grace period.
312 * This is permanent. The data is deleted from the database.
313 * Only runs if purgeGraceDays is set in land config (default: off).
314 */
315export async function purge(rootId, userId) {
316 let graceDays;
317 try {
318 const { getLandConfigValue } = await import("../../seed/landConfig.js");
319 graceDays = Number(getLandConfigValue("purgeGraceDays"));
320 } catch (err) {
321 log.debug("Prune", "Failed to read purgeGraceDays config:", err.message);
322 }
323
324 if (!graceDays || graceDays <= 0) {
325 throw new Error("Purge is disabled. Set purgeGraceDays in land config to enable.");
326 }
327
328 const graceMs = graceDays * 24 * 60 * 60 * 1000;
329 const cutoff = new Date(Date.now() - graceMs);
330
331 // Find trimmed nodes in this tree older than the grace period
332 const trimmed = await Node.find({
333 rootOwner: rootId,
334 status: "trimmed",
335 dateCreated: { $lt: cutoff },
336 }).select("_id name").lean();
337
338 if (trimmed.length === 0) return { purged: 0 };
339
340 // Delete notes, contributions, then nodes
341 const ids = trimmed.map(n => n._id);
342 await Note.deleteMany({ nodeId: { $in: ids } });
343 await Contribution.deleteMany({ nodeId: { $in: ids } });
344 await Node.deleteMany({ _id: { $in: ids } });
345
346 // Remove from parent children arrays
347 for (const t of trimmed) {
348 await Node.updateMany(
349 { children: t._id },
350 { $pull: { children: t._id } },
351 );
352 }
353
354 log.info("Prune", `Purged ${trimmed.length} node(s) from tree ${rootId} (past ${graceDays}-day grace period)`);
355
356 return { purged: trimmed.length, names: trimmed.map(n => n.name) };
357}
358
359// ─────────────────────────────────────────────────────────────────────────
360// SIGNAL SOURCE HELPERS (optional extensions)
361// ─────────────────────────────────────────────────────────────────────────
362
363async function getCascadeActivity(rootId, nodeIds, cutoff) {
364 const active = new Set();
365 try {
366 // Check .flow for recent cascade results involving these nodes
367 const flowNode = await Node.findOne({ systemRole: "flow" }).select("_id").lean();
368 if (!flowNode) return active;
369
370 const cutoffDate = cutoff.toISOString().slice(0, 10);
371 const partitions = await Node.find({
372 parent: flowNode._id,
373 name: { $gte: cutoffDate },
374 }).select("metadata").lean();
375
376 for (const p of partitions) {
377 const results = p.metadata instanceof Map
378 ? p.metadata.get("results") || {}
379 : p.metadata?.results || {};
380 for (const entries of Object.values(results)) {
381 const arr = Array.isArray(entries) ? entries : [entries];
382 for (const r of arr) {
383 if (r.source && nodeIds.includes(r.source)) active.add(r.source);
384 }
385 }
386 }
387 } catch (err) {
388 log.debug("Prune", "Cascade activity check failed:", err.message);
389 }
390 return active;
391}
392
393async function getCodebookReferences(rootId, nodeIds) {
394 const refs = new Set();
395 const codebookExt = getExtension("codebook");
396 if (!codebookExt?.exports?.getReferencedNodes) return refs;
397 try {
398 const referenced = await codebookExt.exports.getReferencedNodes(rootId);
399 for (const id of referenced) {
400 if (nodeIds.includes(id)) refs.add(id);
401 }
402 } catch (err) {
403 log.debug("Prune", "Codebook reference check failed:", err.message);
404 }
405 return refs;
406}
407
408async function getContradictionReferences(rootId, nodeIds) {
409 const refs = new Set();
410 const contradictionExt = getExtension("contradiction");
411 if (!contradictionExt?.exports?.getReferencedNodes) return refs;
412 try {
413 const referenced = await contradictionExt.exports.getReferencedNodes(rootId);
414 for (const id of referenced) {
415 if (nodeIds.includes(id)) refs.add(id);
416 }
417 } catch (err) {
418 log.debug("Prune", "Contradiction reference check failed:", err.message);
419 }
420 return refs;
421}
422
1import log from "../../seed/log.js";
2import { setServices } from "./core.js";
3import { setModels as setJobModels, setMetadata as setJobMetadata, startPruneJob, stopPruneJob } from "./pruneJob.js";
4
5export async function init(core) {
6 core.llm.registerRootLlmSlot("prune");
7 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
8 setServices({
9 models: core.models,
10 contributions: core.contributions,
11 llm: { ...core.llm, runChat: async (opts) => {
12 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
13 return core.llm.runChat({ ...opts, llmPriority: BG });
14 } },
15 energy: core.energy || null,
16 metadata: core.metadata,
17 });
18
19 setJobModels(core.models);
20 setJobMetadata(core.metadata);
21
22 const { default: router, setModels, setMetadata: setRouteMetadata } = await import("./routes.js");
23 setModels(core.models);
24 setRouteMetadata(core.metadata);
25
26 log.info("Prune", "Tree pruning engine loaded");
27
28 return {
29 router,
30 jobs: [
31 {
32 name: "prune-cycle",
33 start: () => startPruneJob(),
34 stop: () => stopPruneJob(),
35 },
36 ],
37 };
38}
39
1export default {
2 name: "prune",
3 version: "1.0.1",
4 builtFor: "treeos-maintenance",
5 description:
6 "The tree sheds dead weight. Not compression where meaning is preserved in " +
7 "smaller form. Actual removal of content that has no value anymore. " +
8 "\n\n" +
9 "Prune listens to evolution's dormancy metrics and long-memory's interaction " +
10 "history. It identifies nodes that are truly dead. Not sleeping. Dead. No " +
11 "visits in 90 days. No cascade signals received or originated. No codebook " +
12 "entries. No contradictions referencing them. No other nodes linking to them " +
13 "in metadata. Zero connections to anything alive. " +
14 "\n\n" +
15 "It marks them for pruning. Writes candidates to metadata.prune.candidates on " +
16 "the tree root. The intent extension picks these up and presents them to the " +
17 "user or, if autoPrune is enabled, trims them directly. Before trimming, it " +
18 "runs one final check: sends the node's content to the AI with 'is there " +
19 "anything here worth preserving?' If yes, the essential fact gets absorbed " +
20 "into the parent's metadata.prune.absorbed dictionary. Then the node is trimmed. " +
21 "\n\n" +
22 "Trimmed is the same status tree-compress uses. The node stops appearing in " +
23 "tree summaries, navigation, and enrichContext. The data stays in the database. " +
24 "The AI can't see it. The user can't navigate to it unless they explicitly look. " +
25 "The tree shed the leaf. The leaf is on the ground. It's not in the canopy " +
26 "anymore but it's still on the land. Someone can pick it up later. " +
27 "\n\n" +
28 "The difference from tree-compress: compress keeps everything in denser form. " +
29 "Prune actually lets go. The node is gone from the canopy. The branch is lighter. " +
30 "The tree shed a leaf because autumn came. " +
31 "\n\n" +
32 "Restoration uses the same path as any trimmed node. decompress-node or " +
33 "prune undo <nodeId> sets the status back to active. One status. One recovery " +
34 "mechanism. The leaf goes back on the branch. " +
35 "\n\n" +
36 "If an operator wants true permanent removal, that's a separate purge operation " +
37 "that runs on nodes already in trimmed status past a configurable grace period. " +
38 "Prune decides what to shed. Purge decides what to destroy. Two different " +
39 "decisions. Two different risk levels. Prune is reversible. Purge is not. " +
40 "\n\n" +
41 "Seasonal cycles. pruneInterval configurable. Some operators run it monthly. " +
42 "Some quarterly. Some manually after a big project ends.",
43
44 needs: {
45 services: ["llm", "hooks", "contributions"],
46 models: ["Node", "Contribution", "Note"],
47 },
48
49 optional: {
50 services: ["energy"],
51 extensions: [
52 "evolution",
53 "long-memory",
54 "codebook",
55 "contradiction",
56 "pulse",
57 ],
58 },
59
60 provides: {
61 models: {},
62 routes: false,
63 tools: false,
64 jobs: true,
65 orchestrator: false,
66 energyActions: {
67 pruneScan: { cost: 2 },
68 pruneAbsorb: { cost: 1 },
69 },
70 sessionTypes: {},
71
72 hooks: {
73 fires: [],
74 listens: ["afterBoot"],
75 },
76
77 cli: [
78 {
79 command: "prune [action] [args...]", scope: ["tree"],
80 description: "Pruning candidates for current tree. Actions: confirm, undo, history.",
81 method: "GET",
82 endpoint: "/root/:rootId/prune",
83 subcommands: {
84 "confirm": { method: "POST", endpoint: "/root/:rootId/prune/confirm", description: "Execute pruning" },
85 "undo": { method: "POST", endpoint: "/root/:rootId/prune/undo", args: ["nodeId"], description: "Restore trimmed node" },
86 "history": { method: "GET", endpoint: "/root/:rootId/prune/history", description: "What was shed and absorbed" },
87 },
88 },
89 ],
90 },
91};
92
1// Prune Job
2//
3// Runs on a configurable interval. For each tree with autoPrune enabled,
4// scans for dead nodes and trims them. The seasonal cycle.
5//
6// Trees opt in via metadata.prune.autoPrune = true on the root.
7// Default off. The tree doesn't shed leaves unless the operator says so.
8
9import log from "../../seed/log.js";
10import { scanForCandidates, confirmPrune } from "./core.js";
11
12let Node = null;
13let User = null;
14let _metadata = null;
15export function setModels(models) { Node = models.Node; User = models.User; }
16export function setMetadata(metadata) { _metadata = metadata; }
17
18let _timer = null;
19
20async function getIntervalMs() {
21 try {
22 const { getLandConfigValue } = await import("../../seed/landConfig.js");
23 return Number(getLandConfigValue("pruneIntervalMs")) || 7 * 24 * 60 * 60 * 1000; // weekly default
24 } catch {
25 return 7 * 24 * 60 * 60 * 1000;
26 }
27}
28
29export async function startPruneJob() {
30 if (_timer) return;
31 const interval = await getIntervalMs();
32 _timer = setInterval(runPruneCycle, interval);
33 if (_timer.unref) _timer.unref();
34 log.info("Prune", `Prune job started (checking every ${Math.round(interval / (24 * 60 * 60 * 1000))}d)`);
35}
36
37export function stopPruneJob() {
38 if (_timer) {
39 clearInterval(_timer);
40 _timer = null;
41 }
42}
43
44async function runPruneCycle() {
45 try {
46 // Find trees with autoPrune enabled
47 const roots = await Node.find({
48 rootOwner: { $nin: [null, "SYSTEM"] },
49 "metadata.prune.autoPrune": true,
50 }).select("_id name rootOwner metadata").lean();
51
52 if (roots.length === 0) return;
53
54 log.verbose("Prune", `Prune cycle: ${roots.length} tree(s) opted in for auto-prune`);
55
56 for (const root of roots) {
57 try {
58 const pruneMeta = _metadata.getExtMeta(root, "prune");
59 if (pruneMeta.paused) continue;
60
61 const userId = root.rootOwner?.toString();
62 if (!userId) continue;
63
64 const user = await User.findById(userId).select("username").lean();
65 if (!user) continue;
66
67 // Scan
68 const candidates = await scanForCandidates(root._id.toString(), userId);
69 if (candidates.length === 0) continue;
70
71 log.verbose("Prune", `Auto-pruning ${candidates.length} node(s) from ${root.name}`);
72
73 // Confirm (absorb + trim)
74 await confirmPrune(root._id.toString(), userId, user.username);
75 } catch (err) {
76 log.warn("Prune", `Auto-prune failed for tree ${root.name}: ${err.message}`);
77 }
78 }
79 } catch (err) {
80 log.error("Prune", `Prune cycle error: ${err.message}`);
81 }
82}
83
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import { scanForCandidates, confirmPrune, undoPrune, purge } from "./core.js";
6
7let Node = null;
8let _metadata = null;
9export function setModels(models) { Node = models.Node; }
10export function setMetadata(metadata) { _metadata = metadata; }
11
12function validateRootId(req, res) {
13 const rootId = req.params.rootId;
14 if (!rootId || rootId === "undefined" || rootId === "null") {
15 sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
16 return null;
17 }
18 return rootId;
19}
20
21const router = express.Router();
22
23// GET /root/:rootId/prune - Show candidates
24router.get("/root/:rootId/prune", authenticate, async (req, res) => {
25 try {
26 const rootId = validateRootId(req, res);
27 if (!rootId) return;
28 const root = await Node.findById(rootId).select("metadata name").lean();
29 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
30
31 const pruneMeta = _metadata.getExtMeta(root, "prune");
32
33 sendOk(res, {
34 rootId,
35 candidates: pruneMeta.candidates || [],
36 lastScanAt: pruneMeta.lastScanAt || null,
37 lastPruneAt: pruneMeta.lastPruneAt || null,
38 dormancyDays: pruneMeta.dormancyDays || 90,
39 });
40 } catch (err) {
41 sendError(res, 500, ERR.INTERNAL, err.message);
42 }
43});
44
45// POST /root/:rootId/prune/scan - Run a fresh scan
46router.post("/root/:rootId/prune/scan", authenticate, async (req, res) => {
47 try {
48 const rootId = validateRootId(req, res);
49 if (!rootId) return;
50 const candidates = await scanForCandidates(rootId, req.userId);
51 sendOk(res, { candidates, count: candidates.length });
52 } catch (err) {
53 sendError(res, 500, ERR.INTERNAL, err.message);
54 }
55});
56
57// POST /root/:rootId/prune/confirm - Execute pruning
58router.post("/root/:rootId/prune/confirm", authenticate, async (req, res) => {
59 try {
60 const rootId = validateRootId(req, res);
61 if (!rootId) return;
62 const result = await confirmPrune(rootId, req.userId, req.username);
63 sendOk(res, result);
64 } catch (err) {
65 sendError(res, 500, ERR.INTERNAL, err.message);
66 }
67});
68
69// POST /root/:rootId/prune/undo - Restore a pruned node
70router.post("/root/:rootId/prune/undo", authenticate, async (req, res) => {
71 try {
72 const { nodeId } = req.body;
73 if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId is required");
74 const result = await undoPrune(nodeId, req.userId);
75 sendOk(res, result);
76 } catch (err) {
77 sendError(res, 500, ERR.INTERNAL, err.message);
78 }
79});
80
81// GET /root/:rootId/prune/history - What was shed and absorbed
82router.get("/root/:rootId/prune/history", authenticate, async (req, res) => {
83 try {
84 const rootId = validateRootId(req, res);
85 if (!rootId) return;
86 const root = await Node.findById(rootId).select("metadata").lean();
87 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
88
89 const pruneMeta = _metadata.getExtMeta(root, "prune");
90
91 sendOk(res, {
92 history: pruneMeta.history || [],
93 totalPruned: (pruneMeta.history || []).reduce((sum, h) => sum + (h.pruned || 0), 0),
94 totalAbsorbed: (pruneMeta.history || []).reduce((sum, h) => sum + (h.absorbed || 0), 0),
95 });
96 } catch (err) {
97 sendError(res, 500, ERR.INTERNAL, err.message);
98 }
99});
100
101// POST /root/:rootId/prune/purge - Permanent removal (irreversible)
102router.post("/root/:rootId/prune/purge", authenticate, async (req, res) => {
103 try {
104 const rootId = validateRootId(req, res);
105 if (!rootId) return;
106 const result = await purge(rootId, req.userId);
107 sendOk(res, result);
108 } catch (err) {
109 sendError(res, 500, ERR.INTERNAL, err.message);
110 }
111});
112
113export default router;
114
Loading comments...