Repository navigation
feat: per-request headers on workflow runs - #44
Merged
Merged
Conversation
Adds an optional `headers:` to `Ai::Client#run_workflow`, mirroring what
`#generate` already accepts, and threads it through every request a run
makes. Also bumps the generator template so the classes it produces take
and forward `headers:`.
A workflow run is three HTTP requests, not one: `create-run`, `stream`,
then `GET /runs/:id`. The actor must travel on all three, so `http_post`
now applies the headers and the results request sets them itself. As with
`#generate`, per-request headers win over the global configuration, which
is what lets a service token replace the shared `api_key`.
Today the backend calls workflows with the legacy MASTRA_API_KEY shared
secret and no actor headers. factorial-agent treats that as identity not
proven and clears the actor, so a workflow run arrives anonymous and
cannot be billed. Agents do not have this problem because
`AiInteractions::AgentGateway` sends a factorial-id service token plus
the actor headers through `#generate`. This change gives workflows the
same route.
Backwards compatible: `headers:` defaults to `{}` everywhere, so existing
callers are unaffected.
Next: a `WorkflowGateway` in the monolith to mint the token and send the
actor, then `createOneWorkflow` in factorial-agent to charge the run.
arnaub
marked this pull request as ready for review
September 2, 2026 09:29
rubenmoya
previously approved these changes
Sep 2, 2026
Generated `Ai::Workflows::*` classes shared no base type, so anything that took a workflow as an argument had to type it `T.untyped`. This adds an interface they include, letting callers write `T.all(T::Class[Ai::Workflow], Ai::Workflow::ClassMethods)` instead. The abstract `call` types `input` as `T.untyped` on purpose. Each class narrows it to its own `Input`, and a concrete type on the abstract method would make that narrowing an illegal override: a parameter's type must be a supertype of the same parameter on the super method. Confining the untyped parameter here removes it from every caller. The interface also makes a class that forgets `self.call` fail with NotImplementedError rather than silently. First consumer: AiInteractions::WorkflowGateway in the monolith, which mints a factorial-id service token and sends the acting employee or company so workflow runs can be attributed and billed. Consumers must regenerate their workflow classes to pick up the include.
rubenmoya
approved these changes
Sep 2, 2026
arnaub
added a commit
that referenced
this pull request
Sep 2, 2026
Two bugs in the workflow generator, both introduced or exposed by #44. ## 1. Generated classes did not typecheck #44 added `include Ai::Workflow` to the template but left the `call` sig without `override.`. Sorbet rejects an implementation of an abstract method that does not declare it, so every regenerated class failed in the consumer: ``` Method `Ai::Workflows::…#call` implements an abstract method `Ai::Workflow::ClassMethods#call` but is not declared with `override.` Sorbet 5051 ``` `override.` is not decoration — it is what makes Sorbet verify the signature is a legal implementation of the abstract method. ## 2. `--all` never worked ```ruby workflow_names = workflow_names # assigns the nil local to itself if workflow_names.empty? # undefined method `empty?' for nil ``` Ruby binds the local as soon as it parses the assignment, so the right-hand side resolves to the local being defined, not to `#workflow_names`. The local is renamed to `names`. Only `--name` worked before, which is why this went unnoticed — the existing generated classes were all produced one at a time. ## Tests Five examples covering both. Each was verified to fail with its fix reverted. The template assertions also pin the `include` and the header forwarding, so a future template edit cannot quietly drop them again. **Note for anyone extending these specs:** `generate_all_workflows` calls `exit 1` when a workflow fails. Driving the whole method under test tears down the RSpec process and silently truncates the run — it looked like passing tests with examples missing. Test the pieces instead. That `exit 1` is worth revisiting separately; a generator that kills its caller is hard to use from anything but a terminal. ## Checks `rspec` 111 examples, 0 failures. `rubocop` 40 files, no offenses. `srb tc` no errors. Version `0.8.0` → `0.8.1`.
pol-torrent
added a commit
that referenced
this pull request
Sep 8, 2026
## Why The work-items context gatherer (factorial monorepo, `WorkItems::Agents::RunAgentJob` → `AiInteractions::AgentGateway`) needs up to 2 minutes for a response, and this client's unset `read_timeout` — Net::HTTP's 60s default — is the **only** ceiling in the whole chain (Sidekiq SLO is advisory, the hop is internal Service DNS with no proxy, Mastra sets no server timeout). Production Datadog shows the endpoint's max at 60.27s: the client hanging up, not the agent finishing. A global bump was rejected deliberately: it would move the failure envelope for every caller. This is per-request, defaulting to today's behaviour. ## What - `Ai::Agent#generate_text` / `#generate_object` accept an optional `read_timeout:` (seconds, default `nil`). - `Ai::Client#generate` (abstract), `Ai::Clients::Mastra#generate` and `#response` thread it through to `build_http`, which applies it only when given — `nil` keeps Net::HTTP's 60s default. - `Ai::Clients::Test` accepts the kwarg and exposes `last_read_timeout` for assertions. - `run_workflow` is untouched: it streams (`read_body`), and `read_timeout` is per-read, so it never trips there. - Version bumped to 0.9.0 (main moved to 0.8.1 under #44–#46). Deliberately same shape as #40 (per-request `headers:`), including keeping the value **out of the `options` hash** — `options` is camelized and POSTed to Mastra as the request body; a transport setting doesn't belong in the payload. Note on semantics (also commented in `build_http`): Net::HTTP times reads per socket read, not wall clock. For a single-body generate response that is effectively time-to-response; it is not a hard total deadline. ## Consumer The factorial monorepo follow-up adds `read_timeout` to `AgentGateway::Request`, threads it from the work-items agent classes (`ContextAgent` → 120s), and bumps the gem ref. ## Verification - `bundle exec rspec`: 95 examples, 0 failures — new specs assert the timeout lands on the `Net::HTTP` instance, the 60s default holds when absent, and both `Ai::Agent` methods forward it. - `bundle exec srb tc`: clean. - `bundle exec rubocop lib spec`: clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The backend calls workflows with the legacy
MASTRA_API_KEYshared secret and no actor headers.factorial-agent treats a shared secret as identity not proven, calls
clearActor(), and dropsfactorialIdActorandavailableBillableBuckets. A workflow run therefore arrives anonymous andcannot be billed at all.
Agents do not have this problem:
AiInteractions::AgentGatewaysends a factorial-id service tokenand the actor headers through
#generate, which has acceptedheaders:since #40. This givesworkflows the same route.
What
1. Per-request headers on workflow runs.
headers:onAi::Client#run_workflowand both implementations;http_postapplies them and theresults
GETsets them itself. A run is three requests —create-run,stream,GET /runs/:id— and the actor must travel on all three, which the main new test asserts. As with#generate, per-request headers win over the global configuration, so a service token can replacethe shared
api_key.2.
Ai::Workflow, so callers can type a workflow class.Generated classes shared no base type, so anything taking a workflow as an argument had to use
T.untyped. They now include an interface, and callers writeT.all(T::Class[Ai::Workflow], Ai::Workflow::ClassMethods).The abstract
calltypesinputasT.untypeddeliberately: each class narrows it to its ownInput, and a concrete type on the abstract method makes that an illegal override — a parameter'stype must be a supertype of the same parameter on the super method. Confining the untyped
parameter to the interface removes it from every caller.
3. The generator template forwards
headers:and includes the interface. Version0.7.0→0.8.0.Compatibility
Backwards compatible:
headers:defaults to{}everywhere, so existing callers are unaffected.Consumers must regenerate their workflow classes (
bin/rails generate ai:workflow --all) to pick upboth the
headers:parameter and the include.Checks
rspec106 examples, 0 failures.rubocop39 files, no offenses.srb tcno errors.Next
AiInteractions::WorkflowGatewayin the monolith: mint the service token, computeAiCredits::BillableBuckets.available_for, send the actor headers.createOneWorkflowin factorial-agent to charge the run — once per workflow, not once per agentinside it.