Pipeline builder dispatch kinds#

Note

This page is generated by scripts/generate_dispatch_reference.py from phenotypic.gui.builder._callbacks.DispatchKind plus a hand-curated mapping that mirrors spec §5.6 verbatim. Run the script after touching either to regenerate; the --check flag is wired into CI to catch drift.

Every state-mutating event in the pipeline builder funnels through a single fan-in callback that dispatches via phenotypic.gui.builder._callbacks._dispatch_state_update(). Each kind value is one of the literals enumerated below; the payload schemas come straight from spec §5.6 of the Builder canvas — DAG redesign design document.

Dispatch flow per mutation:

  1. Pre-mutation: the dispatcher runs _seed_input_image on every reachable scope (idempotent guard for validation Rule 6).

  2. Mutation: the named kind branch applies to a deep-copied state dict.

  3. Post-mutation: the fan-in callback re-runs phenotypic.gui.builder._validation.validate(); the resulting list lands in STORE_ISSUES, drives the toolbar count badge, and gates Run preview / Save pipeline (see Pipeline builder validation rules).

The kinds are documented in declaration order so the literal alias and this page never diverge.

Legacy linear builder#

add_node#

Payload schema: {class_name: str}

Append a fresh StepNode for class_name to the current legacy scope. Selects the new node.

add_pipeline#

Payload schema: {}

Append a StepNode carrying an empty nested scope.

select_node#

Payload schema: {node_id: str}

Set selected_node_id.

delete_node#

Payload schema: {}

Remove selected_node_id from the current scope.

drill_in#

Payload schema: {}

Push selected_node_id onto the breadcrumb (only if that node has a nested scope).

drill_out#

Payload schema: {}

Pop the breadcrumb tail.

reorder#

Payload schema: {order: list[str]}

Reorder the current scope’s nodes by node-id sequence.

edit_param#

Payload schema: {node_id: str, name: str, value: Any, omit: bool}

Set or delete params[name] on a specific node.

edit_label#

Payload schema: {node_id: str, label: str}

Update the node label.

port_slot_add#

Payload schema: {node_id: str, param: str}

Append a None slot to the consumer’s aux_ports list for a list-typed param. No-op for scalar ports.

port_slot_remove#

Payload schema: {node_id: str, param: str, slot: int}

Remove the slot at slot from the consumer’s aux_ports list and reindex remaining slots.

DAG canvas: palette drag-and-drop#

block_create#

Payload schema: {class_name: str, x: float, y: float, container_block_id: str | None, ts: int}

Append a fresh BlockNode to the root scope (when container_block_id is None) or to a container’s nested scope (DFS lookup, innermost-wins hit-test per spec §4.4). Rejects class_name == "InputImage" with a toast — Input Image is auto-seeded per scope and cannot be created from the palette (spec §4.8 + §4.10). Drop coordinates (x, y) are not persisted; the leaf-first dagre pass re-lays the canvas on the next render (spec §4.7).

DAG canvas: wire drawing#

edge_create#

Payload schema: {source_block_id: str, target_block_id: str, target_port: str, edge_kind: "image" | "aux", ts: int}

Mint an Edge between two ports inside the same scope. For scalar aux + image-flow: replaces any existing wire from source_block_id (single-wire rule, spec §4.2 / §4.3). For list aux: server-side append — the dispatcher resolves target_slot = block.list_slot_counts.get(target_port, 0) and increments list_slot_counts[target_port] by 1 (eliminates the concurrent-drag race; the client emits no slot index). Cross-scope wires are rejected with a toast (spec §4.4).

edge_delete#

Payload schema: {edge_id: str, ts: int}

Remove a single edge by edge_id (DFS across every scope). For list-aux edges, remaining edges’ target_slot values are NOT renumbered — the freed slot becomes an empty placeholder. Use list_aux_reorder to compact.

DAG canvas: list-aux fan-in#

list_aux_reorder#

Payload schema: {block_id: str, param: str, new_order: list[str | None], ts: int}

Update the canonical execution order of a list-typed aux port. new_order is a permutation of the wired edge_ids interspersed with None placeholders for empty slots; the dispatcher rebuilds each edge’s target_slot from its position in the new order and updates block.list_slot_counts[param] = len(new_order). Non-permutation inputs are rejected with a toast (no-op).

list_aux_add_empty_slot#

Payload schema: {block_id: str, param: str, ts: int}

Increment block.list_slot_counts[param] by 1. No edge is materialised — empty slots live solely on the consumer block. At to_pipeline_dag time, slot indices in [0, count) not covered by an edge emit None entries.

DAG canvas: selection#

wire_select#

Payload schema: {edge_id: str | None, ts: int}

Set selected_edge_id. None deselects. Setting a new id clears selected_block_id (mutual exclusion, spec §4.5).

block_select#

Payload schema: {block_id: str | None, ts: int}

Set selected_block_id. None deselects. Setting a new id clears selected_edge_id (mutual exclusion, spec §4.5).

DAG canvas: Pipeline containers#

block_reparent#

Payload schema: {block_id: str, new_parent_block_id: str | None, x: float, y: float, ts: int}

Move a block between scopes. new_parent_block_id == None promotes the block to the current scope; a non-None value adopts it into that container’s nested scope (sibling-container moves are a single atomic dispatch). Rejects InputImage block_ids (the source must remain in its scope). Drag-out direction (new parent is an ancestor) with orphan edges → snap-back + toast; drag-in / sibling direction with orphan edges → drop the incompatible edges + toast the count, then commit the move (spec §4.4).

block_collapsed_toggle#

Payload schema: {block_id: str, ts: int}

Toggle block.collapsed for a Pipeline container. No-op on non-container blocks (anything whose class_name != "ImagePipeline"). The visual expand/collapse state is per-block (per spec §4.4).

drill_into_container#

Payload schema: {block_id: str, ts: int}

Push block_id onto state.breadcrumb. Validated: block_id must resolve to a Pipeline container at the current breadcrumb depth (siblings at other depths are rejected silently).

drill_to_scope#

Payload schema: {target_breadcrumb: list[str], ts: int}

Atomic breadcrumb replacement — replaces state.breadcrumb with target_breadcrumb in one dispatch. Each block_id in target_breadcrumb must resolve to a real Pipeline container at the right depth in state.root; stale ids → reject + toast. Used by scroll_to for cross-scope navigation.

block_delete_request#

Payload schema: {block_id: str, ts: int}

First stage of the two-stage container delete. Rejects InputImage block_ids (defense in depth). Non-container OR empty container (only the auto-seeded InputImage sentinel inside) → delegates to block_delete_confirm immediately. Non-empty container → sets state.pending_delete_block_id = block_id which opens the confirm-delete modal (body: “Delete container <label> and its N inner block(s)?” where N excludes InputImage).

block_delete_confirm#

Payload schema: {block_id: str, ts: int}

Second stage (or single stage if no confirmation was needed). Atomically removes the block, its nested scope (if any) including every inner block + edge, and every edge in the containing scope whose source or target is the block. Clears selected_block_id / selected_edge_id / pending_delete_block_id when they pointed at the deleted block or one of its edges.