Skip to content

feat(approvals): add human-in-the-loop approval node (#635)#710

Draft
Leela8256 wants to merge 9 commits intorocketride-org:developfrom
Leela8256:feat/RR-635-human-in-the-loop-approval-node
Draft

feat(approvals): add human-in-the-loop approval node (#635)#710
Leela8256 wants to merge 9 commits intorocketride-org:developfrom
Leela8256:feat/RR-635-human-in-the-loop-approval-node

Conversation

@Leela8256
Copy link
Copy Markdown

@Leela8256 Leela8256 commented Apr 27, 2026

Summary

LLM pipelines in regulated industries — medical, legal, financial, compliance — cannot deliver AI output to users without human sign-off. Today there is no way to pause a RocketRide pipeline mid-flight and wait for a reviewer's decision.

This PR adds that gate. An approval filter node sits on the answers lane between the LLM and the downstream response. The pipeline thread blocks until a human approves, edits, or rejects the response via REST or the new Approvals tab in the pipeline editor. Approved answers flow through (with optional edits applied). Rejected answers are suppressed entirely.

Changes in UI:
Before human in loop:
image

After having human in loop:
image

What the reviewer sees:
image

What ships:

Blocking gate — threading.Event per request; pipeline parks until decided
ApprovalManager — thread-safe registry with create_and_wait() / approve() / reject()
REST API — GET /approvals, POST /approvals/{id}/approve|reject — auto-mounted on every source endpoint
Reviewer UI — Approvals tab in the pipeline editor; polls pending requests, shows payload, lets reviewer edit before approving
Three profiles: auto (dev/dry-run), manual (compliance defaults), custom
Payload truncation, require_reason_on_reject, SSRF-safe webhook notifications
Resolves #635. Addresses the gap list from closed #542.

Type

Feature.

Testing

  • Tests added or updated
  • Tested locally
  • ./builder test passes

Testing

  • 99 unit tests pass across packages/ai/tests and nodes/test/approval
  • 4 contract tests pass: pytest nodes/test/test_contracts.py -k approval
  • Threading tests run 10× — no flakes
  • ruff check and ruff format --check clean
  • ./builder test — requires built engine; CI will validate

Checklist

  • Conventional commits
  • No secrets or credentials
  • Strictly additive — no behavior changes to existing nodes
  • Wiki page for approval node — happy to add if requested

Linked Issue

Resolves #635
References #542


Context

Built on #542 — that PR made the right design decisions (ApprovalManager surface, auto/manual/custom profiles, SSRF guard, answers lane hook). It was closed because the blocking gate was missing. Issue #635 mapped out exactly what was needed; this PR executes on that list.

Gap list from #635

| Item | Status |

  • Blocking gate in writeAnswers()threading.Event per request
  • Inbound REST decision API — four endpoints
  • Shallow copy in get_request() — deepcopy on every store read
  • timeout_action not validated — TimeoutAction.parse() raises on bad values
  • IPv6 ranges in SSRF guard — ::1, fe80::/10, fc00::/7, IPv4-mapped
  • Silent config fallback — IGlobal.beginGlobal raises on bad input
  • manual profile hides webhook_url — exposed in profile + shape
  • Persistence — abstract ApprovalStore + in-memory impl
  • Audit logging — decided_by / decided_at / reason recorded on request
  • Memory cleanup — discard_resolved() API in place

Beyond the gap list

  • Payload truncationmax_payload_chars limits reviewer preview size; original Answer is never mutated; _truncated_to / _original_length markers make truncation visible

  • require_reason_on_reject — HTTP 400 (not 409) when reason is missing; compliance default in manual profile

  • ApprovalDecision.was_modified — prevents a truncated preview from being written back onto the downstream Answer on approval

  • silent notification mode — explicit flag rather than disabling each channel individually

  • depends mock in conftest.py — AI tests now run on a fresh checkout without the built engine

Known gaps (scoped as follow-up)

  • SQLite-backed store (ApprovalStore ABC already in place)
  • Multi-reviewer / N-of-M signoff
  • RBAC on /approve
  • Background TTL sweeper for resolved requests

Acknowledgements

Thanks to the authors of #542 — the surface decisions in that PR are why this one was tractable. And thanks to @asclearuc for the gap list in issue #635, which was specific enough to work from directly.

🤖 Co-authored with Claude Code

Introduce ai.approvals — a thread-safe registry for human-in-the-loop
approval requests with blocking wait semantics, an abstract persistence
backend with an in-memory implementation, and a notifier supporting log +
webhook channels with IPv4 and IPv6 SSRF validation.

This is the foundation PR rocketride-org#542 needed but did not ship: the manager
exposes a real blocking gate via threading.Event, deep-copies all reads
to prevent internal-state mutation, validates timeout_action up front
instead of falling back silently, enforces a pending cap, and rejects
late decisions on already-resolved requests.

The notifier closes IPv6 SSRF holes that PR rocketride-org#542 left open: ::1, fe80::/10,
fc00::/7, and IPv4-mapped IPv6 are all rejected by default, with an
opt-in flag for self-hosted operators that need to target internal hosts.

Tests: 67 unit tests across manager, notifier, store, models, registry —
all pass against pure Python with no engine build required. The shared
conftest also gains a 'depends' module mock so AI tests can run against
a fresh checkout.

Refs rocketride-org#635
Add ai.modules.approvals — a module loadable via WebServer.use('approvals')
that exposes:

  GET    /approvals                    list, optional ?status= filter
  GET    /approvals/{id}               fetch a single request
  POST   /approvals/{id}/approve       { modified_payload?, decided_by?, reason? }
  POST   /approvals/{id}/reject        { decided_by?, reason? }

The handlers are built as a factory bound to an injected ApprovalManager,
so they remain unit-testable on a bare FastAPI app without pulling in
ai.web (and its uvicorn / rocketlib transitive dependencies). A WebServer
type-only import keeps initModule() typed without import-time cost.

Pydantic bodies use extra='forbid' so typos in request payloads surface
as 422s instead of being silently dropped.

The module is added to ai.modules.ALL so server.use('approvals') passes
the existing allowlist check.

Tests: 15 endpoint tests covering happy paths, 404 on unknown ids, 409
on already-resolved transitions, 400 on invalid status filters, 422 on
extra fields, and end-to-end blocking-gate resolution via REST.

Refs rocketride-org#635
Add the 'approval' filter node — the missing piece from PR rocketride-org#542. It
gates the answers lane: writeAnswers() registers a pending approval,
blocks the calling thread until a reviewer resolves it via the REST
API, then either emits the (possibly modified) answer downstream on
approval or suppresses it on rejection.

Configuration is exposed through three preconfig profiles:
  * auto    — instant approval, useful for development.
  * manual  — requires a real reviewer; webhook_url is exposed in this
              profile (PR rocketride-org#542 reviewers flagged that hiding it was a
              config-masking bug).
  * custom  — every dial tunable.

IGlobal validates every config value up front and raises ValueError
on bad input — replacing PR rocketride-org#542's silent fallback. The TimeoutAction
parse step rejects unknown values with a clear error.

Tests: 7 IInstance tests covering: writeAnswers blocks until approve,
does not emit on reject, applies modified_payload correctly for both
text and JSON answers, honors timeout_action, and passes through when
no manager is bound (CONFIG mode). rocketlib + depends are mocked so
the node loads without a built engine.

Refs rocketride-org#635
Mount /approvals routes alongside /data and /profile when the webhook
endpoint starts. Loading is unconditional and inert when no approval
node is in the pipeline; this avoids a coordination problem between
source endpoints and downstream gating nodes (the source bootstraps
the FastAPI server before it knows what's downstream).

Wrapped in try/except so an upgrade path that doesn't include the
approvals module doesn't fail the source bootstrap.

Refs rocketride-org#635
Add a runnable example showing chat -> agent -> approval -> response,
plus a README entry explaining the request lifecycle and the REST
contract a reviewer interacts with.

Refs rocketride-org#635
Three additions parity-matching what PR rocketride-org#542's description listed but its
implementation never delivered, plus a design fix surfaced by the new tests.

1. Payload truncation
   IGlobal exposes max_payload_chars (0 = unlimited). When set, IInstance
   truncates the textual representation of the registered Answer before
   storing it as the reviewer's preview, attaching _truncated_to and
   _original_length markers so reviewers and audit logs can tell that the
   payload they see is a preview rather than the full content. The original
   Answer is never mutated — truncation only affects what the reviewer sees.

2. Require reason on rejection
   New per-request flag require_reason_on_reject (compliance default in the
   manual profile). Manager.reject() raises ApprovalReasonRequiredError when
   the flag is set and no non-empty reason is provided. The REST layer maps
   this exception to HTTP 400 — distinct from the 409 used for already-resolved
   transitions, so reviewers know to retry with a reason rather than concluding
   the request is gone. Approvals are unaffected; the gate is reject-specific.

3. Explicit silent notification mode
   New NotifierConfig.silent flag short-circuits all channels. Surfaces silent
   mode as a first-class option for setups that wire the approvals API into a
   custom dashboard, preventing the easy mistake of leaving log_channel_enabled
   on while expecting the system to be quiet.

Design fix surfaced by the truncation test: ApprovalDecision now carries
payload (the original/registered preview) and modified_payload (only set
when the reviewer edited) as separate fields, with a was_modified property.
Previously decision.payload conflated the two, which meant approving a
truncated request without modifications would have written the truncated
preview back onto the downstream Answer. IInstance now only re-applies the
payload when was_modified is True.

services.json gains the three new fields with full type/title/description,
the manual profile now defaults to require_reason_on_reject=true and
max_payload_chars=50000 (compliance defaults), and the shape is updated.

Tests: +14 cases (5 in manager require-reason coverage, 1 model round-trip,
1 notifier silent-mode, 4 endpoint 400-mapping, 4 IInstance truncation).
99 pass total. Ruff lint + format clean.

Refs rocketride-org#635
Surfaced by ruff D101 during pre-flight audit. The class had docstrings on
all methods but not on the class itself.

Refs rocketride-org#635
… check

Surfaced during pre-push CI audit. The previous unconditional
sys.modules['rocketlib'] = mock_rocketlib statement would clobber the real
engine-bundled module in CI environments, where conftest puts dist/server on
sys.path and other node tests genuinely import rocketlib. The guarded form
only installs the mock when the real module is unavailable (fresh checkouts
without a built engine), so CI-side runs use the real modules and local
contributor runs still work.

Refs rocketride-org#635
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b2c91db2-ab3b-44ac-b638-4944a83f9171

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added docs Documentation module:nodes Python pipeline nodes module:ai AI/ML modules labels Apr 27, 2026
@github-actions
Copy link
Copy Markdown

No description provided.

Adds a human-in-the-loop reviewer UI directly inside the pipeline
editor. When an approval node blocks the pipeline, a live badge
appears on the Approvals tab showing the pending count.

- ApprovalPanel polls GET /approvals?status=pending every 2 s
- Each pending request renders with the payload, an editable textarea
  (reviewer can modify the answer before approving), and a countdown
  timer to the deadline
- Approve posts to POST /approvals/{id}/approve with optional
  modified_payload; the pipeline thread unblocks immediately
- Reject opens a reason modal; enforces require_reason_on_reject when
  the flag is set on the request
- Empty state shown when no requests are pending
- Tab badge clears automatically when the queue drains

Connects the REST API added in packages/ai (issue rocketride-org#635) to a
reviewer-facing UI without requiring any external tool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added the module:ui Chat UI and Dropper UI label Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation module:ai AI/ML modules module:nodes Python pipeline nodes module:ui Chat UI and Dropper UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Node]: human-in-the-loop approval node for pipeline oversight

1 participant