Skip to content

Node Link Engine, Registry & Scheduler

dazeb edited this page Sep 17, 2026 · 2 revisions

Node Link Engine, Registry & Scheduler

src/core/links/ is the rules-and-execution core for node links: registry.ts declares which links are legal and extracts source content, engine.ts executes one link, and service.ts wires the pure core to live app state (nodes, projects, PTYs, chat, A2A peers). The auto-run debounce lives one level up in src/core/links-scheduler.ts. All four modules are Electron-free and dependency-injected, so the desktop main process and the Server Edition build the same service, and the engine never throws — a broken link can fail without taking the app down.

Module map

File Responsibility
src/core/links/registry.ts Kind matrix (LINK_SOURCES / LINK_TARGETS), validateLink, connectableLinkKinds, linkDefaultConfig, sanitizeTitle, and extractContent (node → SourceContent). Pure; IO arrives as ExtractDeps (capturePane, readFile).
src/core/links/engine.ts runLink — executes one link end to end per kind: file write/append, chat/terminal injection, A2A forward. No fs/pty/network of its own; every effect rides LinkEngineDeps. Never throws.
src/core/links/service.ts LinkService — the wiring layer that materializes links: maps endpoints to nodes, project roots, and fs paths; builds LinkEngineDeps from live state; records run results; owns the scheduler.
src/core/links-scheduler.ts LinkScheduler — per-link debounce timers, dirty tracking, generation guards, boot-race replay, and dispose.

Each core file ships a colocated vitest suite (engine.test.ts, registry.test.ts, service.test.ts); dependency injection is what keeps those tests pure.

Link kinds

Kind Valid sources Valid targets Default config Effect
file-output terminal, sticky, editor, chat file (pseudo-target) { path: '', mode: 'overwrite', header: true } Write/append the source text to .termsprawl/outputs/<sanitizeTitle(title)>.md (or config.path), capped at 1 MB.
context-inject terminal, sticky, editor, chat chat, terminal { wrapper: true, pastePointer: true } Chat target: append a user message. Terminal target: stage a context file and optionally paste a pointer into the PTY.
a2a-peer terminal, chat a2a-peer (pseudo-target) { message: 'last-output', deliverReply: false } Forward the extracted source text to a configured A2A peer.

file and a2a-peer are deliberately not node kinds: a file-output link connects visually to any node (its title seeds the default filename) but really writes a file, and an a2a-peer link targets the configured peer, never a node.

Registry: legality and extraction

Kind matrix and validation

  • LINK_SOURCES maps each kind to the node kinds that may source it; LINK_TARGETS maps each kind to the node kinds (or pseudo-targets) it may inject into.
  • validateLink(kind, sourceKind, targetKind) returns null when valid or a human-readable error: unknown link kind: …, a <source> node cannot source a <kind> link, or a <kind> link cannot target a <target> node.
  • connectableLinkKinds(sourceKind, targetKind) governs what the canvas may create by dragging an edge between two nodes. It is narrower than LINK_TARGETS: a qualifying source always offers file-output (any visual anchor), plus context-inject only when the target is chat/terminal. It never returns a2a-peer — those links are created from the A2A send UI, not by node dragging.
  • linkDefaultConfig supplies the initial options for a new link (see the kind table above).
  • sanitizeTitle lowercases the title, collapses runs of non-[a-z0-9] into -, trims leading/trailing dashes, and falls back to output ("My Agent!" → my-agent). defaultOutputPath uses it for the default filename.

Content extraction (extractContent)

  • Guard: an editor node with data.remote throws before any local read — a remote path must never be interpreted on this machine. This is the one extraction path that throws; the service catches it and records a failed run.
  • sticky → trimmed data.text (or empty), title sticky.
  • terminal → deps.capturePane(nodeId), title data.title ?? 'terminal'.
  • editor → requires data.path, reads via deps.readFile, title baseName(path).
  • chat → with an a2a-peer config using message: 'last-output', returns the last non-empty assistant message as text; otherwise formats every non-empty message as ${role}: ${content} and returns kind conversation. Title data.title ?? 'chat'.
  • Unknown kinds and any dependency failure inside the extraction try block degrade fail-open to { kind: 'empty' } — a dead PTY session or unreadable file simply skips the run.

Engine: running one link

