Skip to content

fix(flows): expose flows_run_detached over RPC and switch both UI Run controls to it - #5296

Merged
graycyrus merged 1 commit into
tinyhumansai:mainfrom
graycyrus:feat/flows-run-detached-rpc
Jul 31, 2026
Merged

fix(flows): expose flows_run_detached over RPC and switch both UI Run controls to it#5296
graycyrus merged 1 commit into
tinyhumansai:mainfrom
graycyrus:feat/flows-run-detached-rpc

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Stacked on #5286 (fix/flows-resume-run-lifecycle). This branch contains that PR's commit as its base. Review only the second commit, and merge after #5286.

Summary

  • The canvas live-run overlay could never show live progress, and one Run click froze every other row on the Workflows page for the whole run. Both had the same root cause.
  • Exposes the already-existing flows_run_detached over RPC and switches both UI Run controls to it.
  • Also fixes three canvas defects that live in the same files: a mid-conversation blank-canvas reveal, a false "Unsaved" badge, and a stale doc claim.

Problem

openhuman.flows_run blocks server-side until the run terminates (up to FLOW_RUN_TIMEOUT_SECS; the client mirrors it with a 610s timeout). Both Run entry points awaited it as though it were fire-and-forget:

  • FlowCanvasPage.tsx set activeRunId from the RPC's return value, but useFlowRunProgress only subscribes to flow:run_progress once activeRunId is set — i.e. after every progress event has already fired and been dropped. So nodes never ringed running/success/error; the overlay only ever worked for the rare pending_approval pause.
  • FlowsPage.tsx awaited it under a page-global busyKey, so a 5-minute run disabled Run/Toggle on every row for 5 minutes and fired the "Run started" toast only when the run ended. Its inline comment claimed "Fire-and-forget: the caller doesn't wait for the run to finish" — which did not match the RPC's semantics.

ops::flows_run_detached already existed and returns {run_id, detached:true} immediately, registering the run before returning its id — but it was never exposed over RPC. Its only caller was the agent run_flow tool, and its doc comment claimed it "mirrors how the UI 'Run' control … fire runs fire-and-forget", which was simply false.

Solution

  • Backend: registers flows.run_detached (wire openhuman.flows_run_detached) as a thin controller over the existing ops::flows_run_detached. flows.run stays registered — the agent tool and existing callers still use the blocking form. The false doc comment is corrected rather than deleted, since it becomes true with this change.

  • Frontend: runFlowDetached() uses the default timeout (it returns immediately, so the 610s budget is wrong for it). FlowCanvasPage sets activeRunId from the immediate response, so the progress subscription is established as early as it can be.

    To be precise rather than overclaim: this does not make the window strictly zero. flows_run_detached spawns the run and returns without awaiting it, so the first FlowRunProgress could in principle be published before the HTTP response is even sent. In practice the round-trip plus a React commit is far slower than a tokio::spawn scheduling gap, and the consequence is cosmetic — at worst the first node's animation frame is missed, and useFlowRunPoller's 2s durable-row fallback recovers it. Run history is never affected. FlowsPage tracks busy state per row (busyByFlow: Record<flowId, 'toggle'|'run'>) instead of one global value.

  • Completion signal preserved: since the RPC no longer blocks until completion, FlowsPage now uses useFlowRunFinished — the same hook the runs sidebar/drawer already use — to refetch when a run settles, so last_run_at/last_status still update. The runs rail itself is untouched; it already reconciled detached runs from the agent-initiated path.

Also fixed (same files, so they belong here)

  • F-m2chatFirst was re-derived from the live initialBuildSeed prop every render, so clearBuildSeed flipped it false and revealed the blank trigger-only canvas mid-conversation whenever the builder's first turn ended with a clarifying question. Now latched at mount.
  • F-m3 — false "Unsaved" after a canvas remount. The mount-time onGraphChange writes concrete auto-layout positions for nodes the server stored without position, so opening an agent-built flow and rejecting a proposal showed a dirty badge with zero user edits. Both sides of the dirty comparison are now normalized at comparison time. (A first attempt that pre-normalized only the persisted seed made the first mount read dirty and broke two tests — normalizing both operands inside the memo is correct for first-mount and remount alike.)
  • F-m5ReadonlyFlowCanvas's doc claimed the /flows/:id viewer uses it, but no production caller reaches it. Doc corrected rather than deleted: it has 7 passing tests, and discarding that coverage for a cleanliness win was out of proportion to this PR's scope. Left as a documented, inert fallback with a pointer to remove it if no consumer ever appears.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — Rust controller coverage in tests/json_rpc_e2e.rs; Vitest covers the detached call not using the long timeout, FlowsPage keeping other rows interactive (the F-M2 regression), and FlowCanvasPage setting activeRunId before any progress event
  • Coverage matrix updated — N/A: bug fix to existing Run controls, no feature rows added/removed/renamed
  • All affected feature IDs from the matrix are listed under ## RelatedN/A: no matrix feature rows affected
  • No new external network dependencies introduced
  • Manual smoke checklist updated if this touches release-cut surfaces — Run is a release-cut surface, but its user-visible contract is unchanged (click Run, see it run); only the transport changes. Worth a manual pass on the canvas overlay at review time.
  • Linked issue closed via Closes #NNNN/A: found by code review, no tracking issue filed yet

