ADR-server-002
Generic BaseController contract supersedes env-specific EnvController
Context
ADR-legacy-002 introduced canvas episode control via three parallel layers: a standalone EnvController Protocol + HabitatEnvController + _controllers registry (app/env_controller.py, 343 lines), an env-specific REST router (app/api/canvas/env.py, 233 lines), a Habitat-aware React component (EnvContextBar.tsx), and 5 hidden env_habitat__ep_* bridge tools in habitat.py to make server mode work via /call/{fn}. Adding any future env nodeset (Matterport3D, AI2-THOR, RxR per TODOs E1/E4) required editing every layer and re-creating the bridge tools — the opposite of the "all things in single nodeset" principle embodied by BaseNodeSet, BaseCanvasNode, and AutoServerApp. Additionally, server-mode episode control was deferred to TODO #35 because the HabitatEnvController/hidden-tool bridge was awkward.
Decision
(1) BaseController ABC in app/components/controllers.py — declarative contract with name/display_name/fields/actions ClassVars, abstract on_load(), concrete on_field_change()/on_action()/get_options() defaults, and a _context attribute the registry stamps with mode and server_url. Paired ControllerField (select/number/text/slider) and ControllerAction (with side_effect: run_start|run_pause|run_stop|run_step|none) dataclasses describe the panel UI. (2) Opt-in ClassVar controller: ClassVar[type | None] = None on BaseNodeSet. Nodesets set it to their BaseController subclass; WorkspaceComponentRegistry reads it on load and instantiates + registers the controller via _register_controller_for. (3) One generic REST router /api/controllers with 5 routes (GET /, GET /{name}/state, GET /{name}/options/{field}, POST /{name}/field/{field}, POST /{name}/action/{action}). _require_idle() via ExecutionGuard blocks field changes and run_start/none actions during execution; run_pause/run_stop pass through. Zero env-specific code. Replaces api/canvas/env.py entirely. (4) One generic frontend panel ControllerPanel.tsx — picker dropdown lists every registered controller from GET /api/controllers, renders declared fields via a select/number/text/slider dispatcher, renders actions as color-coded icon buttons (play green, pause yellow, stop red, reset orange), interprets side_effect to dispatch existing runPipeline/navPause/navStop store calls. Persists active selection in localStorage[agentcanvas:active_controller]. Self-manages via nodesets-changed window event — no env-detection useEffect in CanvasPage. Replaces EnvContextBar.tsx entirely. (5) Server-mode bridge: ServerApp._build_app gains an optional get_controller_instance() hook that, when non-None, mounts 5 matching /controller/* routes next to /call/{fn}. AutoServerApp.on_startup instantiates nodeset.controller() inside the spawned subprocess (where the env actually lives) with _context.mode="local". WorkspaceComponentRegistry._register_remote_controller(name, server_url) — called from _load_nodeset_as_server — httpx.GETs /controller/info from the subprocess and registers a RemoteControllerProxy(BaseController) in the agentcanvas main process that forwards every method call over HTTP. The panel works identically in local and server modes; the proxy is transparent. (6) HabitatController inside workspace/nodesets/server/habitat.py — subclasses BaseController, reads HabitatEnvManager directly, on_field_change("split", ...) triggers shutdown+reinit via the single-thread executor, on_field_change("episode_index", ...) caches the index and fetches preview info, on_action("play") calls set_episode_by_index then returns side_effect: run_start, on_action("pause"|"stop") returns side_effect: run_{name}, get_options("episode_index") lists all episodes via mgr.get_episodes_list(0, 10000). Supersedes all 5 hidden env_habitat__ep_* tools which are deleted. (7) Eval/canvas parity: BatchEvalOrchestrator (ADR-eval-001) now calls get_controller(env_nodeset).on_field_change("episode_index", i) + on_action("play") per episode instead of the old env_ctrl.set_episode(i). Eval and canvas share the exact same env-control code path.
Alternatives
(a) Controller as a canvas node — clicking Play, Pause, Stop on a node wired into the graph, with the node's execute() doing the env reset at step 0. Conceptually elegant (pre-flight concept disappears entirely), but rejected: the node can scroll out of view during a long run hurting discoverability, a node reaching into LoopRunner inverts the dependency direction, new users wouldn't know to add a controller node before Play, and the run lifecycle state (pause/stop/resume) has nowhere natural to live if multiple controller nodes exist. (b) Keep ADR-legacy-002's HabitatEnvController but move it into habitat.py — shrinks dependency graph but leaves the REST router + frontend component still env-specific. Rejected: only solves one-third of the problem. (c) Extend AutoServerApp to host control-plane routes in local mode too — would let the same /controller/* routes serve both modes via sub-app mounting. Rejected: would grow AutoServerApp's responsibility beyond "server-mode subprocess transport" (ADR-server-001), conflating local and server paths, and the local-mode nodeset already has direct in-process access to its controller — no transport needed. (d) Use an @control_endpoint decorator that marks arbitrary methods on a BaseNodeSet as HTTP routes — rejected: conflates tool methods (typed ports, wire-type inputs) with control-plane methods (form-like inputs, actions) and bypasses the ExecutionGuard layering story. (e) Restore the hidden env_habitat__ep_* tools forever — rejected: duplicates the control plane across every env nodeset (one set per env), clutters the node type registry, and still requires the env-specific HabitatEnvController to wrap them.
Rationale
The controller is fundamentally declarative data (fields + actions) plus four small async hooks. Making it a first-class base class lets the framework route it uniformly without per-env router or UI code. The side_effect channel is load-bearing: it keeps controllers pure data-and-state objects that never import LoopRunner, while still letting the UI Play button trigger runPipeline. The server-mode bridge is the key win: RemoteControllerProxy + /controller/* on AutoServerApp turns the server-mode gap (TODO #35) from "not yet supported" into "works transparently" without re-adding hidden tools. The controller runs in the subprocess where the env lives, so its on_load reads real state; the main process sees a proxy that forwards calls. This is structurally symmetrical to how proxy canvas nodes already handle tool calls across the subprocess boundary (ADR-server-001), and it keeps AutoServerApp's responsibility unchanged — it still only does server-mode subprocess transport, just now for controller endpoints in addition to /call/{fn}. Net: ~800 lines deleted (old env-specific layers + hidden tools), ~480 lines added (new module + router + panel + proxy + HabitatController + design doc), one unified contract instead of three parallel layers.
Affected docs
design-docs/controllers.md (new), mkdocs.yml (nav entry), roadmap.md (TODO #35 partial — server-mode episode control now works via bridge; full server-mode canvas execution remains separate)