runLink(link, input, deps) takes the extracted SourceContent, the targetKind/targetData, the resolved projectRoot, and a LinkEngineDeps bag, and returns LinkRunResult ({ ok, summary }). The outer try/catch means every failure path is a value, never a thrown exception.

  • Early exit: source.kind === 'empty' → source is empty.
  • file-output: truncates at MB = 1_000_000 (decimal MB) and appends <!-- truncated at 1MB -->; resolves the output path via deps.resolveOutputPath (throws on escape → output path escapes project root); optionally prepends <!-- termsprawl link <id> @ <ISO timestamp> --> using deps.now(); mkdirp(dirname) then appendFile (with a newline separator) or writeFile. Summary: wrote <relPath>.
  • context-inject: for a chat target, content is optionally wrapped as [context from <source title>]\n<text> and handed to deps.chatInject(link.target, { role: 'user', content }, sourceTitle). For a terminal target it writes the raw text to stagedContextPath(target) (.termsprawl/links/context/<targetNodeId>.md) and, when pastePointer is set, sends \x1b[200~[termsprawl] context staged: <relPath> — run /termsprawl-context to read\x1b[201~ through deps.ptyWrite (bracketed paste keeps the shell from interpreting it). Any other target kind fails with a context-inject link cannot target <kind>.
  • a2a-peer: deps.a2aSend(link.target, source.text, { deliverReply }); the summary notes a reply when one came back. Delivering that reply into a linked chat node or terminal is explicitly the caller's job — the engine stays pure.
  • Every case re-checks link.config.kind and returns unknown link kind on mismatch; the default branch does the same for unrecognized kinds.
flowchart TD
  A["runLink(link, input, deps)"]
  A --> B{"source.kind is 'empty'?"}
  B -- yes --> F0["fail: source is empty"]
  B -- no --> C{"link.kind"}
  C -- file-output --> D1["truncate at 1MB if needed"]
  D1 --> D2{"resolveOutputPath throws?"}
  D2 -- yes --> F1["fail: output path escapes project root"]
  D2 -- no --> D3["optional header; append mode adds separator"]
  D3 --> D4["mkdirp(dirname) then writeFile / appendFile"]
  D4 --> O1["ok: wrote relPath"]
  C -- context-inject --> E0{"targetKind"}
  E0 -- chat --> E1["chatInject (wrapper option)"]
  E1 --> O2["ok: injected into chat target"]
  E0 -- terminal --> E2["write staged context file"]
  E2 --> E3{"pastePointer?"}
  E3 -- yes --> E4["ptyWrite bracketed-paste pointer"]
  E3 -- no --> O3
  E4 --> O3["ok: staged context for target"]
  E0 -- other --> F2["fail: cannot target this kind"]
  C -- a2a-peer --> G1["a2aSend(peer, text, deliverReply)"]
  G1 --> O4["ok: sent to peer"]
  C -- "default / config mismatch" --> F3["fail: unknown link kind"]
Loading

Key nodes: the file-output branch is the only one that both truncates and does path-escape validation; the context-inject branch splits on targetKind because chat injection rides an event channel while terminal injection writes a real file plus an optional PTY paste; the a2a-peer branch never touches the filesystem. All of this sits inside one try/catch, so any unexpected throw — including from an injected dep — becomes link failed: <message>.

Service: materializing links against live state

LinkService owns the runtime instance of the scheduler and adapts persisted NodeLink records to the pure core. It operates across all projects: a background terminal's auto-link keeps running when its tab is not active.

  • Construction: new LinkService(deps) creates new LinkScheduler(async (linkId) => { await this.runById(linkId) }) and seeds it with deps.allLinks(). linksChanged() re-seeds the scheduler after any link edit.
  • Dirty signals: notePtyActivity(nodeId) (PTY output on a terminal/agent node) and markDirty(sourceId) (renderer-side sticky/chat/editor content changes) both forward the source node id to LinkScheduler.markDirty, which fans it out to that source's auto links.
  • runById(linkId): used by both the manual "Run now" action and the scheduler. Looks up the link via deps.findLink; unknown id → link not found; otherwise runLink(link, projectId).
  • runLink(link, projectId) is the materialization path:
    • Resolves the project via projectOfNode(source) ?? projectOfNode(target). Folder projects root outputs at project.cwd; cwd-less (inline/remote) projects get <userData>/link-outputs/<projectId>.
    • extractByNode looks the node up in deps.nodesOfProject(projectId) and calls registry extractContent with capturePane/readFile adapters. Extraction errors (e.g. a remote editor source) are caught, recorded via recordLinkRun(…, false, …), and returned as a failed run — before the engine ever runs.
    • Resolves targetKind: 'a2a-peer' for a2a links; otherwise the target node's type/data.kind, falling back to 'file' when no node is found — which is exactly the file-output pseudo-target, so file links keep working even if the visual anchor disappears.
    • Builds LinkEngineDeps from live state: fs writes (mkdir recursive + writeFile/appendFile), resolveOutputPath (throws OUTSIDE on escape), chatInject → deps.chatBroadcast(nodeId, { kind: 'context-added', messageId: 'ctx-…', role, content, sourceTitle }) (the chat commit itself rides the ChatNode), ptyWrite, and a2aSend (injects sourceNodeId: link.source).
    • After the engine returns, persists the outcome with recordLinkRun(projectId, link.id, Date.now(), result.ok, result.summary) — best-effort, never throws.
  • sendNodeToPeer(nodeId, peerId): one-shot A2A send outside the link model — extracts the node's content with no config, fails on an empty source, sends with deliverReply: false, and truncates any reply to 120 characters in the summary.
  • stagedPathFor(targetNodeId) exposes stagedContextPath for tests and CLI docs; dispose() tears down the scheduler.

Scheduler: debounced auto-run

LinkScheduler runs a link only after its source goes quiet for a debounce gap — default gapMs is 3000 ms, and each link tracks its own timer so bursts coalesce into the last dirty mark. The class is epoch-guarded: dispose() invalidates queued callbacks so a project switch cancels pending runs.

State Meaning
links: Map<id, NodeLink> Known link list, replaced wholesale by setLinks.
timers: Map<id, timeout> Per-link debounce timer; cleared on reschedule or invalidation.
generations: Map<id, number> Invalidates callbacks queued for an older version of a link (edit, source change, removal).
pendingDirty: Set<sourceId> Dirty sources observed before links were loaded (boot race); replayed on the next setLinks.
inFlight / dirtyWhileInFlight Track a run currently executing and dirt observed while it runs.
disposed Terminal flag; every entry point no-ops after dispose().
  • setLinks(links): for every previously known link that is now missing, no longer auto, or has a different source, it clears the timer, removes it from dirtyWhileInFlight, and bumps its generation. It then swaps the map and replays pendingDirty through markDirty.
  • markDirty(sourceId, link?): with an explicit link, schedules only when link.source === sourceId && link.auto. Otherwise it scans all links sourced by sourceId and schedules the auto ones. If the link map is empty or the source has no known links, the id is remembered in pendingDirty for replay.
  • schedule(linkId): refuses when disposed, unknown, or not auto; clears any pending timer for the link (last dirty wins); captures the current generation; after gapMs, re-checks that the generation is unchanged before invoking onRun. An edit that lands during the gap therefore cancels the stale run.

Call chain

sequenceDiagram
  autonumber
  participant Src as PTY activity / Renderer
  participant Svc as LinkService
  participant Sch as LinkScheduler
  participant Reg as registry.extractContent
  participant Eng as engine.runLink
  participant Deps as Injected deps (fs, chat, PTY, A2A)

  Src->>Svc: notePtyActivity(nodeId) / markDirty(sourceId)
  Svc->>Sch: markDirty(sourceId)
  Sch->>Sch: schedule auto links (debounce gapMs, generation-guarded)
  Sch->>Svc: onRun(linkId)
  Svc->>Svc: findLink(linkId) then runLink(link, projectId)
  Svc->>Svc: resolve project root (cwd or userData/link-outputs/<projectId>)
  Svc->>Reg: extractContent(node, adapters, config)
  Reg-->>Svc: SourceContent (text | conversation | empty)
  Svc->>Eng: runLink(link, {source, targetKind, targetData, projectRoot}, deps)
  Eng->>Deps: writeFile / chatInject / ptyWrite / a2aSend
  Eng-->>Svc: LinkRunResult { ok, summary }
  Svc->>Deps: recordLinkRun(projectId, linkId, at, ok, summary)
Loading

Key steps: dirty signals are keyed by source node and fan out to links inside the scheduler; the scheduler is the only place a run is delayed or cancelled; LinkService.runLink is the single choke point that both manual runs (runById) and scheduled runs pass through, and it is where extraction, project-root resolution, dep construction, and run recording happen in order. A2A reply delivery (when deliverReply is set) happens outside this chain, in the caller that owns sendToPeer.

Boundary conditions

  • Empty source → source is empty (engine run or one-shot peer send); no side effects occur.
  • Remote editor sources are never read locally; extraction throws, and the service records a failed run.
  • Output-path escape is rejected in resolveOutputPath (throws OUTSIDE) and surfaced as output path escapes project root; the same guard covers both the default .termsprawl/outputs/… path and the staged context path.
  • File output is capped at 1 MB (decimal), with a <!-- truncated at 1MB --> marker appended.
  • Missing target node falls back to target kind file, so file-output still materializes.
  • Config-kind mismatches and unknown kinds yield unknown link kind; a context-inject link to an unsupported target yields a context-inject link cannot target <kind>.
  • Failures are values, not exceptions: neither runLink function throws, and recordLinkRun is best-effort.
  • Scheduler edge cases: disposed instances no-op; the boot race is covered by pendingDirty replay; project switches and link edits bump generations so queued runs are cancelled; rescheduling clears the previous timer so bursts coalesce (default quiet gap 3 s).
  • cwd-less projects redirect all link outputs under <userData>/link-outputs/<projectId>.

Extension points

  • New link kind: extend LinkKind / LinkConfig in @shared/types; add entries to LINK_SOURCES and LINK_TARGETS; add a linkDefaultConfig case; add a runLink branch in the engine. If it has a new side effect, add a method to LinkEngineDeps and wire it in LinkService's deps object plus LinkServiceDeps.
  • New source node kind: add it to the relevant LINK_SOURCES entries and add an extractContent case returning SourceContent.
  • New injectable target: add it to LINK_TARGETS and implement the branch in runLink.
  • Canvas drag creation: connectableLinkKinds decides what edge-dragging may create; the a2a-peer kind demonstrates opting out of drag creation entirely.
  • New dirty signal: call LinkService.markDirty(sourceId) (or notePtyActivity for PTY-backed sources) rather than poking the scheduler directly.
  • Scheduler tuning: LinkSchedulerOptions.gapMs is injectable, so tests and future policies can shorten the debounce without touching the service.

Sources: src/core/links/engine.ts Sources: src/core/links/registry.ts Sources: src/core/links/service.ts Sources: src/core/links-scheduler.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally