1/**
2 * Propagation Core
3 *
4 * The actual work of moving cascade signals through the tree.
5 * Walks children[] outward. Checks each child's metadata.cascade
6 * to decide whether to continue deeper. Sends cross-land via Canopy.
7 * Retries failed hops from .flow on a timer.
8 */
9
10import log from "../../seed/log.js";
11import { deliverCascade, getCascadeResults } from "../../seed/tree/cascade.js";
12
13// Node model wired from init via setModels
14let Node = null;
15export function setModels(models) { Node = models.Node; }
16import { getLandConfigValue } from "../../seed/landConfig.js";
17import { SYSTEM_ROLE } from "../../seed/protocol.js";
18
19/**
20 * Get propagation config from .config node metadata.propagation.
21 * These are NOT kernel configs. They are propagation's own settings.
22 */
23export async function getPropagationConfig() {
24 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
25 if (!configNode) return defaults();
26
27 const meta = configNode.metadata instanceof Map
28 ? configNode.metadata.get("propagation") || {}
29 : configNode.metadata?.propagation || {};
30
31 return {
32 propagationTimeout: meta.propagationTimeout ?? 10000,
33 propagationRetries: meta.propagationRetries ?? 3,
34 defaultCascadeMode: meta.defaultCascadeMode ?? "open",
35 };
36}
37
38function defaults() {
39 return {
40 propagationTimeout: 10000,
41 propagationRetries: 3,
42 defaultCascadeMode: "open",
43 };
44}
45
46/**
47 * Propagate a cascade signal outward through children.
48 *
49 * Called by the onCascade hook handler. Walks children[] of the source node,
50 * calling deliverCascade on each child. deliverCascade fires onCascade at
51 * the child, which this handler picks up again, continuing deeper.
52 *
53 * The recursion guard: deliverCascade checks cascadeMaxDepth from kernel config.
54 * We also check each child's metadata.cascade to decide whether to continue.
55 * A child without cascade enabled receives the signal but does not propagate further.
56 *
57 * @param {object} opts
58 * @param {object} opts.node - the node where the signal originated
59 * @param {string} opts.nodeId - the node's ID
60 * @param {string} opts.signalId - ties the full cascade chain together
61 * @param {object} opts.payload - signal data (writeContext from kernel or relay payload)
62 * @param {number} opts.depth - current propagation depth
63 * @param {object} opts.cascadeConfig - the node's metadata.cascade
64 */
65export async function propagateOutward({ node, nodeId, signalId, payload, depth, cascadeConfig }) {
66 const children = node.children;
67 if (!children || children.length === 0) return [];
68
69 const config = await getPropagationConfig();
70 const maxDepth = parseInt(getLandConfigValue("cascadeMaxDepth") || "50", 10);
71
72 // Don't propagate if we're already at max depth
73 if (depth >= maxDepth) {
74 log.debug("Propagation", `Depth limit (${maxDepth}) reached at ${node.name || nodeId}. Stopping.`);
75 return [];
76 }
77
78 // Check cascade mode: sealed signals don't propagate to children
79 // unless the child explicitly opts in with metadata.cascade.acceptSealed = true
80 const mode = cascadeConfig?.mode || config.defaultCascadeMode;
81
82 const results = [];
83
84 for (const childId of children) {
85 const childIdStr = childId.toString();
86
87 // Load child to check its cascade willingness
88 const child = await Node.findById(childIdStr).select("name metadata systemRole children parent").lean();
89 if (!child || child.systemRole) continue;
90
91 const childMeta = child.metadata instanceof Map
92 ? Object.fromEntries(child.metadata)
93 : (child.metadata || {});
94
95 const childCascade = childMeta.cascade;
96
97 // In sealed mode, skip children that don't accept sealed signals
98 if (mode === "sealed" && !childCascade?.acceptSealed) continue;
99
100 // Perspective filter: if installed, check whether this child's perspective accepts the signal
101 try {
102 const { getExtension } = await import("../loader.js");
103 const perspectiveExt = getExtension("perspective-filter");
104 if (perspectiveExt?.exports?.shouldDeliver) {
105 const accepted = await perspectiveExt.exports.shouldDeliver(child, payload);
106 if (!accepted) {
107 log.debug("Propagation", `Signal ${signalId.slice(0, 8)} rejected by perspective at "${child.name}"`);
108 continue;
109 }
110 }
111 } catch (err) {
112 log.debug("Propagation", "perspective filter check failed:", err.message);
113 }
114
115 // Filter: if child has cascade.filters, check them
116 if (childCascade?.filters) {
117 const accepted = evaluateFilters(childCascade.filters, payload);
118 if (!accepted) {
119 log.debug("Propagation", `Signal ${signalId.slice(0, 8)} filtered out at "${child.name}"`);
120 continue;
121 }
122 }
123
124 try {
125 // Sealed transport: if signal is sealed, intermediary nodes get redacted payload.
126 // Destination nodes (leaves with no cascade-enabled children) get the full payload.
127 let deliveryPayload = payload;
128 try {
129 const { getExtension } = await import("../loader.js");
130 const sealedExt = getExtension("sealed-transport");
131 if (sealedExt?.exports?.isSealed?.(cascadeConfig, payload)) {
132 const hasChildren = child.children && child.children.length > 0;
133 if (hasChildren) {
134 deliveryPayload = sealedExt.exports.sealPayload(payload);
135 }
136 }
137 } catch (err) {
138 log.debug("Propagation", "sealed-transport check failed:", err.message);
139 }
140
141 // deliverCascade writes to .flow and fires onCascade at the child.
142 // If the child has cascade.enabled, our handler fires again (recursion).
143 // If not, the child receives the signal but does not propagate deeper.
144 const result = await withTimeout(
145 deliverCascade({
146 nodeId: childIdStr,
147 signalId,
148 payload: deliveryPayload,
149 source: nodeId,
150 depth: depth + 1,
151 }),
152 config.propagationTimeout,
153 );
154 results.push(result);
155 } catch (err) {
156 log.warn("Propagation", `Hop to "${child.name}" failed: ${err.message}`);
157 results.push({
158 status: "failed",
159 source: childIdStr,
160 payload: { reason: err.message },
161 timestamp: new Date(),
162 signalId,
163 extName: "propagation",
164 });
165 }
166 }
167
168 return results;
169}
170
171/**
172 * Propagate a cascade signal to peered lands via Canopy.
173 *
174 * Checks .peers for active peer connections and sends through
175 * the cascade API endpoint on each peer. The receiving land's
176 * propagation extension picks it up from their side.
177 */
178export async function propagateToPeers({ nodeId, signalId, payload, depth }) {
179 let LandPeer;
180 try {
181 const mod = await import("../../canopy/models/landPeer.js");
182 LandPeer = mod.default;
183 } catch {
184 return []; // LandPeer model not available
185 }
186
187 let getPeerBaseUrl;
188 try {
189 const mod = await import("../../canopy/peers.js");
190 getPeerBaseUrl = mod.getPeerBaseUrl;
191 } catch {
192 return [];
193 }
194
195 const peers = await LandPeer.find({ status: "active" });
196 if (peers.length === 0) return [];
197
198 const config = await getPropagationConfig();
199 const results = [];
200
201 for (const peer of peers) {
202 const baseUrl = getPeerBaseUrl(peer);
203 const url = `${baseUrl}/api/v1/node/${nodeId}/cascade`;
204
205 try {
206 const res = await withTimeout(
207 fetch(url, {
208 method: "POST",
209 headers: { "Content-Type": "application/json" },
210 body: JSON.stringify({
211 signalId,
212 payload,
213 source: nodeId,
214 depth: depth + 1,
215 }),
216 signal: AbortSignal.timeout(config.propagationTimeout),
217 }),
218 config.propagationTimeout + 1000, // outer safety
219 );
220
221 if (res.ok) {
222 const data = await res.json();
223 results.push({ peer: peer.domain, status: "delivered", result: data.result });
224 log.debug("Propagation", `Cross-land to ${peer.domain}: delivered`);
225 } else {
226 results.push({ peer: peer.domain, status: "failed", httpStatus: res.status });
227 log.warn("Propagation", `Cross-land to ${peer.domain}: HTTP ${res.status}`);
228 }
229 } catch (err) {
230 results.push({ peer: peer.domain, status: "failed", reason: err.message });
231 log.warn("Propagation", `Cross-land to ${peer.domain} failed: ${err.message}`);
232 }
233 }
234
235 return results;
236}
237
238/**
239 * Retry failed cascade hops from .flow.
240 * Loads all recent results, finds entries with status "failed",
241 * and re-attempts delivery.
242 */
243export async function retryFailedHops() {
244 const config = await getPropagationConfig();
245
246 // Load .flow results
247 const flowNode = await Node.findOne({ systemRole: SYSTEM_ROLE.FLOW }).select("metadata").lean();
248 if (!flowNode) return { retried: 0, succeeded: 0 };
249
250 const allResults = flowNode.metadata instanceof Map
251 ? flowNode.metadata.get("results") || {}
252 : flowNode.metadata?.results || {};
253
254 let retried = 0;
255 let succeeded = 0;
256 const maxRetries = config.propagationRetries;
257
258 for (const [signalId, entries] of Object.entries(allResults)) {
259 if (!Array.isArray(entries)) continue;
260
261 for (const entry of entries) {
262 if (entry.status !== "failed") continue;
263 if (entry._retryCount >= maxRetries) continue;
264
265 const targetNodeId = entry.source;
266 if (!targetNodeId) continue;
267
268 retried++;
269
270 try {
271 const result = await deliverCascade({
272 nodeId: targetNodeId,
273 signalId,
274 payload: entry.payload?.originalPayload || entry.payload || {},
275 source: entry.payload?.originalSource || targetNodeId,
276 depth: entry.payload?.depth || 0,
277 });
278
279 if (result.status === "succeeded") succeeded++;
280 } catch (err) {
281 log.debug("Propagation", `Retry for ${signalId.slice(0, 8)} at ${targetNodeId.slice(0, 8)} failed: ${err.message}`);
282 }
283 }
284 }
285
286 if (retried > 0) {
287 log.verbose("Propagation", `Retry job: ${retried} retried, ${succeeded} succeeded`);
288 }
289
290 return { retried, succeeded };
291}
292
293/**
294 * Evaluate cascade filters against a payload.
295 * Filters are an array of { field, op, value } objects.
296 * All must pass (AND logic).
297 */
298function evaluateFilters(filters, payload) {
299 if (!Array.isArray(filters) || filters.length === 0) return true;
300
301 for (const filter of filters) {
302 const { field, op, value } = filter;
303 const actual = payload?.[field];
304
305 switch (op) {
306 case "eq": if (actual !== value) return false; break;
307 case "ne": if (actual === value) return false; break;
308 case "exists": if ((actual != null) !== value) return false; break;
309 case "contains":
310 if (typeof actual !== "string" || !actual.includes(value)) return false;
311 break;
312 default: break;
313 }
314 }
315
316 return true;
317}
318
319/**
320 * Race a promise against a timeout.
321 */
322function withTimeout(promise, ms) {
323 let timer;
324 return Promise.race([
325 promise,
326 new Promise((_, reject) => {
327 timer = setTimeout(() => reject(new Error(`Propagation timeout (${ms}ms)`)), ms);
328 }),
329 ]).finally(() => clearTimeout(timer));
330}
331
1/**
2 * Propagation Extension
3 *
4 * The foundation of the cascade network. Listens to onCascade and does
5 * the actual work of moving signals through the tree. When the kernel
6 * fires onCascade because content was written at a cascade-enabled node,
7 * propagation walks children[] outward, checking each child's cascade
8 * config to determine if the signal should continue deeper.
9 *
10 * For cross-land signals, checks .peers for active peer connections
11 * and sends through Canopy. The receiving land's propagation extension
12 * picks it up from their side via deliverCascade.
13 *
14 * Depends on: kernel cascade primitive only.
15 */
16
17import log from "../../seed/log.js";
18import tools from "./tools.js";
19import { propagateOutward, propagateToPeers } from "./core.js";
20import { startRetryJob, stopRetryJob } from "./retryJob.js";
21
22export async function init(core) {
23 const { setModels } = await import("./core.js");
24 setModels(core.models);
25 // ── The one hook: onCascade ──────────────────────────────────────────
26 //
27 // When the kernel fires onCascade, we receive the node, the signal,
28 // and the depth. Our job: walk children outward and deliver cross-land.
29 //
30 // The kernel calls checkCascade at depth 0 (local write).
31 // We call deliverCascade on each child, which fires onCascade again
32 // at depth+1. This handler fires again, recurses. deliverCascade
33 // checks cascadeMaxDepth so the chain is bounded.
34
35 core.hooks.register("onCascade", async (hookData) => {
36 const { node, nodeId, signalId, writeContext, payload, cascadeConfig, depth } = hookData;
37
38 // The signal data is writeContext (from checkCascade) or payload (from deliverCascade)
39 const signalPayload = writeContext || payload || {};
40
41 // Propagate outward through children
42 try {
43 const results = await propagateOutward({
44 node,
45 nodeId,
46 signalId,
47 payload: signalPayload,
48 depth: depth || 0,
49 cascadeConfig: cascadeConfig || {},
50 });
51
52 // If any hops failed, mark the origin result as partial
53 const anyFailed = results.some((r) => r.status === "failed");
54 if (anyFailed && results.length > 0) {
55 hookData._resultStatus = "partial";
56 hookData._resultPayload = {
57 ...signalPayload,
58 propagation: {
59 delivered: results.filter((r) => r.status === "succeeded").length,
60 failed: results.filter((r) => r.status === "failed").length,
61 total: results.length,
62 },
63 };
64 }
65
66 hookData._resultExtName = "propagation";
67 } catch (err) {
68 log.error("Propagation", `Outward propagation failed at ${node?.name || nodeId}: ${err.message}`);
69 hookData._resultStatus = "failed";
70 hookData._resultPayload = { reason: err.message };
71 hookData._resultExtName = "propagation";
72 }
73
74 // Cross-land propagation: send to peered lands if cascade config enables it
75 if (cascadeConfig?.crossLand) {
76 try {
77 const peerResults = await propagateToPeers({
78 nodeId,
79 signalId,
80 payload: signalPayload,
81 depth: depth || 0,
82 });
83
84 if (peerResults.length > 0) {
85 log.debug("Propagation", `Cross-land: ${peerResults.filter((r) => r.status === "delivered").length}/${peerResults.length} peers reached`);
86 }
87 } catch (err) {
88 log.warn("Propagation", `Cross-land propagation failed: ${err.message}`);
89 }
90 }
91 }, "propagation");
92
93 const { default: router } = await import("./routes.js");
94
95 return {
96 router,
97 tools,
98 jobs: [
99 {
100 name: "propagation-retry",
101 start: () => startRetryJob(),
102 stop: () => stopRetryJob(),
103 },
104 ],
105 };
106}
107
1export default {
2 name: "propagation",
3 version: "1.0.1",
4 builtFor: "treeos-cascade",
5 description:
6 "The foundation of the cascade network. Nothing else works without it. Listens to onCascade " +
7 "and does the actual work of moving signals through the tree. When the kernel fires onCascade " +
8 "because content was written at a node with metadata.cascade configured, propagation receives " +
9 "the node, the content, and the direction. If the direction is outward, it walks children[] " +
10 "downward, checking each child node for metadata.cascade to determine if it should continue " +
11 "deeper. It respects cascadeMaxDepth from the kernel safety config. At each node it delivers " +
12 "to, it writes a result to .flow using the kernel result shape. Succeeded if delivered. Failed " +
13 "if something broke. Rejected if the node filters said no. For cross-land signals, it checks " +
14 ".peers for active peer connections and sends through Canopy to peered lands. The receiving " +
15 "land propagation extension picks it up from their side. Owns its own extension config in " +
16 "metadata.propagation on the .config node: propagationTimeout, propagationRetries, and " +
17 "defaultCascadeMode. These are not kernel configs. Different lands can have completely " +
18 "different propagation behavior because the extension is replaceable.",
19
20 needs: {
21 services: ["hooks", "cascade"],
22 models: ["Node"],
23 },
24
25 optional: {},
26
27 provides: {
28 routes: "./routes.js",
29 tools: true,
30 jobs: true,
31
32 hooks: {
33 fires: [],
34 listens: ["onCascade"],
35 },
36
37 cli: [
38 {
39 command: "cascade [action] [args...]", scope: ["tree"],
40 description: "Cascade status and control. No action shows status. Actions: trigger, retry, config.",
41 method: "GET",
42 endpoint: "/node/:nodeId/cascade/status",
43 subcommands: {
44 "trigger": {
45 method: "POST",
46 endpoint: "/node/:nodeId/cascade/trigger",
47 description: "Manually fire checkCascade at current position",
48 },
49 "retry": {
50 method: "POST",
51 endpoint: "/node/:nodeId/cascade/retry",
52 description: "Retry failed hops from this node",
53 },
54 "config": {
55 method: "GET",
56 endpoint: "/node/:nodeId/cascade/config",
57 description: "Show cascade config on this node",
58 },
59 },
60 },
61 ],
62 },
63};
64
1/**
2 * Retry Job
3 *
4 * Background job that periodically scans .flow for failed cascade hops
5 * and retries them. Respects the propagationRetries config.
6 */
7
8import log from "../../seed/log.js";
9import { retryFailedHops } from "./core.js";
10
11const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
12
13let jobTimer = null;
14
15async function run() {
16 try {
17 const { retried, succeeded } = await retryFailedHops();
18 if (retried > 0) {
19 log.verbose("Propagation", `Retry sweep: ${succeeded}/${retried} recovered`);
20 }
21 } catch (err) {
22 log.error("Propagation", `Retry job error: ${err.message}`);
23 }
24}
25
26export function startRetryJob({ intervalMs = DEFAULT_INTERVAL_MS } = {}) {
27 if (jobTimer) clearInterval(jobTimer);
28 jobTimer = setInterval(run, intervalMs);
29 log.info("Propagation", `Retry job started (interval: ${intervalMs / 1000}s)`);
30 return jobTimer;
31}
32
33export function stopRetryJob() {
34 if (jobTimer) {
35 clearInterval(jobTimer);
36 jobTimer = null;
37 log.info("Propagation", "Retry job stopped");
38 }
39}
40
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import Node from "../../seed/models/node.js";
5import { getLandConfigValue } from "../../seed/landConfig.js";
6import { checkCascade, getCascadeResults, getAllCascadeResults } from "../../seed/tree/cascade.js";
7import { retryFailedHops, getPropagationConfig } from "./core.js";
8
9const router = express.Router();
10
11// GET /node/:nodeId/cascade/status - cascade status at this node
12router.get("/node/:nodeId/cascade/status", authenticate, async (req, res) => {
13 try {
14 const node = await Node.findById(req.params.nodeId).select("name metadata").lean();
15 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
16
17 const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
18 const cascadeConfig = meta.cascade || {};
19 const globalEnabled = getLandConfigValue("cascadeEnabled");
20 const propConfig = await getPropagationConfig();
21
22 sendOk(res, {
23 nodeId: req.params.nodeId,
24 nodeName: node.name,
25 cascadeEnabled: globalEnabled === true || globalEnabled === "true",
26 nodeEnabled: !!cascadeConfig.enabled,
27 mode: cascadeConfig.mode || propConfig.defaultCascadeMode,
28 crossLand: !!cascadeConfig.crossLand,
29 filters: cascadeConfig.filters || [],
30 acceptSealed: !!cascadeConfig.acceptSealed,
31 });
32 } catch (err) {
33 sendError(res, 500, ERR.INTERNAL, err.message);
34 }
35});
36
37// POST /node/:nodeId/cascade/trigger - manually fire checkCascade
38router.post("/node/:nodeId/cascade/trigger", authenticate, async (req, res) => {
39 try {
40 const result = await checkCascade(req.params.nodeId, {
41 action: "manual-cascade",
42 triggeredBy: req.userId,
43 });
44 if (!result) {
45 return sendOk(res, { message: "Cascade did not fire. Check cascadeEnabled and node config." });
46 }
47 sendOk(res, { signalId: result.signalId, status: result.result?.status });
48 } catch (err) {
49 sendError(res, 500, ERR.INTERNAL, err.message);
50 }
51});
52
53// POST /node/:nodeId/cascade/retry - retry failed hops from this node
54router.post("/node/:nodeId/cascade/retry", authenticate, async (req, res) => {
55 try {
56 const result = await retryFailedHops();
57 sendOk(res, result);
58 } catch (err) {
59 sendError(res, 500, ERR.INTERNAL, err.message);
60 }
61});
62
63// GET /node/:nodeId/cascade/config - show cascade config
64router.get("/node/:nodeId/cascade/config", authenticate, async (req, res) => {
65 try {
66 const node = await Node.findById(req.params.nodeId).select("metadata").lean();
67 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
68 const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
69 const propConfig = await getPropagationConfig();
70 sendOk(res, { cascadeConfig: meta.cascade || {}, propagationConfig: propConfig });
71 } catch (err) {
72 sendError(res, 500, ERR.INTERNAL, err.message);
73 }
74});
75
76export default router;
77
1import { z } from "zod";
2import { checkCascade, getCascadeResults, getAllCascadeResults } from "../../seed/tree/cascade.js";
3
4export default [
5 {
6 name: "trigger-cascade",
7 description:
8 "Manually fire a cascade signal at a node. The node must have metadata.cascade.enabled = true and cascadeEnabled must be true in land config.",
9 schema: {
10 nodeId: z.string().describe("The node ID to trigger cascade at."),
11 payload: z
12 .record(z.any())
13 .optional()
14 .describe("Optional payload data to include in the signal."),
15 userId: z.string().describe("Injected by server. Ignore."),
16 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
17 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
18 },
19 annotations: {
20 readOnlyHint: false,
21 destructiveHint: false,
22 idempotentHint: false,
23 openWorldHint: false,
24 },
25 handler: async ({ nodeId, payload, userId }) => {
26 try {
27 const writeContext = {
28 action: "manual-cascade",
29 triggeredBy: userId,
30 ...(payload || {}),
31 };
32
33 const result = await checkCascade(nodeId, writeContext);
34
35 if (!result) {
36 return {
37 content: [
38 {
39 type: "text",
40 text: "Cascade did not fire. Either cascadeEnabled is false in land config or the node does not have metadata.cascade.enabled = true.",
41 },
42 ],
43 };
44 }
45
46 return {
47 content: [
48 {
49 type: "text",
50 text: JSON.stringify(
51 {
52 message: "Cascade triggered",
53 signalId: result.signalId,
54 originStatus: result.result?.status,
55 },
56 null,
57 2,
58 ),
59 },
60 ],
61 };
62 } catch (err) {
63 return {
64 content: [{ type: "text", text: `Cascade failed: ${err.message}` }],
65 };
66 }
67 },
68 },
69 {
70 name: "cascade-status",
71 description:
72 "Get cascade results. Pass a signalId to see results for that signal, or omit to see recent signals.",
73 schema: {
74 signalId: z
75 .string()
76 .optional()
77 .describe("Signal ID to check. Omit for recent results."),
78 limit: z
79 .number()
80 .optional()
81 .default(20)
82 .describe("Max number of recent signals to return (default 20)."),
83 userId: z.string().describe("Injected by server. Ignore."),
84 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
85 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
86 },
87 annotations: {
88 readOnlyHint: true,
89 destructiveHint: false,
90 idempotentHint: true,
91 openWorldHint: false,
92 },
93 handler: async ({ signalId, limit }) => {
94 try {
95 if (signalId) {
96 const results = await getCascadeResults(signalId);
97 return {
98 content: [
99 {
100 type: "text",
101 text: JSON.stringify(
102 {
103 signalId,
104 hops: results.length,
105 results: results.map((r) => ({
106 status: r.status,
107 source: r.source,
108 extName: r.extName,
109 timestamp: r.timestamp,
110 })),
111 },
112 null,
113 2,
114 ),
115 },
116 ],
117 };
118 }
119
120 const all = await getAllCascadeResults(limit || 20);
121 const summary = Object.entries(all).map(([sid, entries]) => ({
122 signalId: sid,
123 hops: entries.length,
124 lastStatus: entries[entries.length - 1]?.status,
125 lastTimestamp: entries[entries.length - 1]?.timestamp,
126 }));
127
128 return {
129 content: [
130 {
131 type: "text",
132 text: JSON.stringify({ signals: summary.length, results: summary }, null, 2),
133 },
134 ],
135 };
136 } catch (err) {
137 return {
138 content: [{ type: "text", text: `Failed to read cascade status: ${err.message}` }],
139 };
140 }
141 },
142 },
143];
144
Loading comments...