-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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.
| 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.
-
LINK_SOURCESmaps each kind to the node kinds that may source it;LINK_TARGETSmaps each kind to the node kinds (or pseudo-targets) it may inject into. -
validateLink(kind, sourceKind, targetKind)returnsnullwhen valid or a human-readable error:unknown link kind: …,a <source> node cannot source a <kind> link, ora <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 thanLINK_TARGETS: a qualifying source always offersfile-output(any visual anchor), pluscontext-injectonly when the target ischat/terminal. It never returnsa2a-peer— those links are created from the A2A send UI, not by node dragging. -
linkDefaultConfigsupplies the initial options for a new link (see the kind table above). -
sanitizeTitlelowercases the title, collapses runs of non-[a-z0-9]into-, trims leading/trailing dashes, and falls back tooutput("My Agent!"→my-agent).defaultOutputPathuses it for the default filename.
-
Guard: an
editornode withdata.remotethrows 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→ trimmeddata.text(orempty), titlesticky. -
terminal→deps.capturePane(nodeId), titledata.title ?? 'terminal'. -
editor→ requiresdata.path, reads viadeps.readFile, titlebaseName(path). -
chat→ with ana2a-peerconfig usingmessage: 'last-output', returns the last non-empty assistant message astext; otherwise formats every non-empty message as${role}: ${content}and returns kindconversation. Titledata.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.
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 atMB = 1_000_000(decimal MB) and appends<!-- truncated at 1MB -->; resolves the output path viadeps.resolveOutputPath(throws on escape →output path escapes project root); optionally prepends<!-- termsprawl link <id> @ <ISO timestamp> -->usingdeps.now();mkdirp(dirname)thenappendFile(with a newline separator) orwriteFile. Summary:wrote <relPath>. -
context-inject: for achattarget, content is optionally wrapped as[context from <source title>]\n<text>and handed todeps.chatInject(link.target, { role: 'user', content }, sourceTitle). For aterminaltarget it writes the raw text tostagedContextPath(target)(.termsprawl/links/context/<targetNodeId>.md) and, whenpastePointeris set, sends\x1b[200~[termsprawl] context staged: <relPath> — run /termsprawl-context to read\x1b[201~throughdeps.ptyWrite(bracketed paste keeps the shell from interpreting it). Any other target kind fails witha 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.kindand returnsunknown link kindon mismatch; thedefaultbranch 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"]
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>.
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)createsnew LinkScheduler(async (linkId) => { await this.runById(linkId) })and seeds it withdeps.allLinks().linksChanged()re-seeds the scheduler after any link edit. -
Dirty signals:
notePtyActivity(nodeId)(PTY output on a terminal/agent node) andmarkDirty(sourceId)(renderer-side sticky/chat/editor content changes) both forward the source node id toLinkScheduler.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 viadeps.findLink; unknown id →link not found; otherwiserunLink(link, projectId). -
runLink(link, projectId)is the materialization path:- Resolves the project via
projectOfNode(source) ?? projectOfNode(target). Folder projects root outputs atproject.cwd; cwd-less (inline/remote) projects get<userData>/link-outputs/<projectId>. -
extractByNodelooks the node up indeps.nodesOfProject(projectId)and calls registryextractContentwithcapturePane/readFileadapters. Extraction errors (e.g. a remote editor source) are caught, recorded viarecordLinkRun(…, false, …), and returned as a failed run — before the engine ever runs. - Resolves
targetKind:'a2a-peer'for a2a links; otherwise the target node'stype/data.kind, falling back to'file'when no node is found — which is exactly thefile-outputpseudo-target, so file links keep working even if the visual anchor disappears. - Builds
LinkEngineDepsfrom live state: fs writes (mkdirrecursive +writeFile/appendFile),resolveOutputPath(throwsOUTSIDEon escape),chatInject→deps.chatBroadcast(nodeId, { kind: 'context-added', messageId: 'ctx-…', role, content, sourceTitle })(the chat commit itself rides the ChatNode),ptyWrite, anda2aSend(injectssourceNodeId: link.source). - After the engine returns, persists the outcome with
recordLinkRun(projectId, link.id, Date.now(), result.ok, result.summary)— best-effort, never throws.
- Resolves the project via
-
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 withdeliverReply: false, and truncates any reply to 120 characters in the summary. -
stagedPathFor(targetNodeId)exposesstagedContextPathfor tests and CLI docs;dispose()tears down the scheduler.
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 longerauto, or has a differentsource, it clears the timer, removes it fromdirtyWhileInFlight, and bumps its generation. It then swaps the map and replayspendingDirtythroughmarkDirty. -
markDirty(sourceId, link?): with an explicit link, schedules only whenlink.source === sourceId && link.auto. Otherwise it scans all links sourced bysourceIdand schedules theautoones. If the link map is empty or the source has no known links, the id is remembered inpendingDirtyfor replay. -
schedule(linkId): refuses when disposed, unknown, or notauto; clears any pending timer for the link (last dirty wins); captures the current generation; aftergapMs, re-checks that the generation is unchanged before invokingonRun. An edit that lands during the gap therefore cancels the stale run.
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)
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.
- Empty source →
source is empty(engine run or one-shot peer send); no side effects occur. - Remote
editorsources are never read locally; extraction throws, and the service records a failed run. - Output-path escape is rejected in
resolveOutputPath(throwsOUTSIDE) and surfaced asoutput 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, sofile-outputstill materializes. - Config-kind mismatches and unknown kinds yield
unknown link kind; acontext-injectlink to an unsupported target yieldsa context-inject link cannot target <kind>. - Failures are values, not exceptions: neither
runLinkfunction throws, andrecordLinkRunis best-effort. - Scheduler edge cases: disposed instances no-op; the boot race is covered by
pendingDirtyreplay; 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>.
-
New link kind: extend
LinkKind/LinkConfigin@shared/types; add entries toLINK_SOURCESandLINK_TARGETS; add alinkDefaultConfigcase; add arunLinkbranch in the engine. If it has a new side effect, add a method toLinkEngineDepsand wire it inLinkService's deps object plusLinkServiceDeps. -
New source node kind: add it to the relevant
LINK_SOURCESentries and add anextractContentcase returningSourceContent. -
New injectable target: add it to
LINK_TARGETSand implement the branch inrunLink. -
Canvas drag creation:
connectableLinkKindsdecides what edge-dragging may create; thea2a-peerkind demonstrates opting out of drag creation entirely. -
New dirty signal: call
LinkService.markDirty(sourceId)(ornotePtyActivityfor PTY-backed sources) rather than poking the scheduler directly. -
Scheduler tuning:
LinkSchedulerOptions.gapMsis 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance