/** * Graph Execution Engine v2 — Imperative `graph_*` Tool Logic * * Version: 2.0 * Date: 2026-07-25 * * Phase 4, Subtask 5. Implements the TOOL LOGIC layer for the eight imperative * graph tools defined in `.rolebox/design/tool-merge-map.md` §2.2: * * graph_create, graph_add_node, graph_add_edge, graph_add_loop, * graph_run, graph_status, graph_cancel * * This module intentionally contains **no zod schemas and no tool * registration** — those belong to the tool-assembly wiring (subtask 6). It * exports a factory, {@link createGraphToolSet}, whose methods take plain * object parameters and return plain (JSON-serializable) values so subtask 6 * can wrap each one with a zod `args` schema + a `defineTool` registration * without touching this file. * * ## Architecture * * - A per-instance **in-memory graph registry** maps `graph_id` → * `{ declaration, runtime }`. The declaration is the source of truth being * mutated by the construction tools; the runtime is a bound * {@link EngineRuntime} (see `src/graph/engine/index.ts`) rebuilt from the * declaration after every construction step. * - **Construction tools** (create/add_node/add_edge/add_loop) build a * *candidate* declaration, structurally validate it, and only commit + * re-provision on success. Mutation is therefore atomic — a failing edit * leaves the registry untouched. * - **`graph_run`** (non dry-run) builds a manager-backed runtime via * `createEngine(declaration, { manager, graphId, parentContext })` and calls * `run()`. `dry_run` validates the structure without executing. * - The stateless render / format half — tree + summary renderers, pagination, * the C-WIRE flag entry extractors, declaration lookups — lives in * `./status-render.ts` (Y30), so this module keeps the registry, the deps and * the engine assembly. The public toolset contract is unchanged. * * ## Design-vs-code divergences (tool-merge-map.md §2.2 → real types) * * 1. `graph_add_node(join)` — the design pseudo-code shows a bare string * (`join: "all"`), but the real `JoinConfig` type * (`src/types.graph-v2.ts:125`) is structured `{ strategy, quorum? }`. This * tool accepts the **structured** form to match the code. * 2. `graph_add_edge(data_passthrough_*)` — the design lists * `include/exclude/max_chars`. All three are stored on the real * `DataMapping` (`src/types.graph-v2.ts:102`): * `data_passthrough_include` → `data_passthrough.fields`, * `data_passthrough_exclude` → `data_passthrough.exclude`, * `data_passthrough_max_chars` → `data_passthrough.maxChars`. No * `exclude`/`max_chars` arguments are reported as `ignored` — both are now * applied by the engine's data-mapping transform. * 3. `graph_add_edge(retry)` — design shows a bare number; real `RetryConfig` * (`src/types.graph-v2.ts:108`) is `{ max, backoff_ms? }`. A bare number is * coerced to `{ max }`. * 4. `graph_run` — the design's `node_id`/`retry`/`modify_prompt` retry mutation * is now backed by the engine's `EngineRuntime.retryNode` surface * (`node-retry.ts`, Phase-4 finishing round). When `node_id` is supplied with * `retry:true` (or `modify_prompt` set), `graph_run` re-opens and re-dispatches * that node after `run()` instead of reporting it `retry_pending`. The * `retry_pending` field is therefore gone from {@link GraphRunResult}. * 5. `graph_cancel` — the engine's {@link EngineRuntime.cancel} retires every * cancellable node for a whole-graph cancel (plus the teardown, terminal * transition and persistence flush), and {@link EngineRuntime.cancelNodes} * is the real node/loop-scoped primitive (a loop target expands to its full * member set, and `cascade` walks the forward closure). Both branches * report the engine's authoritative `CancelScopeReport` retired set — the * tool layer never reverse-infers "what was cancelled" from `errorReason` * text. * 6. **Observed & confirmed:** `engine-state.registerNode` * (`src/graph/engine/engine-state.ts:222-225`) correctly calls * `resolveJoinStrategy(config.join)` to propagate the node's declared join * (default `"all"`) into `NodeRuntimeState.joinStrategy`. The join config * written by this tool therefore flows through correctly — no engine-side * hard-coding remains. * * Design reference: `.rolebox/design/tool-merge-map.md` §2.2. */ import type { DispatchManager } from "../../dispatch/core/manager.ts"; import type { EdgeType, LoopMode, JoinConfig, RetryConfig, GraphBudgetSpec, NodeBudgetSpec } from "../../types.graph-v2.ts"; import type { EngineState } from "../../types.engine-v2.ts"; import { type EngineRuntime, type NodeDispatchPort, type NodeLivenessFeed, type GraphCompletionHandler } from "../engine/index.ts"; import type { ISessionClient } from "../../platform/ports/session-client.ts"; import { EnginePhase, NodeStatus } from "../../constants.ts"; import { type GroupByMode } from "./status-queries.ts"; import { type GraphBudgetSummary, type GraphFlagData, type GraphLoopSummary } from "./status-render.ts"; export declare const log: import("tslog").Logger; /** * Config form of a graph-notify source (subtask 3). Carries the owner emperor * session identity + the session client used to deliver reminders. A single * config feeds both the per-node {@link onNodeCompletion} seam (via * {@link createGraphNotifier}) and the graph-terminal {@link onGraphTerminal} * seam (via {@link createGraphTerminalNotifier}), each with independent dedupe * epochs per engine construction. `emperorSessionId` may be a static string or a * resolver evaluated at engine-construction time (a resolver lets a caller * resolve the emperor session lazily, e.g. from a live session registry). */ export interface GraphNotifyConfig { /** Session client used to deliver `` completions. */ sessionClient: ISessionClient; /** * Emperor session to target for reminders. A static id, or a resolver invoked * once when the notifier is built (fresh per engine construction). The * resolver receives the invoking session id (`invokingSessionId`) — the * session whose execution context drove the engine construction — so a caller * can derive the emperor session from the graph tool's execution context at * runtime. When the resolved value is absent / empty, the notifier is a no-op. */ emperorSessionId?: string | ((invokingSessionId?: string) => string | undefined); /** Optional agent tag forwarded to the injected prompt. */ agent?: string; } /** * Graph node-completion notifier source accepted by {@link GraphToolSetDeps}. * Either a prebuilt notifier fn (a `GraphCompletionHandler` from * `graph-notify.ts`) or a structured owner config. When a structured config is * supplied, it also produces a graph-terminal notifier (`onGraphTerminal` seam) * via {@link createGraphTerminalNotifier} — the config form feeds both per-node * completion and graph-terminal reminders. Absent → the engine runs with its * default no-op seams (backward compatible). */ export type GraphNotifySource = GraphCompletionHandler | GraphNotifyConfig; /** Options for constructing a {@link GraphToolSet}. */ export interface GraphToolSetDeps { /** Active {@link DispatchManager}; required only for non dry-run execution. */ manager?: DispatchManager; /** * Optional injected dispatch seam. When present, it is used in place of a * manager-backed bridge for graph node dispatch — this lets callers and tests * drive `graph_run` (including the `retry` path) without a real * {@link DispatchManager} (see `engine-advance.ts` `NodeDispatchPort`). */ dispatch?: NodeDispatchPort; /** Working directory for graph node dispatches (parent context). */ directory?: string; /** Optional engine-state persistence dir (`.rolebox/state/...`). */ stateDir?: string; /** * Optional per-node staleness deadline (ms) for every engine this toolset * builds (F2). Defaults to {@link DEFAULT_NODE_STALE_TIMEOUT_MS} (15 min) — * a `running` node whose worker stops advancing is marked `timeout` so a * graph never hangs. A node's declared per-node `budget.timeout_ms` * overrides it. Set to a non-positive value to disable the staleness * watcher on these engines (opt-out). */ nodeStaleTimeoutMs?: number; /** * Optional stale-lock sweep interval (ms) for every engine this toolset * builds (F2). Defaults to {@link DEFAULT_SWEEPER_INTERVAL_MS} (60 s) — a * stuck `advancingLock` is released periodically. Set to a non-positive * value to disable the periodic sweep on these engines (manual ticking * only — opt-out). */ sweeperIntervalMs?: number; /** * Optional soft-stall warn threshold (ms) for the heartbeat-based liveness * monitor every engine this toolset builds instantiates (subtask 6). A * heartbeat-fed `running` node that goes idle past this threshold is * classified `stalling` and surfaces the engine's `onNodeStall` seam (the * stall notifier) once per stall episode. Absent → the monitor's default * (`min(60_000, nodeStaleTimeoutMs / 2)`). */ nodeStallWarnMs?: number; /** * Optional hard-stall grace (ms) past `nodeStallWarnMs` before a stalling * node is marked `timeout` (subtask 6). Absent → the monitor's default * (30_000). */ nodeStallGraceMs?: number; /** * Optional node-liveness feed seam (node-anomaly-detection subtask 2). * Threaded into every engine this toolset builds: when present, the engine * records a `dispatch` heartbeat on every launch, registers its sessions * with the feed, and maintains a `sessionId → nodeId` reverse index (see * {@link GraphToolSet.resolveSessionOwner}) so the platform liveness wiring * can heartbeat / fail-fast graph sessions. Absent → engine behavior * unchanged. */ livenessFeed?: NodeLivenessFeed; /** * Optional graph-notify source (subtask 3). When present, every engine this * toolset constructs — in `buildEngine` (used by all construction paths) and * in `graph_run`'s own runtime — wires both the engine's `onNodeCompletion` * DI seam (via {@link createGraphNotifier}) and the `onGraphTerminal` seam * (via {@link createGraphTerminalNotifier}), so per-node completions AND * graph-terminal transitions (COMPLETE / BLOCKED) route to graph-notify * targeting the owner emperor session. A prebuilt `GraphCompletionHandler` fn * is used as-is for `onNodeCompletion` but cannot produce a terminal handler * — use the config form ({@link GraphNotifyConfig}) to enable both. Absent → * the engine's default no-op seams (no notification). `graphParentContext` * budget scoping (`sessionID: graphId`) is untouched — the emperor session is * carried ONLY for notification targeting. */ graphNotify?: GraphNotifySource; /** * Optional session-chain resolver (platform-injected). Given a session id, * returns the ordered chain of sessions from that session UP to the * OUTERMOST live session (`[sessionId, parent, ..., outermost]`), or * `undefined` / a single-element chain when the session has no tracked * dispatcher parent. * * Consumed only by nested blocked-gate propagation: when a graph at any * nesting depth reaches the quiescent-blocked phase, the toolset delivers a * {@link buildPropagatedBlockedText} reminder to `chain.at(-1)` (the user's * orchestrator session) so the human can `graph_approve` there — the * subagent session that invoked the nested graph may already be dead. Absent * (opencode/Pi, or any caller without a parent-session index) → no * propagation: single-level graphs behave exactly as before. * * The dsh plugin wires this from * `DshDispatchAdapter.resolveSessionChain` (its dispatch-parent index). */ resolveSessionChain?: (sessionId: string) => string[] | undefined; } export interface GraphCreateArgs { name: string; budget?: GraphBudgetSpec; } export interface GraphAddNodeArgs { graph_id: string; id: string; agent: string; prompt: string; completion_condition?: string; needs_approval?: boolean; join?: JoinConfig; budget?: NodeBudgetSpec; timeout_ms?: number; max_retries?: number; } export interface GraphAddEdgeArgs { graph_id: string; from: string; to: string; type?: EdgeType; signal_filter?: string[]; condition?: string; data_passthrough_include?: string[]; data_passthrough_exclude?: string[]; data_passthrough_max_chars?: number; retry?: number | RetryConfig; } export interface GraphAddLoopArgs { graph_id: string; id: string; nodes: string[]; max_traversals: number; /** * Session-isolation mode for this loop group's rounds. `inherit` (real) is * recorded on the loop declaration and surfaced in `graph_status` loop * render/summary. `fresh` (per-round session isolation) is * documented-unsupported — it returns an explicit error naming the * alternative path (a separate graph per round) rather than a silent no-op. * Absent = default behavior (byte-identical to legacy output). */ mode?: LoopMode; } export interface GraphRunArgs { graph_id: string; node_id?: string; retry?: boolean; modify_prompt?: string; dry_run?: boolean; } export type GraphStatusFormat = "summary" | "tree" | "json"; /** * Session-scope of a `graph_status` query. * * - `session` — the in-memory registry only (the default; byte-identical to * legacy behavior). * - `persisted` — only graphs hydrated from the on-disk engine-state store * (`stateDir/.rolebox/state/engine-*.json`, subtask 3's scanner) — a * cross-session view over graphs written by earlier sessions. * - `all` — the registry PLUS persisted graphs; on a `graphId` collision the * live registry entry wins. */ export type GraphStatusScope = "session" | "persisted" | "all"; export interface GraphStatusArgs { graph_id?: string; node_id?: string; loop_id?: string; format?: GraphStatusFormat; /** Session-scope of the query (see {@link GraphStatusScope}). When * `persisted` or `all`, the scanned persisted EngineStates are merged into * the render/query pipeline so the no-target list, query/status/agent/ * from_date/to_date filter, `group_by` buckets, and `include_budget` * aggregation all read across sessions. An empty store yields an explicit * honest-empty note — never fabricated rows. */ scope?: GraphStatusScope; /** Case-insensitive substring filter on nodeId / prompt / agent (backed by * `status-queries.ts` — pure, honest subset, never fabricated rows). */ query?: string; /** Exact {@link NodeStatus} node filter (canonical lowercase value). */ status?: NodeStatus; /** Exact agent node filter. */ agent?: string; /** ISO-8601 window lower bound on node timestamps (startedAt >= from). */ from_date?: string; /** ISO-8601 window upper bound on node timestamps (completedAt <= to). */ to_date?: string; /** Bucket COMPLETED nodes over their completedAt by hour / day / agent, * returning the bucket list with counts (uncompleted nodes excluded honestly). * A distinct view mode — when set it takes precedence over the row render. */ group_by?: GroupByMode; /** Cap the number of node rows emitted in summary and json renders. Unset or * <= 0 leaves the output unbounded (byte-identical to legacy behavior). */ limit?: number; /** Prune the tree render at `depth` levels (0 = roots only). Unset = full * depth (byte-identical to legacy tree output). */ depth?: number; include_output?: boolean; include_progress?: boolean; include_budget?: boolean; include_metrics?: boolean; include_loops?: boolean; /** Include the node's recorded lifecycle checkpoint snapshot(s) from * `EngineState.checkpoints[nodeId]` (subtask 1 field). OPTIONAL-ADDITIVE — * absent until a checkpoint is recorded; when none exist, an explicit * "no checkpoint recorded" note is shown — never fabricated. */ include_checkpoint?: boolean; /** Include the node's recorded artifact file paths from * `NodeRuntimeState.artifacts[]` (subtask 1 field). Nodes with no artifacts * are omitted honestly; a run with no artifacts yields an explicit * "no artifacts / evidence recorded" note. */ include_artifacts?: boolean; /** Include the node's recorded evidence references from * `NodeRuntimeState.evidence[]` (subtask 1 field). Honest-empty like * `include_artifacts`. */ include_evidence?: boolean; /** Include each node's recorded liveness state from * `NodeRuntimeState.liveness` (subtask 1 field). OPTIONAL-ADDITIVE — * only nodes WITH recorded liveness get the block; absent liveness → * nothing rendered, never fabricated. Running nodes always render their * liveness regardless of this flag. */ include_liveness?: boolean; /** Include each loop group's ordered round history from * `LoopGroupRuntimeState.rounds[]` (subtask 1 field). Absent rounds yield an * explicit "no loop rounds recorded" note — never invented rows. */ include_history?: boolean; /** Filter round history to a single 1-based round index within a loop group * (paired with `include_history` or alone). A round that was not recorded * yields an explicit "round N: not recorded" note. */ round?: number; /** Surface the timestamped per-node signal-event history from * `SignalLedgerEntry.history` ({signal, payload, atMs}). An empty history * yields an explicit "no events recorded" note — never fabricated rows. */ stream?: boolean; /** ISO-8601 lower bound — when `stream` (or alone) is set, include only * signal events at or after this timestamp. Events before `since` are * filtered out; if none remain, an explicit "no events since " note. */ since?: string; /** First-class "awaiting human" view: list every `blocked` `needs_approval` * node across the resolved scope (registry only for `session`; persisted only * for `persisted`; merged for `all`). Each row carries the owning graph, the * blocked-since timestamp, a truncated `approval_payload` summary, and a * paste-ready `graph_approve` call. A distinct view mode — an empty result * renders an honest "no pending approvals" note, never fabricated rows. */ pending_approvals?: boolean; max_chars?: number; offset?: number; tail?: boolean; /** When set, atomically write an export to this path and return a * confirmation instead of a status render. Mode-dependent: a `node_id` writes * that node's materialized result text, `include_metrics` writes a metrics * JSON snapshot, and neither writes the owning graph's declaration to YAML * (dispatch_export merge — §3 row 18). */ export_path?: string; } export interface GraphCancelArgs { graph_id: string; node_id?: string; loop_id?: string; cascade?: boolean; } export interface GraphCreateResult { graph_id: string; name: string; created_at: string; } export interface GraphAddNodeResult { node_id: string; graph_id: string; created: boolean; } export interface GraphAddEdgeResult { edge_id: string; from: string; to: string; type: EdgeType; } export interface GraphAddLoopResult { loop_id: string; graph_id: string; nodes: string[]; max_traversals: number; } export interface GraphRunResult { graph_id: string; phase: string; /** Nodes that are genuinely active: Running, Blocked, or Ready (dispatch-imminent). Excludes Pending. */ active_nodes: string[]; /** Nodes that are Pending — not yet dispatched, awaiting upstream completion. */ pending_nodes: string[]; dry_run?: boolean; validation?: { valid: boolean; errors: string[]; warnings: string[]; }; /** Present when a node retry was requested (`node_id` + `retry`/`modify_prompt`). */ retry?: { node_id: string; re_dispatched: number; reset: string[]; }; } export interface GraphCancelResult { cancelled: string[]; graph_id: string; } export type GraphApproveAction = "approve" | "reject"; /** * Human-approval routing for a blocked `needs_approval` node. * * Backs the Phase C migration of the orchestrator-facing `dispatch_approve` / * `dispatch_reject` pair (see `.rolebox/design/tool-merge-map.md` §3 rows 7–8, * GAP-2 in `phase-c-inventory.md`). Routes import-only to the engine's public * `EngineRuntime.approveNode` / `rejectNode` — a thin parent-facing surface so * a graph that has paused at a `blocked` `needs_approval` node can be resumed * (approve) or re-entered/escalated (reject) from the orchestrator session. */ export interface GraphApproveArgs { /** Graph containing the blocked node. */ graph_id: string; /** The `needs_approval` node currently `blocked` awaiting the human. */ node_id: string; /** * `approve` resolves the gate (`blocked → completed`) and runs the node's * forward `answer` data flow. `reject` re-enters the node (`blocked → ready`, * merging the reason into its re-execution prompt) when it belongs to a loop * group, or escalates it (`blocked → escalate`) when it has no loop to re-open. */ action: GraphApproveAction; /** Human-supplied rejection feedback (only meaningful when action=reject). */ reason?: string; /** Optional approval output passed downstream on the answer edge (action=approve). */ payload?: unknown; } export interface GraphApproveResult { graph_id: string; node_id: string; action: GraphApproveAction; /** The node's lifecycle status after the decision (NodeStatus, or "unknown"). */ node_status: NodeStatus | "unknown"; /** The graph phase after the decision advanced. */ phase: string; /** * Whether the decision actually resolved the node — `true` only when the * node was `blocked` at the moment the decision arrived. `false` marks an * idempotent no-op (already-resolved / never-blocked node), so callers do * not mistake the echoed live `node_status` for a decision that took effect. */ applied: boolean; } /** * One node row of the `graph_status` JSON snapshot — the typed shape of * {@link GraphToolSet.nodeSummary}. Optional keys are emitted only when their * source data is present, so the serialized shape is unchanged from the * pre-typing implementation; the difference is that a rename or a removed key * now fails to compile instead of silently changing the JSON output. */ export interface GraphNodeSummary extends GraphFlagData { node_id: string; status: NodeStatus; agent: string; needs_approval: boolean; loop_group: string | undefined; traversal_count: number; retry_count: number; dispatch_session_id?: string; dispatch_task_id?: string; error: string | undefined; progress?: unknown; last_signal_at?: number; output?: string; last_activity_at?: number; idle_ms?: number; heartbeat_source?: string; stall_status?: string; stall_warned_at?: number; stall_reason?: string; } /** * The `graph_status` JSON snapshot (format=json, graph-scoped). Built as a * typed object — the C-WIRE flag keys arrive through {@link flagData} spread at * the call site, and `budget` / `loops` / `metrics` are conditionally * spread, so no key is ever written as an explicit `undefined` that only * `JSON.stringify` happens to drop. The key names are the public JSON * contract and must not change. */ export interface GraphStatusSnapshot extends GraphFlagData { graph_id: string; phase: string; nodes: GraphNodeSummary[]; budget?: GraphBudgetSummary; loops?: GraphLoopSummary[]; metrics?: string; notification_degraded?: boolean; notification_degraded_statuses?: string[]; } /** * `graph_status` flags in `.rolebox/design/tool-merge-map.md` §2.2 that have * **no backing data** in the current engine runtime shapes * (`src/types.engine-v2.ts` — `EngineState` / `NodeRuntimeState` / * `LoopGroupRuntimeState`). These are intentionally NOT exposed as zod args and * are never fabricated — answering an observability request the engine cannot * support would mean inventing values. * * Kept as a single inspectable registry so tests can assert that every §2.2 * flag is either surfaced with genuine data or explicitly documented as * unbacked (see `tests/graph/graph-status-flags.test.ts`). * * The flag-backing timeline (each backed flag is therefore absent here): * * - Subtask 3 backed `group_by` (completed-node bucketing), `limit` (row cap for * summary/json), and `depth` (tree cutoff) — see `status-queries.ts`. * - Subtask 3 (C-WIRE) backed the final seven: `round` + `include_history` * (`LoopGroupRuntimeState.rounds[]`), `include_checkpoint` * (`EngineState.checkpoints`), `include_artifacts` / `include_evidence` * (`NodeRuntimeState.artifacts[]` / `.evidence[]`), and `stream` + `since` * (`SignalLedgerEntry.history[]`). Their renderers live in the "C-WIRE * observability flags" section below. * * The registry is therefore EMPTY — every original §2.2 `graph_status` flag is * now backed with genuine data or an honest-empty note. It is retained as an * empty `ReadonlyArray` so the audit tests can pin this end state. */ export declare const UNSUPPORTED_GRAPH_STATUS_FLAGS: ReadonlyArray<{ flag: string; reason: string; }>; /** * A graph-terminal observation delivered to {@link GraphToolSet.subscribeGraphTerminal} * observers. `sessionId` is the graph's invoking session (the session whose * tool call ran the graph), which lets an observer correlate a nested graph * with the dispatch task that spawned its agent. `failed` is true when the * terminal graph carries at least one escalated or timed-out node. * `isBlocked` is true for the quiescent-blocked (HITL gate) terminal, and * `blockedNodeIds` names the `needs_approval` node(s) awaiting a human decision * (`[]` for a non-blocked terminal). `phase` is the graph phase at emission. */ export interface GraphTerminalObservation { graphId: string; sessionId?: string; failed: boolean; /** True when the graph is quiescent-blocked on a `needs_approval` gate. */ isBlocked: boolean; /** The graph phase at emission time. */ phase: string; /** The blocked `needs_approval` node ids (empty for a non-blocked terminal). */ blockedNodeIds: string[]; } /** Observer callback for {@link GraphToolSet.subscribeGraphTerminal}. */ export type GraphTerminalObserver = (info: GraphTerminalObservation) => void; /** * The imperative `graph_*` tool set bound to a dispatch manager and a single * in-memory graph registry. Construct once per session (or per graph batch); * {@link graph_create} opens a registry slot that the other tools mutate. */ export declare class GraphToolSet { private readonly deps; private readonly registry; /** * Graph-terminal observers. Consumed by the dsh dispatch adapter to hold an * outer dispatch open until a graph the dispatched agent launched from its * own session reaches a terminal state (nested-graph propagation). Empty by * default — no observer, no behavior change. */ private readonly graphTerminalObservers; constructor(deps?: GraphToolSetDeps); /** * Resolve the configured graph-notify source into a concrete * `onNodeCompletion` handler, or `undefined` for the engine's default no-op * seam. A prebuilt notifier fn is returned as-is; a config form is materialized * via {@link createGraphNotifier} once per call (a fresh notifier = a fresh * dedupe epoch per engine construction). The config's `emperorSessionId` * resolver is invoked with the invoking session id (`invokingSessionId`) when * provided, so the emperor session can be derived from the graph tool's * execution context at runtime. Returns `undefined` when no source is * configured or the resolved emperor session is absent — in the latter case a * degradation warning naming the graph is logged (and a durable * `notification_degraded` marker recorded when a stateDir is configured) so * the silent drop is observable (F6). Subtask 3. */ private completionHandler; /** * Resolve the configured graph-notify source into a concrete * `onGraphTerminal` handler, or `undefined` for the engine's default no-op * seam. Same resolution logic as {@link completionHandler} — but only the * config form (`GraphNotifyConfig`) can produce a terminal handler; a prebuilt * `GraphCompletionHandler` fn cannot be deconstructed, so it yields * `undefined`. A fresh notifier = a fresh dedupe epoch per engine construction. * When the resolved emperor session is absent, a degradation warning naming * the graph is logged (plus a durable marker when a stateDir is configured) * instead of silently degrading (F6). */ private terminalHandler; /** * Resolve the configured graph-notify source into a concrete `onNodeStall` * handler, or `undefined` for the engine's default no-op seam. Same * resolution logic as {@link completionHandler} — config form only * (`GraphNotifyConfig`); a prebuilt `GraphCompletionHandler` fn cannot be * deconstructed, so it yields `undefined` (stall notifications ride the * engine-level `onNodeStall` DI seam, distinct from the prebuilt per-node * completion handler). A fresh notifier = a fresh dedupe epoch per engine * construction. When the resolved emperor session is absent, a degradation * warning naming the graph is logged (plus a durable marker when a stateDir * is configured) instead of silently degrading (F6). Subtask 5. */ private stallHandler; /** * Record a durable `notification_degraded` event when a stateDir is * configured (F6, optional-additive — absent stateDir → warning log only). * Written by the toolset itself because the notifier is never constructed in * this path; the marker lands in the graph's event log (`.rolebox/state/ * graph-events-{hash}.ndjson`) so a graph_status consumer can surface * "terminal notification degraded". Subtask 5 adds the `stall` kind. */ private recordNotificationDegraded; /** * Read-only helper backing the graph_status degraded hint: read the graph's * durable event log (`.rolebox/state/graph-events-{hash}.ndjson`) and return * the deduped `status` values of any `notification_degraded` events * (`"completion"` / `"terminal"` / `"stall"`), in file order. Empty when no stateDir is * configured, no log file exists, or no degraded event was recorded — and * never throws, so a missing / corrupt log can never break a status query * (total, observability-only, mirroring the recorder's own total discipline). */ private notificationDegradedStatuses; /** Create a fresh, provisioned engine from a declaration (re-provision). */ private buildEngine; private parentContext; /** Look up a graph entry or throw a descriptive error. */ private getEntry; /** * Resolve an entry for `graph_approve` / `graph_reject` that survives a plugin * restart (subtask 2). * * The in-memory registry is empty after a restart, but a graph paused at a * `blocked` `needs_approval` node persists its state on disk. `recoverInterruptedGraphs` * (engine-startup.ts) rebuilds such graphs as independent `createEngine` * instances that are never placed into this toolset's registry — so a plain * `getEntry` throws "graph X does not exist" even though the gate is durably * resumable. * * Order of resolution: * 1. Registry hit → return it unchanged (the normal in-memory path). * 2. Persisted hit at a non-`complete` phase with a `blocked` target node → * rebuild a fresh engine from the persisted declaration, adopt the on-disk * per-node progress with `adoptPrior` semantics (so the `blocked` gate is * carried across instead of reset to `ready`), register it, and return. * 3. Neither present → throw the same descriptive error `getEntry` throws. * * Additive and non-destructive: the registry path is untouched; the persisted * path only rebuilds when the registry has no entry for `graphId`. */ private resolveApprovalEntry; /** Commit a candidate declaration: validate → store → rebuild runtime. * * The toolset never retains a reference to a caller-supplied object: the * construction tools copy every object / array they receive before it enters * the candidate declaration (Y31), so the validated declaration stored here * cannot be mutated afterwards through the caller's `args`. * * When the graph already has a runtime with execution progress (a * construction tool was called AFTER `graph_run` — e.g. the emperor adds a * validate node mid-flight), the prior runtime's per-node progress is * adopted into the rebuilt engine so completed / running nodes are never * reset back to `ready` and re-dispatched on the next `graph_run`. */ private commit; graph_create(args: GraphCreateArgs, invokingSessionId?: string, agent?: string): GraphCreateResult; graph_add_node(args: GraphAddNodeArgs, invokingSessionId?: string, agent?: string): GraphAddNodeResult; graph_add_edge(args: GraphAddEdgeArgs, invokingSessionId?: string, agent?: string): GraphAddEdgeResult; graph_add_loop(args: GraphAddLoopArgs, invokingSessionId?: string, agent?: string): GraphAddLoopResult; graph_run(args: GraphRunArgs, invokingSessionId?: string, agent?: string): Promise; /** * Whether the given session owns at least one graph whose engine is genuinely * mid-flight: phase `executing` AND at least one node `ready` or `running`. * * `Blocked` nodes are deliberately EXCLUDED (hence the dedicated * {@link GRAPH_INFLIGHT_NODE_STATUSES} predicate rather than reusing * `GRAPH_RUN_ACTIVE_STATUSES`, which includes `Blocked`) — a `needs_approval` * gate is a legitimate pause awaiting the human, so an auto-continue must * freeze through the existing gated (approval) path instead of treating the * graph as inflight work to contend with. A graph whose phase is `idle` (never * run / finished) or whose executing engine has no unsettled node does NOT * count. Absent invoking-session match, or no graphs at all → `false`. */ hasInflightGraphsForSession(sessionID: string): boolean; /** * Whether the given session owns at least one graph whose engine has NOT * reached a terminal phase — i.e. its phase is still `executing`. Unlike * {@link hasInflightGraphsForSession}, a quiescent-blocked graph (a * `needs_approval` gate awaiting the human) still counts: a dispatched * subagent that launched a nested graph must not be reported terminal while * that graph is unsettled for ANY reason, including a HITL gate. * * Consumed by the dsh dispatch adapter's nested-graph settlement guard so an * outer node stays `running` (and its failure never silently becomes a * success) until the nested graph it launched reaches a terminal state. */ hasExecutingGraphsForSession(sessionID: string): boolean; /** * Subscribe to graph-terminal events for every graph this toolset runs. * Returns an unsubscribe function. Used by the dsh dispatch adapter to learn * when a nested graph launched from a dispatched agent's session settles, so * the outer dispatch can be settled from the nested graph's outcome rather * than from the agent's (premature) turn completion. */ subscribeGraphTerminal(observer: GraphTerminalObserver): () => void; /** * Fan a terminal event out to every registered observer. Best-effort: a * throwing observer must never corrupt the engine's terminal transition * (mirrors the `onGraphTerminal` notifier convention). A no-op with no * observers registered. * * For the quiescent-blocked terminal it also propagates the approval request * up the session chain to the outermost live session (see * {@link GraphToolSetDeps.resolveSessionChain}) so a nested `needs_approval` * gate reaches the user's orchestrator session — not just the (possibly * dead) subagent session that invoked the nested graph. */ private notifyGraphTerminal; /** * Read the ids of the currently `blocked` nodes in a graph's live runtime. * Empty when the graph is unknown (e.g. the terminal fired before the * registry entry was replaced) — never fabricated. */ private blockedNodeIdsFor; /** * Deliver a blocked-gate reminder to the OUTERMOST live session on the * invoking session's chain. A no-op when no resolver is wired (opencode/Pi), * when the chain has no parent (single-level graph — the graph's own invoking * session already received the terminal reminder), or when no session client * is configured. Delivery is fire-and-forget, serialized per target session * via {@link enqueueNotify}; a failure is logged and never breaks the * terminal transition. */ private propagateBlockedGate; /** * Resolve the engine runtime + node owning a live dispatch session (subtask * 6 — the Pi liveness wiring's `sessionId → nodeId` resolution). Iterates * every registry runtime's engine-level reverse index * (`EngineRuntime.getNodeIdForSession` — populated at launch when a liveness * feed is wired onto the engine, dropped on the node's terminal transition). * Returns `undefined` when no registry runtime owns the session (unknown * session, detached terminal node, or an engine built without a liveness * feed) — the wiring then no-ops. Total: a misbehaving runtime is logged and * skipped, never thrown. */ resolveSessionOwner(sessionId: string): { graphId: string; runtime: EngineRuntime; nodeId: string; } | undefined; /** * Snapshot every live runtime in this toolset's in-memory graph registry as * an {@link EngineState}, in registry order (graph_create order). * * This is the monitor's live-state surface: on platforms where engine state * is never persisted to disk (opencode — see the platform contract in * `src/core/services/tool-service.ts`), `readLiveEngineGraphs` * (`src/cli/commands/monitor/monitor-reader-engine.ts`) projects these * runtimes instead of scanning `engine-*.json`. Statuses come from * {@link EngineRuntime.status()}, so each returned state is a deep-enough * clone — mutating it cannot corrupt the live engine. Returns an empty array * when the registry holds no graphs. */ liveEngineStates(): EngineState[]; graph_status(args: GraphStatusArgs): string; /** Scan the on-disk engine-state store under `stateDir` (default cwd). */ private persistedScan; /** Registry states followed by persisted states, deduped by graphId (registry * wins) — the node set the `all` scope aggregates over. */ private collectAllStates; /** The in-memory registry states, in registry order (graph_create order). */ private registryStates; /** * Pending-approvals render: enumerate every `blocked` `needs_approval` node * across the resolved scope (registry for `session`; persisted for * `persisted`; merged for `all`, deduped registry-wins) via the pure * `status-queries.ts` {@link listPendingApprovals} helper. An optional * `graph_id` narrows the scan to a single graph. Every row carries the owning * graph, blocked-since timestamp, a truncated approval_payload summary, and a * paste-ready `graph_approve` call — all sourced from REAL recorded state, * never fabricated. An empty result yields an honest note. */ private renderPendingApprovals; /** No-target list for persisted/all scope: persisted graphs are shown, and an * empty store yields an explicit honest-empty note. */ private renderScopedGraphList; /** * Cross-session aggregate render (scope persisted/all, no target, with a * filter / group_by / include_budget view active). Every node row carries its * owning graph so cross-graph identity stays unambiguous. All data reads REAL * recorded state (registry or persisted) — never fabricated rows. */ private renderCrossSession; /** `group_by` buckets across sessions: completed nodes from every graph in * scope are merged into one bucket list keyed by hour/day/agent. */ private renderCrossSessionGroups; /** Resolve a single graph target for persisted/all scope (registry wins for * `all`; persisted store only for `persisted`). */ private resolveState; /** Find the graph owning a node/loop across registry + persisted states. */ private resolveOwningScoped; /** Targeted (graph_id/node_id/loop_id) render for persisted/all scope. */ private renderScopedTarget; graph_cancel(args: GraphCancelArgs): Promise; /** * Resolve a blocked `needs_approval` node with a human decision. * * This is the parent-facing HITL surface that Phase C migrates * `dispatch_approve` / `dispatch_reject` onto (GAP-2). It routes import-only * to the engine's public `approveNode` / `rejectNode` on the registry's live * runtime — the same runtime `graph_run` left paused at the blocked node. * * `approve` completes the gate and runs the forward `answer` data flow; * `reject` re-enters the node (loop-group member) or escalates it (no loop), * per the engine's `rejectNode` semantics. Idempotent by engine guard: a * decision on an already-resolved node is a no-op reported as-is. * * No new engine logic here — this is a thin routing surface over the public * {@link EngineRuntime} API (protected engine files are untouched). */ graph_approve(args: GraphApproveArgs): Promise; private renderGraph; /** * The `group_by` view: bucket COMPLETED nodes over their `completedAt` (hour / * day / agent) and return the bucket list with counts. Delegated to the pure * `status-queries.ts` `groupCompletedNodes` — uncompleted nodes are excluded * honestly, never invented into a bucket. */ private renderGroups; private renderSummary; private renderNode; private renderLoop; /** * Append the honest text sections produced by any active C-WIRE flag onto a * base render, separated by a blank line. Returns `base` unchanged when no * flag is active (see `flagSectionsActive` in status-render.ts). Every * section reads REAL recorded data or an explicit honest-empty note — never * fabricated rows. */ private appendFlagSections; /** Text section: per-loop round history (`include_history` / `round`). */ private renderRoundHistory; /** Text section: per-node lifecycle checkpoints (`include_checkpoint`). */ private renderCheckpoints; /** Text section: per-node artifacts / evidence (`include_artifacts` / `include_evidence`). */ private renderArtifactsEvidence; /** Text section: timestamped signal-event history (`stream` / `since`). */ private renderSignalStream; /** * Serialize a graph declaration to YAML and write it to `exportPath` * atomically: write to a sibling `..tmp` file, then rename it into * place. Renaming is atomic on POSIX filesystems, so a reader never observes * a partially-written target. Returns a human-readable confirmation that * includes the serialized YAML. */ private exportGraph; /** * Mode-dependent `export_path` handling for graph_status. Works from a state * + declaration so both live (registry) and hydrated (persisted) graphs share * the same export logic. Three mutually exclusive modes, resolved by * specificity (node_id is the most specific): * 1. `node_id` set -> export the node's materialized result text, * read from `MaterializedResultRef.sidecarPath` * via {@link resultText} (dispatch_export-style). * Throws when the node has no materialized result. * 2. `include_metrics` -> export a metrics JSON snapshot reusing * {@link metricsSummary} / {@link budgetSummary} * (dispatch_metrics-style). * 3. neither -> export the owning graph declaration to YAML * (existing {@link exportGraph} behaviour). * Every mode writes atomically via {@link writeAtomic}. */ private exportForState; /** * Build a structured metrics JSON snapshot for the `export_path` + * `include_metrics` mode. Reuses {@link metricsSummary} (the human-readable * phase/status summary) and {@link budgetSummary} (graph + per-node budget), * plus a machine-readable `node_counts` breakdown keyed by status — the * graph-level analogue of a dispatch_metrics snapshot. All data is derived * from the live engine state, never fabricated. */ private metricsSnapshot; private nodeSummary; private loopSummary; /** * Find the graph that owns a given node or loop id. Used when graph_status is * called with `node_id`/`loop_id` but no `graph_id` (tool-merge-map.md §2.2 * makes `graph_id` conditional). Throws a clear error when not found or when * the id is ambiguous across multiple graphs. */ private resolveOwningGraph; } /** * Construct an imperative `graph_*` tool set bound to a dispatch manager. * * Subtask 6 wraps each public method below with a zod `args` schema and a * `defineTool` registration. The methods throw descriptive {@link Error}s on * invalid input; the wrapper is responsible for converting those into * agent-visible tool output. */ export declare function createGraphToolSet(deps?: GraphToolSetDeps): GraphToolSet; export { EnginePhase, NodeStatus }; //# sourceMappingURL=graph-tools.d.ts.map