EXTENSION for treeos-cascade
propagation
The foundation of the cascade network. Nothing else works without it. Listens to onCascade and does the actual work of moving signals through the tree. When the kernel fires onCascade because content was written at a node with metadata.cascade configured, propagation receives the node, the content, and the direction. If the direction is outward, it walks children[] downward, checking each child node for metadata.cascade to determine if it should continue deeper. It respects cascadeMaxDepth from the kernel safety config. At each node it delivers to, it writes a result to .flow using the kernel result shape. Succeeded if delivered. Failed if something broke. Rejected if the node filters said no. For cross-land signals, it checks .peers for active peer connections and sends through Canopy to peered lands. The receiving land propagation extension picks it up from their side. Owns its own extension config in metadata.propagation on the .config node: propagationTimeout, propagationRetries, and defaultCascadeMode. These are not kernel configs. Different lands can have completely different propagation behavior because the extension is replaceable.
v1.0.1 by TreeOS Site 0 downloads 6 files 763 lines 25.2 KB published 38d ago
treeos ext install propagation
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 1 CLI commands

Requires

  • services: hooks, cascade
  • models: Node
SHA256: 2407f735d3e0c6247728c77836cf8d43b293e8fb575a44b6dc9496f8183be143

Dependents

7 packages depend on this

PackageTypeRelationship
mycelium v1.0.1extensionneeds
treeos-cascade v1.0.1bundleincludes
channels v1.0.1extensionneeds
sealed-transport v1.0.1extensionneeds
perspective-filter v1.0.1extensionneeds
gap-detection v1.0.1extensionneeds
long-memory v1.0.1extensionneeds

CLI Commands

CommandMethodDescription
cascadeGETCascade status and control. No action shows status. Actions: trigger, retry, config.
cascade triggerPOSTManually fire checkCascade at current position
cascade retryPOSTRetry failed hops from this node
cascade configGETShow cascade config on this node

Hooks

Listens To

  • onCascade

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star propagation

Comments

Loading comments...

Post comments from the CLI: treeos ext comment propagation "your comment"
Max 3 comments per extension. One star and one flag per user.