Skip to content

feat: per-request headers on workflow runs - #44

Merged
arnaub merged 2 commits into
mainfrom
feat/workflow-request-headers
Sep 2, 2026
Merged

arnaub merged 2 commits into
mainfrom
feat/workflow-request-headers

Conversation

@arnaub

@arnaub arnaub commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Why

The backend calls workflows with the legacy MASTRA_API_KEY shared secret and no actor headers.
factorial-agent treats a shared secret as identity not proven, calls clearActor(), and drops
factorialIdActor and availableBillableBuckets. A workflow run therefore arrives anonymous and
cannot be billed at all.

Agents do not have this problem: AiInteractions::AgentGateway sends a factorial-id service token
and the actor headers through #generate, which has accepted headers: since #40. This gives
workflows the same route.

What

1. Per-request headers on workflow runs.
headers: on Ai::Client#run_workflow and both implementations; http_post applies them and the
results GET sets 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 replace
the 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 write
T.all(T::Class[Ai::Workflow], Ai::Workflow::ClassMethods).

The abstract call types input as T.untyped deliberately: each class narrows it to its own
Input, and a concrete type on the abstract method makes that an illegal override — a parameter's
type 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. Version 0.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 up
both the headers: parameter and the include.

Checks

rspec 106 examples, 0 failures. rubocop 39 files, no offenses. srb tc no errors.

Next

  1. AiInteractions::WorkflowGateway in the monolith: mint the service token, compute
    AiCredits::BillableBuckets.available_for, send the actor headers.
  2. createOneWorkflow in factorial-agent to charge the run — once per workflow, not once per agent
    inside it.

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
arnaub marked this pull request as ready for review September 2, 2026 09:29
@arnaub
arnaub requested a review from a team as a code owner September 2, 2026 09:29
rubenmoya
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.
@arnaub
arnaub merged commit e228820 into main Sep 2, 2026
1 check passed
@arnaub
arnaub deleted the feat/workflow-request-headers branch September 2, 2026 12:19
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)
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.

2 participants