Impact

  • Runtime/platform: Rust core (new controller) + desktop frontend.
  • Behaviour: the canvas overlay now actually animates node state during a run, and the Workflows list stays usable while a run is in flight. The "Run started" toast now fires when the run starts.
  • API: adds openhuman.flows_run_detached. Purely additive; openhuman.flows_run is unchanged and still registered.
  • Risk: the completion signal now arrives via the event/poll path rather than the RPC return. Review found FlowsPage had only the event half of that pair, unlike the three other run-outcome surfaces which also carry a poll backstop; a bounded backstop has since been added and pinned by tests. Still the behaviour worth exercising manually, since it is where a regression would hide.
  • i18n: no new strings.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

Validation Run

  • pnpm --filter openhuman-app format:check — prettier clean (after --write on 4 files); eslint 0 errors on touched files
  • pnpm typecheck — exit 0
  • Focused tests: Vitest sweep src/pages src/services/api src/components/flows src/lib/flows src/hooks1637 passed, 145 files; cargo test --lib openhuman::flows554 passed, 0 failed
  • Rust fmt/check (if changed): cargo check clean on BOTH the default build and the disabled build (--no-default-features --features tokenjuice-treesitter)
  • Tauri fmt/check (if changed): N/A, app/src-tauri untouched

Validation Blocked

  • command: cargo fmt --check
  • error: one pre-existing formatting drift at ops.rs:5241
  • impact: none — confirmed via git show HEAD:... that it exists in the base commit and is outside this diff, so it was left alone rather than swept into this PR

Behavior Changes

  • Intended behavior change: the UI Run controls dispatch a detached run and observe progress via events instead of blocking on the RPC.
  • User-visible effect: live node-state animation on the canvas during a run; the Workflows list stays interactive while a run is in flight; "Run started" fires at start, not at finish.

Parity Contract

  • Legacy behavior preserved: openhuman.flows_run is untouched and still registered for the agent tool and any external caller; the runs rail/drawer/WorkflowRunsPage are untouched and already handled detached runs.
  • Guard/fallback/dispatch parity checks: flows_run_detached registers the run before returning its id, so a flows_cancel_run landing immediately after the UI receives it still takes the signalled branch.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this one
  • Resolution: N/A

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3dc1302-9021-4c8c-9e15-c75184f06fd1

📥 Commits

Reviewing files that changed from the base of the PR and between ecb22ce and 4e95a21.

📒 Files selected for processing (15)
  • app/src/components/flows/canvas/FlowCanvas.tsx
  • app/src/components/flows/canvas/__tests__/flowCanvasOutlines.test.ts
  • app/src/index.css
  • app/src/lib/flows/graphAdapter.test.ts
  • app/src/lib/flows/graphAdapter.ts
  • app/src/pages/FlowCanvasPage.tsx
  • app/src/pages/FlowsPage.test.tsx
  • app/src/pages/FlowsPage.tsx
  • app/src/pages/__tests__/FlowCanvasPage.test.tsx
  • app/src/services/api/flowsApi.test.ts
  • app/src/services/api/flowsApi.ts
  • src/openhuman/flows/ops.rs
  • src/openhuman/flows/schemas.rs
  • src/openhuman/tinyflows/observability.rs
  • tests/json_rpc_e2e.rs

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

@graycyrus
graycyrus force-pushed the feat/flows-run-detached-rpc branch from 0a4a65b to c24793c Compare July 30, 2026 19:08
@graycyrus

Copy link
Copy Markdown
Contributor Author

