Backend Overview
ProcessServices, WorkspaceComponentRegistry, JobScheduler, LoopRunner, GraphExecutor, NODE_HANDLERS β the backend as a running system
This is the "you are here" map of the backend: not what every file holds (that's codebase-map) and not the platform's structure (that's architecture), but the backend as a set of running objects β who owns whom, what's a singleton vs per-run, and how a graph actually gets executed. The hard fact that ties it together: almost everything is per-process and reached through one ProcessServices singleton; the only cross-object rendezvous is a single global table, NODE_HANDLERS. Read this first, then follow the links into the deep dives.
Read top to bottom β one spine. Both entry paths, Canvas Play and Batch Eval, converge on the same LoopRunner β GraphExecutor engine; only the wrapping above it differs. The engine fires nodes by querying NODE_HANDLERS β the one table the WorkspaceComponentRegistry fills and ProcessServices holds β and there is one such stack per process (the backend, and every eval subprocess).
1. What the backend is
The backend (agentcanvas/backend/app/) is a pure execution engine with zero domain knowledge (ADR-platform-001): it discovers domain code from workspace/ at runtime and runs graphs against it. As a running system it is a handful of per-process singletons hung off one ProcessServices, plus short-lived per-run objects that the API spins up on demand. Three things explain almost everything:
- One
ProcessServicesper process. Reached viaget_services()β a module singleton. It holds theWorkspaceComponentRegistryand (after startup) theJobScheduler. There is no single global state across the system: the backend process has one, and every eval subprocess has its own. - One global handler table,
NODE_HANDLERS. The registry writes it; everyGraphExecutorreads it. That table is the only rendezvous between "what's installed" and "what runs" β see Β§4. - One engine, spun up per run. A
LoopRunner(canvas singleton, or fresh per eval episode) builds aGraphExecutorthat fires the graph's nodes. Details: execution-paths.
Follow one Play click
The smallest end-to-end trace β what one POST /api/navigate/run touches, in order:
run.py Β· run_loopgrabsExecutionGuard(canvas), validates the graph, and asksget_services().workspace_component_registry.ensure_nodesets_for_graph()to make sure every node type is inNODE_HANDLERS.get_loop_runner()returns the singletonLoopRunner; a fire-and-forgetasynciotask callsrunner.run(graph).LoopRunner.run()builds a freshGraphExecutorandawaits it.GraphExecutor.run()drives the ready-queue: for each node,NODE_HANDLERS[type].forward(). Server-mode nodes call out over HTTP to a registry-ownedauto_hostsubprocess.- Outputs stream over WebSocket (
broadcast()); the guard releases whenrun()returns.
That's the canvas path in brief β the full request path, interactive controls, and live streaming are in canvas-play; the headless eval path is batch-eval.
2. The per-process model
This is the single most load-bearing fact, and the one that surprises people: get_services() is a module global, so "singleton" means per Python process, not per system.
- The backend (uvicorn) process has one
ProcessServicesβ oneWorkspaceComponentRegistryβ oneNODE_HANDLERSβ the canvasLoopRunnersingleton. - Each eval subprocess (
eval_subprocess_main) is a separate process with a freshProcessServices/registry of its own (eval_subprocess_main.pynotes "Subprocess starts with a fresh ProcessServices"). Rather than reload models, it attaches to the parent's already-runningauto_hostservers viaregister_remote_nodeset(a_RemoteAutoServerShimwhosestop()is a no-op β it doesn't own them).
So "who owns the registry / who owns ProcessServices" has the same answer in every process β that process's get_services() β and the chain tops out at the OS process (uvicorn for the backend, eval_subprocess_main for an eval run). There is no class above ProcessServices.
3. Process-global singletons
Everything in this table is created once per process and lives for the whole process. "Reached via" is how any code gets a handle.
| Object | Role | Reached via / built |
|---|---|---|
ProcessServices | The per-process state container. Holds the registry + scheduler. | state.py Β· get_services() (module singleton, lazy) |
WorkspaceComponentRegistry | Discovers nodesets/nodes in workspace/, writes NODE_HANDLERS, owns server-mode subprocesses (via BaseServer), handles load/unload + hot-reload. | ProcessServices.workspace_component_registry β built in ProcessServices.__init__ |
JobScheduler | Eval admission (per-resource measured gate: calibrated estimate vs measured free VRAM/RAM minus reservations, declared fallback; canvas lock free) + FIFO queue; spawns each eval run as a subprocess. | ProcessServices.job_scheduler β built later in main.py Β· lifespan (needs VRAM); None until then |
LoopRunner (canvas) | The canvas Play runner β lifecycle shell (pause/stop/resume) that builds a GraphExecutor per run. | loop_runner.py Β· get_loop_runner() (module singleton) |
NODE_HANDLERS | Global table node_type β BaseCanvasNode class. The seam between "installed" (registry) and "running" (executor). | builtin_nodes.py module global; register_node() mutates it |
ExecutionGuard | Single-tenant lock β only one of canvas/eval active at a time (in-process). | state.py Β· ExecutionGuard (class-level statics) |
ErrorBus | In-process pub/sub; every exception/log/HTTP-error becomes an error_event WS frame + Report-tab entry. | errors.py Β· get_bus() |
| WS broadcast set | The connected WebSocket clients; broadcast() fans messages to all. | state.py Β· _ws_connections + broadcast() |
| Watchers | Background tasks: graph-dir watcher (push graphs_changed) + nodeset-source watcher (hot-reload .py edits). | services/graph_watcher, nodeset_watcher β lifespan tasks |
4. Per-run objects & the one seam
Per-run objects are created by the API/orchestrator and discarded at end-of-run. They are not owned by the registry β they only ever read its output table.
| Object | Lifetime | Where |
|---|---|---|
LoopRunner (per run) | Canvas: the singleton, reused. Eval: fresh per episode. | eval_batch.py Β· _run_one_episode (eval) / the singleton (canvas) |
GraphExecutor | One per LoopRunner.run() β built fresh, discarded at end. | graph_executor.py Β· GraphExecutor.run |
ExecutionLogger | One per run/episode (JSONL + assets). | logging/logger.py |
BatchEvalRunner / EnvWorkerPool | One per eval run (eval-only). | eval_batch.py / env_worker_pool.py |
The seam. The registry and the executor never call each other β they meet at NODE_HANDLERS. The LoopRunner doesn't touch the table at all.
Concretely: register_node() + the register path in registry.py populate NODE_HANDLERS (last-write-wins; unload evicts); GraphExecutor does NODE_HANDLERS.get(node.type) then .forward() (graph_executor.py). The executor reaches the registry directly in exactly one narrow place β to wire up a RemoteContainerProxy when a node is granted a state container that lives in another process (the cross-nodeset container-access path, via get_services().workspace_component_registry); otherwise the table is the whole contract. This is why the registry is 1 and GraphExecutors are N: N executors all read the one table the one registry filled. For the firing engine itself, see graph-executor.
The two run paths
A graph reaches GraphExecutor by one of two routes β each now its own code-grounded deep doc, so this hub only names them:
- Canvas Play β interactive, in-process, single-tenant (
ExecutionGuard), driven by the singletonLoopRunner, streamed live: canvas-play. - Batch eval β headless, spawned in its own subprocess by
JobScheduler(the backend loads only the shared singletons, then hands the run their borrowed URLs), a freshLoopRunnerper episode across N workers: batch-eval.
Both converge on the same LoopRunner β GraphExecutor engine and fire against the same NODE_HANDLERS table (Β§4 above) β the divergence is entirely in the wrapping. The class-by-class comparison of the two paths is execution-paths.
5. Startup & shutdown (lifespan)
The whole runtime is wired in main.py Β· lifespan (the FastAPI startup/shutdown hook). Order matters:
- ErrorBus first β attach the event loop, subscribe the WS fan-out, install the log bridge (so every later subsystem's logs surface).
state = get_services()β constructsProcessServicesβ constructsWorkspaceComponentRegistry(empty so far).workspace_component_registry.scan_all()β discoverworkspace/components β fillsNODE_HANDLERS.await workspace_component_registry.initialize_all()β initialize nodesets (the slow part).- Build
JobScheduler(query VRAM, resolveoutputs/eval_runs/), wire the canvas-lock callback + registry, reconcile aborted runs. - Launch background tasks: the scheduler loop (1 s tick β eval admit/reap + orphaned-canvas-guard reaper), the graph watcher, the nodeset-source watcher.
yieldβ serve requests.- On shutdown: cancel the tasks,
job_scheduler.shutdown(), thenworkspace_component_registry.shutdown_all()(stops all server subprocesses).
6. Where to go deeper
| You want⦠| Read |
|---|---|
| How a run is driven, both paths, class-by-class | execution-paths |
| The interactive canvas run β in-process, live, pausable | canvas-play |
| The firing engine β ready-queue, iteration, termination | graph-executor Β· loop-control-system |
| Batch eval internals β workers, parallelism, persistence | batch-eval |
| Server-mode subprocesses β auto_host, manifest, lifecycle | server-mode |
How a node author plugs into NODE_HANDLERS | base-canvas-node Β· nodesets |
| Where every file lives (per-package) | codebase-map |
| Platform structure (incl. frontend) + tech stack | architecture |
7. Gotchas (where the clean model bites)
ProcessServicess = N registries = N NODE_HANDLERS tables. An eval subprocess cannot see the backend's in-memory objects; it re-scans from disk and attaches to shared servers over HTTP (register_remote_nodeset). Reasoning that assumes "one global registry" is wrong the moment eval forks.
JobScheduler is built late. ProcessServices.__init__ sets job_scheduler = None; it only becomes real inside lifespan (after VRAM detection). Code that reaches get_services().job_scheduler before startup completes gets None.
NODE_HANDLERS is mutable global state. Hot-reload and unload mutate it in place (last-write-wins; evict-on-unload). A node deleted from disk can linger in the table until a reload or restart (registry.py notes this). It is process-local, so the table the eval subprocess fires against is the one it scanned, not the backend's.
LoopRunner is a process singleton behind ExecutionGuard β you cannot run two canvas graphs at once. Eval avoids the contention by running in its own subprocess (default); the legacy in-process eval shares the very same guard.