Finish lifting the per-kind node methods out of SceneDocument - #411
Merged
Conversation
PR #409 moved the two largest per-kind method groups (code_review, gitlink) into their own mixins and left the other thirteen kinds behind. This moves those thirteen, which is the rest of that job: 42 members and 983 lines out of graph.py, taking it from 2,591 lines to 1,502. Five new mixins, grouped by what the kinds actually have in common rather than one module per kind: nodes_agent_runs.py AgentRunOps web_research, artifact, code_sandbox - the three kinds with a start/progress/complete/ fail run lifecycle nodes_planning.py PlanningOps plan, harness nodes_conversational.py ConversationalOps chat, conversation nodes_content.py ContentOps document, thinking, html, note nodes_visual.py VisualOps chart, image Every one of the 42 moved members was verified AST-identical to its pre-move version (ast.unparse round-trip against `git show HEAD:backend/domain/graph.py`), verified absent from SceneDocument's own body afterwards, verified reachable on SceneDocument, and verified to be defined exactly once across the whole MRO. Methods are regrouped by kind inside each new module instead of keeping the order that successive increments happened to append them in; nothing else changed. Two supporting edits: SceneDocumentParts gains `adopt_pending_system_prompt`, which add_chat_node calls and which stays on SceneDocument, and its `add_chat_node` declaration is tightened from a `*args: Any, **kwargs: Any` hedge to the real signature. The hedge was fine while the body lived in SceneDocument itself; now that ConversationalOps defines it, an inexact declaration in a base class is an incompatible-override error rather than a harmless fiction. #409 also left two section-header comments in graph.py describing methods it had just moved away, plus 21 stray blank lines where they used to sit. The headers carried real information about those kinds' import posture, so that text moves into the nodes_gitlink.py and nodes_code_review.py module docstrings rather than being dropped. Test plan: full suite, 3,226 passed / 19 skipped. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five mixins the previous commit created reported 92 mypy errors, every one the same union-attr shape: SceneNode.state is typed `NodeState | None` against a field-less marker, so `node.state.research_stage` cannot be verified. All 92 sat in exactly 17 methods, and all 17 obtain their node with the same hand-written preamble require_node/optional_node (backend/domain/node_access.py, PR #410) already replaces. Converted; the five modules now check clean. Nine of the seventeen were a straight swap - they already checked the kind and raised the same two messages require_node raises. The other eight are a behaviour change and should be read as one. The completion and failure paths for web_research, artifact and code_sandbox looked their node up WITHOUT checking its kind, on the reasoning - written into two of the docstrings - that the id had already been validated earlier in the same request by the matching start_* call. That is true, and it is still true: the check is redundant on every live path. It is also exactly why the gap was invisible. Node states are plain, non-slotted dataclasses, so `node.state.research_stage = "completed"` against a chat node does not fail; it grafts a phantom attribute onto ChatState and returns happily. backend/tests/test_wrong_kind_node_guards.py pins the new contract for all eight, plus complete_gitlink_run and complete_gitlink_apply, which gained the same check in #409 with nothing asserting it. Each case is checked three ways: the wrong-kind call raises (or, for the fail_* methods, returns None, matching their documented silence when the node has gone), and the wrong-kind node's state is left byte-identical. Verified to fail when a guard is removed. One error message changed: set_html_splitter_state said "node is not an html node" and now says "a html node", the wording every other kind produces. Nothing in the repo or the client reads it. [tool.mypy].files widens from 3 backend/domain/ modules to 13 of 18. The five left out are graph.py and the four cross-cutting mixins (groups/branches/commands/layout), which hold 124 errors of the same shape - they read per-kind state off nodes they look up generically, so require_node has no kind to narrow to. Recorded as the next piece of work rather than waved through. Test plan: full suite, 3,244 passed / 19 skipped. Two unrelated subprocess-timing tests (test_mcp_client stdin-drain, test_execution_guard grandchild-kill) failed under load in that run and pass on their own; neither imports backend.domain. ruff clean; mypy clean across 42 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dovvnloading
added a commit
that referenced
this pull request
Sep 4, 2026
…age (#412) PR #411 left 124 errors in backend/domain/, all in graph.py and the four cross-cutting mixins (groups 71, branches 26, commands 10, layout 8, graph 9). Every one was the same union-attr shape the per-kind mixins had: SceneNode.state is typed `NodeState | None` against a field-less marker. require_node could not reach them. It looks a node up by id and narrows on the kind, and this code mostly does neither - it iterates every node in the scene and skips what it does not want, or narrows a node handed to it as a parameter. Three additions close that: * is_node_of(node, kind, state_cls) - a TypeGuard for a node already in hand. TypeGuard, not TypeIs, so the repo's declared 3.10 floor is enough and nothing imports typing_extensions at runtime. It narrows only the positive branch, so a continue-on-wrong-kind loop reads as `if is_node_of(...)` instead of `if not ...: continue`. It also requires a non-None state, matching the `and node.state is not None` conjunct every hand-written check it replaces already carried. * require_node/optional_node now take a tuple of kinds as well as one. Group geometry is frame-or-container throughout. `" or ".join` of a one-element tuple is that element, so the single-kind error message is byte-identical. * GroupSizedState, a base holding group_width/group_height, which FrameState and ContainerState now inherit instead of each declaring. FrameState's docstring explains at length why those two kinds are not one class: is_locked and the group_manual_* quartet are meaningless for a container. That reasoning is about those five fields and is unchanged. group_width/group_height are common to both, are named for both by the wire contract, and are read for both by every piece of group geometry - which is exactly the code no single per-kind class could describe. Neither kind's field set changes; the two shared fields are declared one level up. Nothing serializes by field order (there is no asdict/fields() call anywhere in the domain), and both construction sites use keywords. SceneDocumentParts gains last_chat_node_id, final_deliverable_node_id and current_chat_id. Without them BranchOps inferred `str` from its own first assignment and then rejected the `= None` two lines later - which is how SceneDocument's own `final_deliverable_node_id: str | None = None` declaration ended up reported as an error against itself. Four non-narrowing fixes, none behavioural: * _bbox_of_members initialised four accumulators to None on one line and tested only the first before using all four. They are set together or not at all; the condition now says so. * layout.py rebound `parent` (a str elsewhere in the module) to a SceneNode, and passed `fid if fid is not None else cid` to a function taking str after a guard that proves it is not None. Renamed, and hoisted into a variable the guard can narrow. * branches.py reused the `edge` loop variable for a list that also holds an optional edge. * Command.invert/apply took `document: object` and then read four attributes off it. They take SceneDocumentParts, which declares exactly those four. [tool.mypy].files now lists backend/domain rather than individual modules: all 18, up from 3 at the start of the day. 48 source files check clean. Test plan: full suite, 3,245 passed / 19 skipped. The one failure, test_cancel_during_backoff_aborts_promptly, asserts a cancel completes within a 2.0s wall-clock budget; it fails the same way on a clean checkout of main under load, and is unrelated to this change. 117 frame/container/ group/bbox/measured-size tests pass, which is the real check on the _recompute_group_bounds restructure. ruff clean. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
SceneDocumentis where a node kind lives. Adding one means editing thatclass, and it had grown to 2,591 lines holding methods for fifteen different
kinds side by side. PR #409 moved the two largest groups (
code_review,gitlink) into their own mixins and left the other thirteen behind.A second problem sat underneath it.
SceneNode.stateis typedNodeState | Noneagainst a field-less marker class, so everynode.state.<kind>_<field>access in those methods was unverifiable — whichis why
[tool.mypy].fileshad only ever reached three modules insidebackend/domain/.Change
Two commits, best read in order.
1. The relocation
42 members and 983 lines out of
graph.py, into five mixins grouped by whatthe kinds actually share rather than one module per kind:
nodes_agent_runs.pyAgentRunOpsnodes_planning.pyPlanningOpsnodes_conversational.pyConversationalOpsnodes_content.pyContentOpsnodes_visual.pyVisualOpsgraph.pygoes from 2,591 lines to 1,502, and holds no per-kind methods atall. Nothing outside
backend/domain/changes:SceneDocumentis composedfrom mixins, so every existing caller keeps working untouched — the wire
payload, both persistence sides, the intent modules and the client never see
this.
Every one of the 42 moved members was verified four ways: AST-identical to
its pre-move version (an
ast.unparseround-trip againstgit show HEAD:backend/domain/graph.py), absent fromSceneDocument's ownbody afterwards, reachable on
SceneDocument, and defined exactly onceacross the whole MRO. Methods are regrouped by kind inside each new module
instead of keeping the order successive increments happened to append them
in; nothing else changed.
SceneDocumentPartsgainsadopt_pending_system_prompt, whichadd_chat_nodecalls and which stays onSceneDocument. Itsadd_chat_nodedeclaration is tightened from a
*args: Any, **kwargs: Anyhedge to the realsignature — the hedge was fine while the body lived in
SceneDocumentitself, but now that
ConversationalOpsdefines it, an inexact declarationin a base class is an incompatible-override error rather than a harmless
fiction.
2. The narrowing
Those five modules reported 92 mypy errors, all of the union-attr shape
above, and all 92 sat in exactly 17 methods — each opening with the same
hand-written preamble that
require_node/optional_node(
backend/domain/node_access.py, #410) already replaces. Converted; the fivemodules now check clean.
Nine were a straight swap: they already checked the kind and raised the same
two messages
require_noderaises.The other eight gained a kind check they did not have, and that is a
behaviour change. The completion and failure paths for web_research,
artifact and code_sandbox looked their node up without checking its kind, on
the reasoning — written into two of the docstrings — that the id had already
been validated by the matching
start_*call earlier in the same request.That is true, and still true: the check is redundant on every live path.
It is also why the gap was invisible. Node states are plain, non-slotted
dataclasses, so
node.state.research_stage = "completed"against a chat nodedoes not fail — it grafts a phantom attribute onto
ChatStateand returnshappily.
backend/tests/test_wrong_kind_node_guards.pypins the new contract for alleight, plus
complete_gitlink_runandcomplete_gitlink_apply, which gainedthe same check in #409 with nothing asserting it. Each case is checked three
ways: the wrong-kind call raises
SceneError(or, for thefail_*methods,returns
None, matching their documented silence when the node has gone),and the wrong-kind node's state is left byte-identical.
[tool.mypy].fileswidens from 3backend/domain/modules to 13 of 18. Thefive left out are
graph.pyand the four cross-cutting mixins(groups/branches/commands/layout), which hold 124 errors of the same shape —
they read per-kind state off nodes they look up generically, so
require_nodehas no kind to narrow to. Recorded in the config comment asthe next piece of work rather than waved through.
Notes
set_html_splitter_statesaid"node is not an html node"and now says"a html node", the wording every other kindproduces. Nothing in the repo or the client reads it.
graph.pydescribing methods ithad just moved away, plus 21 stray blank lines where they used to sit. The
headers carried real information about those kinds' import posture, so that
text moves into the
nodes_gitlink.pyandnodes_code_review.pymoduledocstrings instead of being dropped.
Test plan
tests (
test_mcp_clientstdin-drain,test_execution_guardgrandchild-kill) failed under load in that run and pass on their own;
neither imports
backend.domain.ruff check .clean.mypyclean across 42 source files.when it is restored, so it is not a vacuous gate.
test_canvas.py,test_agents.pyandtest_review_lens_backend.pyexercise these methods and were untouched.
🤖 Generated with Claude Code