Graph Inspection Contract

Copy Markdown View Source

Squidie.inspect_run_graph/2 returns a graph-oriented view of one durable workflow run. Use it when a host app, CLI, dashboard, or visual workflow tool needs nodes and edges instead of raw journal history.

The public API still returns structs:

{:ok, graph} = Squidie.inspect_run_graph(run_id)

Use Squidie.Runs.GraphInspection.to_map/1 at the host boundary when a UI needs a stable map payload:

{:ok, graph} = Squidie.inspect_run_graph(run_id)
payload = Squidie.Runs.GraphInspection.to_map(graph)

That keeps existing struct callers compatible while giving UI serializers an explicit shape.

Top-Level Shape

The map shape is:

%{
  run_id: "run_123",
  workflow: "Elixir.MyApp.Workflows.EmailReply",
  source: :read_model,
  status: :running,
  current_node_id: "draft_reply",
  current_node_ids: ["draft_reply"],
  terminal?: false,
  nodes: [...],
  edges: [...],
  child_runs: [],
  child_links: [],
  dynamic_work: [],
  dynamic_work_overlays: [],
  anomalies: []
}

Workflow modules are serialized with Atom.to_string/1, so Elixir modules use the normal "Elixir." prefix. Persisted serialized workflow definitions keep their stored string value.

For runtime-authored specs started with Squidie.start_spec/3 or Squidie.start_spec/4, graph inspection uses the resolved definition persisted on the run. The stored workflow value remains a stable identity, but nodes, edges, action keys, and selected transitions come from the durable spec rather than from loading a workflow module.

current_node_id is the first active node for simple callers. current_node_ids preserves parallel runnable nodes in dependency workflows. terminal? is true when the run is in a terminal state such as :completed, :failed, or :cancelled.

Node Shape

Nodes represent workflow steps:

%{
  id: "draft_reply",
  action: nil,
  status: :running,
  current?: true,
  input: nil,
  output: nil,
  error: nil,
  recovery: nil,
  transition: nil,
  manual_state: nil,
  attempts: []
}

action is the stable host-owned action key when the node came from a spec resolved through Squidie.Workflow.resolve_spec_actions/2. Compiled module-authored workflows usually leave it nil.

recovery is populated from the durable runnable recovery policy when the runtime has one. For compensatable steps it includes the callback module name and status, letting host dashboards show rollback availability without parsing journal entries or loading the current workflow module.

deadline is populated when a declared step or approval has a persisted deadline policy. It contains the evaluated status (:on_time, :due_soon, :overdue, or :escalated), due timestamps, and the declared escalation outcome so graph UIs can decorate nodes without inspecting raw journal entries.

Node status values are:

  • :waiting - no runnable work has been recorded for the node
  • :pending - work is visible or scheduled
  • :running - a worker has an active claim
  • :retrying - a failed attempt scheduled another try
  • :paused - the node is waiting for manual intervention
  • :completed - durable terminal step success exists
  • :failed - durable terminal step failure exists

By default, inputs, outputs, errors, manual state, and attempt details are nil or empty because they can contain host-domain data. Request details explicitly:

{:ok, graph} = Squidie.inspect_run_graph(run_id, include_history: true)
payload = Squidie.Runs.GraphInspection.to_map(graph)

With history enabled, a node can include fields such as:

%{
  id: "review_draft",
  status: :paused,
  current?: true,
  manual_state: %{step: "review_draft", kind: :approval},
  attempts: [%{attempt_number: 1, status: :completed}],
  output: %{drafts: [%{subject: "hello"}]}
}

Host apps should still authorize and redact this payload before exposing it outside trusted operator surfaces. For field-selection guidance, see Observability: redaction and field selection.

Dynamic work nodes are inspectable runtime-authored structure. Preview them through Squidie.preview_dynamic_work/3 when a UI or visual editor needs to validate a candidate payload and render the graph overlay before writing. Record them through Squidie.record_dynamic_work/3 so the runtime validates the stable dynamic key, producer origin, node ids, and optional dynamic edges against an active run snapshot before appending durable metadata. Schedule them through Squidie.schedule_dynamic_work/3 when the dynamic nodes should also become executable runnable intents. Graph projections mark dynamic nodes with dynamic?: true so graph UIs can distinguish them from declared workflow steps:

%{
  id: "deliver_digest:chat_1",
  action: "digest.deliver",
  status: :recorded,
  current?: false,
  dynamic?: true,
  origin: %{step: "schedule_digest", runnable_key: "run_123:schedule_digest:1", attempt: 1},
  metadata: %{chat_id: "chat_1"}
}

Squidie.Runs.DynamicWorkPreview.to_map/1 exposes stable overlay metadata for editor controls without requiring clients to diff graphs themselves:

%{
  run_id: "run_123",
  duplicate?: false,
  recordable?: true,
  origin_node_id: "schedule_digest",
  added_node_ids: ["deliver_digest:chat_1"],
  added_edge_ids: ["schedule_digest:dynamic:deliver_digest:chat_1"],
  warnings: [],
  dynamic_work: %{dynamic_key: "subscription_digest_fanout"},
  graph: %{nodes: [...], edges: [...]}
}

recordable? means recording would append a new durable dynamic-work fact. Exact duplicate previews are still valid but return duplicate?: true, recordable?: false, empty added id lists, and warnings: [:duplicate_dynamic_work].

Recorded dynamic work also appears on inspect_run_graph/2 and Squidie.Runs.GraphInspection.to_map/1 as dynamic_work_overlays. Each overlay summarizes one durable dynamic-work record with the producer node, stable added node ids, stable added edge ids, counts, and recorded status:

%{
  dynamic_key: "subscription_digest_fanout",
  status: :recorded,
  origin_node_id: "schedule_digest",
  added_node_ids: ["deliver_digest:chat_1"],
  added_edge_ids: ["schedule_digest:dynamic:deliver_digest:chat_1"],
  node_count: 1,
  edge_count: 1
}

Use dynamic_work_overlays for graph controls, expandable run-detail panels, and change summaries. Use dynamic_work when the caller needs the full normalized durable fact. Preview graphs returned inside Squidie.Runs.DynamicWorkPreview.to_map/1 may include a candidate overlay for the unrecorded dynamic-work payload. The top-level preview fields remain authoritative for recordable?, duplicate state, added ids, and warnings.

When a dynamic-work overlay represents future executable work, pass the host-owned :action_registry option to preview_dynamic_work/3 and record_dynamic_work/3. Pass it to every schedule_dynamic_work/3 call. Squidie then requires each dynamic node action key to be present, enabled, and compatible before returning, recording, or scheduling the overlay.

Previewing or recording dynamic work remains inspection-only: it does not schedule dispatch attempts, alter dependency readiness, or change terminal-state decisions. Scheduling dynamic work appends the dynamic-work fact and planned runnable intents together, then uses the normal execute_next/1 path for claim, execution, completion, failure, and graph status. Terminal runs reject new dynamic-work previews, records, and schedules. Scheduling also requires an already-applied origin runnable. Dynamic nodes may persist retry intent with retry: [max_attempts: n]; dynamic edges remain graph metadata and do not impose dependency ordering between scheduled dynamic nodes. Scheduled dynamic nodes are replay-unsafe by default so operators must explicitly review irreversible replay.

Nested workflow starts remain separate durable runs. The parent graph exposes their factual records through child_runs and a derived child_links overlay that graph UIs can render as subflow links without treating the child as an inline executable node:

%{
  id: "start_nested_invite:child_run:child_run_123",
  from: "start_nested_invite",
  to: "child_run_123",
  type: :child_run,
  status: :linked,
  child_run_id: "child_run_123",
  child_workflow: "Elixir.MyApp.Workflows.InviteDelivery",
  child_trigger: "deliver_invite",
  child_key: "invite_guest_456",
  origin: %{step: "start_nested_invite", runnable_key: "run_123:start_nested_invite:1", attempt: 1},
  metadata: %{guest_id: "guest_456"},
  started_at: ~U[2026-05-30 12:00:00Z]
}

Use child_links for visual inspection and editor-friendly stable ids. Use child_runs when the UI needs the full child-run fact, and call inspect_run/2 or inspect_run_graph/2 on child_run_id for the child workflow's own status, nodes, and edges. Child links are not workflow transition edges and do not affect dependency readiness, retry behavior, replay behavior, or cancellation boundaries. started_at is optional and appears only when the durable child-run fact includes it. Stale child-run facts without both child_run_id and an origin step remain visible in child_runs, but they do not produce a child_links entry.

Edge Shape

Edges represent transitions or dependencies:

%{
  id: "fetch_emails:ok:draft_reply",
  from: "fetch_emails",
  to: "draft_reply",
  type: :transition,
  status: :selected,
  selected?: true,
  skipped?: false,
  pending?: false,
  blocked?: false,
  outcome: :ok,
  condition: nil,
  recovery: nil
}

Edge status values are:

  • :selected - durable step state proves this path was taken
  • :skipped - a sibling path or terminal outcome won
  • :pending - the source step or dependency has not terminally resolved
  • :blocked - a dependency failed before this edge could become runnable

Conditional transition edges include their condition and deterministic ids:

%{
  id: "classify:ok:auto_approve:condition:0",
  from: "classify",
  to: "auto_approve",
  type: :transition,
  outcome: :ok,
  condition: %{path: [:routing, :decision], equals: "auto"},
  status: :selected,
  selected?: true,
  skipped?: false,
  pending?: false,
  blocked?: false
}

Dependency workflows use dependency edges:

%{
  id: "load_invoice:dependency:send_email",
  from: "load_invoice",
  to: "send_email",
  type: :dependency,
  outcome: nil,
  status: :pending,
  selected?: false,
  skipped?: false,
  pending?: true,
  blocked?: false
}

Dynamic edges connect the producer step to recorded dynamic nodes:

%{
  id: "schedule_digest:dynamic:deliver_digest:chat_1",
  from: "schedule_digest",
  to: "deliver_digest:chat_1",
  type: :dynamic,
  status: :pending,
  selected?: false,
  skipped?: false,
  pending?: true,
  blocked?: false
}

dynamic_work keeps the grouped durable facts behind those nodes and edges:

%{
  dynamic_key: "subscription_digest_fanout",
  status: :recorded,
  reason: :runtime_fanout,
  origin: %{step: "schedule_digest", runnable_key: "run_123:schedule_digest:1", attempt: 1},
  nodes: [%{id: "deliver_digest:chat_1", action: "digest.deliver"}],
  edges: [%{type: :dynamic, from: "schedule_digest", to: "deliver_digest:chat_1"}]
}

Compatibility

The graph map contract is intended for host UI and tooling integration. Squid Mesh may add optional fields in future releases, but the existing field names, identifier semantics, node statuses, edge statuses, and default detail redaction are stable compatibility points.

If the workflow module can no longer be loaded, Squidie still returns any durable node state it can infer from the run. edges is empty in that degraded state because topology belongs to the workflow definition.

The default payload does not include claim tokens, storage configuration, adapter internals, process identifiers, or raw journal entries.