Skip to content

Finish lifting the per-kind node methods out of SceneDocument - #411

Merged
dovvnloading merged 2 commits into
mainfrom
refactor/split-remaining-node-ops
Sep 4, 2026
Merged

Finish lifting the per-kind node methods out of SceneDocument#411
dovvnloading merged 2 commits into
mainfrom
refactor/split-remaining-node-ops

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

SceneDocument is where a node kind lives. Adding one means editing that
class, 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.state is typed
NodeState | None against a field-less marker class, so every
node.state.<kind>_<field> access in those methods was unverifiable — which
is why [tool.mypy].files had only ever reached three modules inside
backend/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 what
the kinds actually share rather than one module per kind:

module mixin kinds
nodes_agent_runs.py AgentRunOps web_research, artifact, code_sandbox — the three 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

graph.py goes from 2,591 lines to 1,502, and holds no per-kind methods at
all. Nothing outside backend/domain/ changes: SceneDocument is composed
from 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.unparse round-trip against
git show HEAD:backend/domain/graph.py), absent from SceneDocument's own
body afterwards, reachable on SceneDocument, and defined exactly once
across 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.

SceneDocumentParts gains adopt_pending_system_prompt, which
add_chat_node calls and which stays on SceneDocument. 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, but now that ConversationalOps defines it, an inexact declaration
in 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 five
modules now check clean.

Nine were a straight swap: they already checked the kind and raised the same
two messages require_node raises.

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 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 SceneError (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.

[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 in the config comment as
the next piece of work rather than waved through.

Notes

  • 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.
  • Lift the two largest per-kind method groups out of SceneDocument #409 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 instead of being dropped.

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 check . clean. mypy clean across 42 source files.
  • The new guard test was verified to fail when a guard is removed and pass
    when it is restored, so it is not a vacuous gate.
  • Existing coverage is the real regression check here: 497 tests across
    test_canvas.py, test_agents.py and test_review_lens_backend.py
    exercise these methods and were untouched.

🤖 Generated with Claude Code

dovvnloading and others added 2 commits September 4, 2026 12:45
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
dovvnloading merged commit 9626e0d into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the refactor/split-remaining-node-ops branch September 4, 2026 17:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant