/** * Graph Execution Engine v2 — Approval Handler * * Version: 2.0 * Date: 2026-07-24 * * Pure state-mutation primitives for the `needs_approval` (human-in-the-loop) * gate lifecycle. When a `needs_approval` node pauses in the `blocked` state, * the human resumes it one of three ways (orchestration-patterns.md §1.3/§1.5): * * - {@link approveBlockedNode} — approve → `blocked → completed`; the engine * records an `answer` signal and returns the {@link EdgePayload} so the * caller's forward-data-flow step activates the downstream `on_signal * (answer)` / `always` edges. * - {@link rejectBlockedNode} — reject → `blocked → ready` (re-enter with the * rejection feedback merged into the node's re-execution prompt), or * `blocked → escalate` when there is no loop group to absorb the rejection. * - {@link pruneDownstreamSubgraph} + rejected-upstream re-entry — partial * approve → cancel the rejected branches' transitive dependents and re-enter * the rejected upstream nodes `ready` with feedback, so the surviving graph * re-runs only the deltas (orchestration-patterns.md §1.5). * * These primitives are **pure state-mutation steps**, mirroring the * signal-propagation conventions: they mutate node lifecycle status and the * frontier only, and never dispatch. Dispatch of re-entered-`ready` nodes (and * any downstream activation) is the caller's job (the advance engine's * `_dispatchReadyNodes`). Cancellation of pruned nodes touches a * `CancelDispatchPort` seam (optional) — structurally satisfied by the * `NodeDispatchPort` cancel seam (`engine-advance.ts`). * * Design references: * - `.rolebox/design/orchestration-patterns.md` §1.3 (approval lifecycle), * §1.5 (partial-approval pruning). */ import { NodeStatus } from "../../constants.ts"; import type { EdgePayload, EngineState, NodeRuntimeState } from "../../types.engine-v2.ts"; import type { CancelDispatchPort } from "./cascade-canceller.ts"; /** * Result of {@link approveBlockedNode}, and of the public `EngineRuntime * .approveNode` (contract C6): the caller learns whether its approval actually * took effect instead of having to diff two `status()` snapshots around the * call. */ export interface ApproveReport { /** * `true` when the node was actually `blocked` and the approval transitioned * it to `completed` — the `answer` signal was recorded and the forward data * flow runs. `false` for an idempotent no-op: a replayed approve against an * already-resolved node, or an approve against a node that was never * `blocked`. A no-op approval performs no graph mutation. */ applied: boolean; } /** * Project the {@link approveBlockedNode} primitive's return value into the * public {@link ApproveReport}. The primitive answers the downstream * {@link EdgePayload} (`null` = the node was not `blocked`); the report only * needs the fact that the approval was applied. */ export declare function approveReport(edgePayload: EdgePayload | null): ApproveReport; /** * Result of {@link rejectBlockedNode}, and of the public `EngineRuntime * .rejectNode` (contract C6). * * A discriminated union (B16): `actualStatus` exists only on the * `already_resolved` branch, so a caller that needs it must first narrow on * `kind`. The previous optional-field form made "the reject was a no-op * because the node was already resolved" and "a genuine rejection lane" * structurally indistinguishable — and a consumer reading `actualStatus` * without checking `kind` got `undefined` for every real rejection. */ export type RejectReport = { kind: "escalate"; } | { kind: "revise"; } | { kind: "already_resolved"; /** * The actual node status at the time of the no-op reject (e.g. * Completed, Escalate, Done). */ actualStatus: NodeStatus; }; /** Result of {@link pruneDownstreamSubgraph}. */ export interface PruneReport { /** Nodes cancelled (transitively dependent on rejected results, cannot survive). */ cancelled: string[]; /** Downstream nodes that survive on their remaining approved upstream sources. */ surviving: string[]; } /** Result of {@link reenterRejectedUpstreams}. */ export interface ReentryReport { /** Rejected upstream nodes re-marked `ready` and added to the frontier. */ reEntered: string[]; } /** * JSON-safe value produced by {@link normalizeApprovalPayload}: exactly the * subset of `unknown` that survives `JSON.stringify` + `JSON.parse` without * loss or a throw. */ export type ApprovalJsonValue = string | number | boolean | null | ApprovalJsonValue[] | { [key: string]: ApprovalJsonValue; }; /** * Why `value` is not acceptable as an approval payload, or `undefined` when it * is (R6a). * * The public approval entry points accept `unknown`, so this is the trust * boundary: a payload that JSON cannot represent — a `bigint` (`JSON.stringify` * throws), a function or symbol (silently dropped), a circular reference * (throws), or an object member whose read throws — is reported so the caller * can reject it BEFORE the state machine runs, instead of half-completing the * node. `undefined` is legal (it means "no payload"; the caller falls back to * the node's recorded approval summary), and `NaN` / `±Infinity` stay legal * because JSON renders them as `null` without throwing — the historical * behaviour. * * Never throws: a payload whose own property access throws is reported as * unacceptable rather than propagating the getter's error. */ export declare function approvalPayloadProblem(value: unknown): string | undefined; /** * Normalize an approval payload into a JSON-safe value, WITHOUT throwing — the * explicit handling R6(c) asks for. * * The public approval entry points accept `unknown` (the tool layer parses a * JSON argument, but the exported `EngineRuntime` API takes any value), and * every later consumer — the per-node `signalsObserved` ledger, the * `signalLedger` history, the durable `JSON.stringify` in the persistence * layer — assumes JSON data. Rather than reject or throw, this projects the * value: `bigint` becomes its decimal string (a raw BigInt makes * `JSON.stringify` throw), `undefined` / function / symbol become `null` at * the top level and in arrays and are dropped as object members (JSON * semantics), a cyclic reference becomes `"[Circular]"`, nesting past * {@link MAX_PAYLOAD_DEPTH} becomes `"[MaxDepth]"`, and a payload whose own * property read throws becomes `"[Unreadable]"`. * * The result is therefore safe to record and to persist, and never throws — so * it cannot leave the approval half-applied (R6b). */ export declare function normalizeApprovalPayload(value: unknown): ApprovalJsonValue; /** * The downstream {@link EdgePayload.result} text for an approval output. * * Total and non-throwing (R6c). A string is returned verbatim — the historical * contract, so an approval note is never JSON-quoted. `null` / `undefined` * answer `""` (the historical empty marker). Anything else is normalized first * (see {@link normalizeApprovalPayload}) and then serialized, so a function / * symbol / BigInt / cyclic payload can no longer answer `undefined` (the * declared-`string` lie that left a node `completed` with a downstream * activation that could never run) and can no longer throw between the state * mutation and the edge emission. */ export declare function approvalResultText(value: unknown): string; /** * Append rejection feedback to a node's re-execution prompt so the re-run sees * why it was rejected. Returns the prompt unchanged when there is no reason. */ export declare function mergeRejectionFeedback(prompt: string, reason?: string): string; /** * Resolve an approval: transition the blocked node to `completed` and record an * `answer` signal for it, returning the {@link EdgePayload} the caller should * route downstream along the `answer` lane. * * - The approval's payload (the agent-rendered summary from the `need_approval` * signal, or the caller-provided payload) becomes the node's `answer` output. * - `blocked → completed` (completed is a legal blocked exit) marks the node * terminal-success and lets downstream `on_signal(answer)` / `always` edges * activate via the caller's forward-data-flow step. * * R6 error/state contract: * * - The caller-supplied payload must be a JSON value. A `bigint`, function, * symbol, circular reference, or an object whose property read throws is * rejected with a `TypeError` BEFORE anything mutates * ({@link approvalPayloadProblem}) — the node keeps its `blocked` status and * no `answer` is recorded. Previously such a payload left the node * `completed` with `EdgePayload.result === undefined`, so the downstream * join could never activate. * - An accepted payload is normalized to a JSON-safe value before it is * recorded ({@link normalizeApprovalPayload}), and the downstream `result` * text is built before the node's lifecycle changes — a failure while * preparing the edge payload can therefore never leave the node * `completed` with its downstream activation missing. * * @returns The downstream {@link EdgePayload}, or `null` when the node was not * actually `blocked` (a no-op guard — approve is idempotent). */ export declare function approveBlockedNode(state: EngineState, node: NodeRuntimeState, payload?: unknown): EdgePayload | null; /** * Resolve a rejection on a blocked `needs_approval` node. * * - No loop group → the rejection has nowhere to re-enter; the node escalates * with the rejection reason (`blocked → escalate`, added Phase 3). This keeps * the graph from proceeding with un-reviewed changes (safety-first timeout / * reject behavior, §1.3). * - Loop group present → the node re-enters `ready` (blocked → ready) with the * rejection feedback merged into its re-execution prompt, so it (and the loop * that feeds it) re-runs. Callers that want the loop group's upstream nodes * re-entered as well reuse `propagateRevise` (signal-propagation.ts) on the * feeding convergence node. * * Pure state mutation — never dispatches. Re-entered-`ready` nodes are added to * the frontier for the caller's `_dispatchReadyNodes` step. * * @returns {@link RejectReport} describing the lane taken. */ export declare function rejectBlockedNode(state: EngineState, node: NodeRuntimeState, reason?: string): RejectReport; /** * Phase 1 + Phase 2 of the partial-approval algorithm (orchestration-patterns.md * §1.5): find every node transitively downstream of a rejected upstream branch * and cancel those that cannot survive on their remaining approved sources * alone. * * - Phase 1 BFS: collect nodes transitively reachable from any rejected node * along `always` / `on_signal(answer)` edges, excluding the approval node * itself (it stays put to re-render). * - Phase 2: for each downstream node, cancel it when it has no surviving * approved upstream, or when its join cannot be met by approved sources alone * (`all` needs every feeder; `quorum:N` needs N). `any` joins survive on a * single approved source. Nodes that depend on a mix of approved + rejected * upstream are **not** cancelled — they enter a partial-await and re-join once * the rejected source re-executes and re-answers (§1.5 rule 2). * * Cancelled nodes transition `pending | ready | running → cancelled → done` * (reusing the cascade-canceller lifecycle pattern) and, when a cancel seam is * present, their dispatch tasks are torn down fire-and-forget (never awaited). * * @param rejectedNodeIds Upstream branches the human rejected. * @param approvalNodeId The `needs_approval` node issuing the partial verdict. * @param dispatchPort Optional cancellation seam (task teardown). */ export declare function pruneDownstreamSubgraph(state: EngineState, rejectedNodeIds: string[], approvalNodeId: string, dispatchPort?: CancelDispatchPort): PruneReport; /** * Re-enter the rejected upstream nodes `ready` with the rejection feedback so * they re-execute and re-answer, which re-satisfies the approval node's join. * Only nodes currently transitionable to `ready` are re-entered (completed → * ready; never a still-running or terminal node). */ export declare function reenterRejectedUpstreams(state: EngineState, rejectedNodeIds: string[], reason?: string): ReentryReport; /** * Clear the rejected sources from an approval node's accumulated upstream * results and recompute its join satisfaction, so the approval node re-waits * for the rejected branches to re-execute and re-answer before it re-renders. * Returns the recomputed join verdict via `node.joinSatisfied`. */ export declare function resetRejectedUpstreams(state: EngineState, node: NodeRuntimeState, rejectedNodeIds: string[]): void; //# sourceMappingURL=approval-handler.d.ts.map