Review follow-up: added the completion backstop FlowsPage was missing — this was the one real regression risk this PR carried, and the PR body flagged it as the thing to watch.

Because flows_run_detached returns as soon as the run is registered, a row's last_run_at/last_status is now refreshed by the FlowRunFinished broadcast rather than by the RPC returning. That broadcast is a plain io.emit to currently-connected sockets with no server-side replay — so a socket drop between clicking Run and the run settling (reconnect gap, sleeping laptop, backgrounded tab) loses it, and the row shows a stale outcome until the user navigates away and back.

Every other run-outcome surface already pairs useFlowRunFinished with useFlowRunsLiveRefresh for exactly this reason — FlowRunsSidebar, FlowRunsDrawer, WorkflowRunsPage — and that hook's own doc calls the poll "a long-interval backstop for the (rare) case a broadcast event is dropped under lag or a socket reconnect gap." FlowsPage became a fourth consumer and was the only one without the backstop half of the pair.

The hook can't be reused verbatim (it is typed for FlowRun[]; this page holds Flow[]), so the same guarantee is rebuilt against outstanding run ids started from this page. It is bounded: entries older than 15 minutes are dropped, comfortably past the engine's ~600s ceiling, so a slow run is never abandoned while one that never reports terminal cannot poll forever. Nothing outstanding means no polling at all.

Two tests pin it: the backstop fires when no FlowRunFinished ever arrives, and no poll happens when nothing is outstanding. Both use advanceTimersByTimeAsync rather than waitFor, which deadlocks under fake timers.

1639 frontend tests pass (145 files), typecheck and prettier clean.

Also confirmed by review and worth recording: the subscribe-before-events window is not strictly zero — flows_run_detached spawns the run and returns without awaiting, so the first FlowRunProgress could in principle precede the client's subscription. Impact is cosmetic (the first node's animation frame) and self-heals within 2s via useFlowRunPoller's durable-row fallback; run history is never affected. I've corrected that overclaim in the PR description rather than leave it standing.

@graycyrus
graycyrus force-pushed the feat/flows-run-detached-rpc branch 2 times, most recently from 880a234 to a999b0c Compare July 31, 2026 06:14
… controls to it

The UI Run buttons (Workflow Canvas + Workflows list) awaited `flows_run`,
which blocks server-side until the run terminates (up to 600s). This meant
`activeRunId` was only set AFTER every `flow:run_progress` event had already
fired and been dropped (`useFlowRunProgress` only subscribes once it's set),
so the canvas overlay could never show live progress (F-M1), and the list
page's page-global busy lock froze every OTHER row's Run/Toggle for the
run's whole duration while toasting "Run started" only once it had actually
finished (F-M2).

`ops::flows_run_detached` already existed (registers the run and returns
its id before returning) but was only reachable from the agent `run_flow`
tool, never over RPC. This registers it as `flows.run_detached`:

- src/openhuman/flows/schemas.rs: new controller at all five required
  sites (schema list, registered-controllers list, match arm + handler,
  and both in-file function-list pin tests).
- app/src/services/api/flowsApi.ts: `runFlowDetached()`, using the
  client's default RPC timeout (not FLOW_RESUME_TIMEOUT_MS) since it
  returns immediately.
- FlowCanvasPage.tsx: Run sets `activeRunId` from the immediate response,
  so the progress subscription is live before the engine executes a node.
- FlowsPage.tsx: `busyKey` is now keyed per flow id (was page-global), Run
  uses the detached call, and `useFlowRunFinished` silently refetches the
  list on completion so `last_run_at`/`last_status` still update without
  the blocking-call timing trick this replaces.

Also in scope (touch the same files):
- F-m2: latch `chatFirst` at mount instead of re-deriving it from the
  live `initialBuildSeed` prop, which flipped false (revealing the blank
  canvas mid-conversation) once the build seed was consumed after a
  clarifying-question first turn.
- F-m3: normalize both sides of the canvas's dirty check through the same
  workflowGraphToXyflow/xyflowToWorkflowGraph round-trip
  (`normalizeWorkflowGraphForDirtyCheck`) before comparing, so a graph
  saved without node positions no longer reads as dirty the instant a
  remounted canvas reports its auto-laid-out positions back.
- F-m5: corrected FlowCanvas.tsx's doc comment, which claimed the
  `/flows/:id` viewer uses `ReadonlyFlowCanvas` — it doesn't (the sole
  consumer always passes `editable`). Kept the component rather than
  deleting it: it's small, self-contained, and has its own passing test
  coverage.

Tests: Rust controller test + updated pin tests (554 passed in
`openhuman::flows`); a new JSON-RPC E2E proving `flows_run_detached`
returns before the run settles; Vitest coverage for the non-blocking
client call, the FlowsPage per-row busy regression, and FlowCanvasPage
setting `activeRunId` before any progress event is dropped (1637 passed
across the touched frontend directories).
@graycyrus
graycyrus force-pushed the feat/flows-run-detached-rpc branch from a999b0c to 4e95a21 Compare July 31, 2026 09:58
@graycyrus

Copy link
Copy Markdown
Contributor Author

Manual testing found the overlay never actually worked — two bugs, both fixed here

Verified live in the running app. The canvas overlay this PR is about did not render at all, and tracing it turned up two independent causes.

1. FlowRunObserver never implemented on_step_start (this PR's bug)

tinyflows' RunObserver has four hooks; we implemented three. on_step_start was left as the trait's default no-op, so the socket only ever carried success/error — emitted after a node had already finished. A node was never marked running, so .flow-node-running and its flow-node-run-pulse keyframe (written for exactly this, complete with a prefers-reduced-motion override) were unreachable dead code. What shipped was a completion trail, not a live overlay.

Now publishes FlowRunProgress { status: "running" } on node activation. Publish-only, deliberately — the durable flow_runs row stays finish-driven, since a started-but-unfinished node has no output, duration or terminal status to record, and a placeholder row would put a running step into run history that the settle-time reconstruction would then reconcile away.

2. A global outline: none !important blanked every canvas ring (pre-existing)

Even after fixing #1 nothing appeared. app/src/index.css carried:

* { outline: none !important; }

Its own comment says it exists to hide the browser focus ring — but on a universal selector with !important it outranks every component rule and suppressed all outlines. All four flow-canvas rings are drawn with outline, so this silently killed:

  • the live run overlay (.flow-node-running / -success / -failed)
  • the validation ring (.flow-node-error)
  • the copilot diff overlay (.flow-node-added / -removed)

Three features beyond this PR have never rendered, in any build, with no test failing — the classes were being applied to the DOM correctly the whole time, so only a human looking at the canvas could catch it.

Removed outline from the bare * rule and kept the tap-highlight suppression. Focus rings stay suppressed via the existing *:focus rule and the explicit element list, so the comment's stated intent is preserved exactly.

How it was proven

A CDP DOM probe sampling every 500ms showed the classes cascading correctly all along:

t+6.5s   step_one:RUNNING
t+14.5s  step_one:success   step_two:RUNNING
t+23s    step_two:success   step_three:RUNNING
t+31s    step_three:success

Forcing a class then reading computed style gave outline: 0px none — even for a hardcoded literal colour, which ruled out the CSS variable and pointed at a global override. After the fix all four states compute correctly (2px solid rgb(47,110,244) etc.) and stay off with no class applied. Confirmed visually in the app.

Guards added

  • A Rust test failing if on_step_start reverts to the default no-op — otherwise the pulse silently becomes unreachable again with every other test green.
  • A frontend test failing if a bare * rule ever sets outline again, plus assertions that each ring still declares an outline and that running stays animated. Verified it genuinely fails against the old CSS.

574 flows + 178 tinyflows + 1642 frontend tests pass; cargo fmt, prettier, tsc clean.

@graycyrus
graycyrus marked this pull request as ready for review July 31, 2026 09:59
@graycyrus
graycyrus requested a review from a team July 31, 2026 09:59

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

graycyrus has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e95a2115f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

event.run_id,
event.status
);
outstandingRunsRef.current.delete(event.run_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the run-list backstop until summary refetch is safe

When a detached run completes normally, the backend publishes FlowRunFinished from finish_flow_run_row before it calls store::record_run to update flow_definitions.last_run_at/last_status (src/openhuman/flows/ops.rs around 4955-4965 and 5306-5324). Because that publish is fire-and-forget, this socket handler can run in between; deleting the outstanding run before the listFlows() refetch means a raced refetch can read the old summary and disables the 30s backstop, leaving the row stale until some unrelated refresh. Keep the run outstanding until a delayed/confirmed summary refresh, or emit/refetch only after record_run has completed.

Useful? React with 👍 / 👎.

@graycyrus
graycyrus merged commit 4884e9a into tinyhumansai:main Jul 31, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant