feat(execution-history): attribute runs to a trigger actor + filter by actor - #20
Conversation
Greptile SummaryThis PR wires the previously-unused
Confidence Score: 5/5Safe to merge — all changes are additive, existing runs with no actor data gracefully render as '—', and the three issues raised in the previous review round have been fully addressed. The actor attribution plumbing is end-to-end consistent: the SDK type, both entrypoints (HTTP and MCP), the storage buffer, the persistence layer, the API facet, and the UI all use the same stable actorId key with a mutable snapshot label. Null-actor runs (pre-migration or anonymous) are handled at every layer. The executeWithPause options-forwarding fix in the usage-tracking wrapper is correct. No regressions are introduced — the trigger/actor fields are optional everywhere a caller that doesn't supply them could reach the code. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant HTTPHandler as HTTP Execute Handler
participant MCPBuild as MCP Session Build
participant Engine as Execution Engine
participant Store as Execution History Store
participant UI as Runs UI
Client->>HTTPHandler: POST /execute (with AuthContext)
HTTPHandler->>HTTPHandler: executionActorFromPrincipal(auth)
HTTPHandler->>Engine: executeWithPause(code, trigger: http+actor)
Engine->>Store: "ExecutionStarted { trigger: { kind, actor } }"
Store->>Store: buffer actorId, actorLabel, actorKind
Client->>MCPBuild: Build MCP Session (Principal)
MCPBuild->>MCPBuild: executionActorFromPrincipal(principal)
MCPBuild->>Engine: "createExecutorMcpServer({ trigger: mcp+actor })"
Engine->>Store: "ExecutionStarted { trigger: { kind, actor } }"
Store->>Store: onExecutionFinished: persist RunRow (actorId, actorLabel, actorKind)
UI->>Store: "list({ actorFilter: [tok_abc] })"
Store->>Store: buildRunsWhere with actorId filter
Store->>Store: "groupCount(actorId) -> actorGroups"
Store->>Store: per-actor label lookup (within filter window, concurrency: 10)
Store-->>UI: "runs + meta { actorCounts }"
UI-->>Client: Actor column + filter facet rendered
Reviews (4): Last reviewed commit: "refactor(cloud): derive AuthContext via ..." | Re-trigger Greptile |
|
@greptile review |
ExecutionStarted already carried an optional trigger, but no caller populated
it, so runs showed an unknown trigger. Add an ExecutionActor {kind,id,label} to
ExecutionTrigger and populate the trigger at both execute call sites: the HTTP
execute handler (kind "http") and the MCP session build (kind "mcp"), each
resolving the actor from the request principal.
Principal/AuthContext gain an actor (derived via executionActorFromPrincipal,
defaulting to a "user" actor keyed by accountId); the host-mcp Principal schema
carries it too so it survives cross-isolate session serialization. The HTTP
handler reads AuthContext optionally so it adds no new requirement. A host that
authenticates a machine credential acting as a human (a service token) sets
principal.actor to keep the credential distinct from the subject it acts as.
Persist the trigger actor on each run (indexed actorId plus an actorLabel/actorKind snapshot), filter the runs list by actorFilter, and surface an actorCounts facet keyed on the stable actorId that renders the most-recent label and kind. The runs UI gains an Actor facet in the filter rail, an Actor column, and an Actor field in the detail drawer.
The actor facet count already respected the active filters, but the per-actor label/kind lookup queried by actorId alone — so under a time-range or status filter it could surface a globally-newest label snapshot that predates the window and mismatches the filtered count. Apply the same filter `where` to the label lookup, and bound its per-actor fan-out (concurrency 10) so a workspace with many distinct token actors doesn't burst unboundedly on meta computation.
The WorkOS org-auth path hand-rolled the AuthContext (and the run-actor label expression), duplicating executionActorFromPrincipal. Wrap the session in a neutral Principal and route through authContextFromPrincipal so the actor derivation lives in one place.
206a318 to
34c6c2f
Compare
…22) ## What Threads the run **actor** through the Cloudflare MCP session Durable Object so MCP-triggered runs carry a `{kind:"mcp", actor}` trigger — the MCP half of run actor attribution (HTTP runs already got one). ## Changes - `McpSessionInit` gains an optional `actor`; the worker dispatcher stamps it from the gate's resolved principal at session create. - The shared DO base carries `actor` onto the persisted `SessionMeta` (alongside `webOrigin`), so a cold isolate rebuilds with the same attribution. - Each host's `buildMcpServer` (`apps/host-cloudflare`) stamps the trigger, falling back to a `user` actor keyed by the session user when the principal supplied none. Generic plumbing only — it threads whatever actor the principal carries (no service-token specifics here). Pairs with the actor contract from #20. ## Verification - `typecheck` 41/41 · lint + format clean on all changed files (Build/Deploy-preview checks fail on the fork's missing R2 secrets, as on prior PRs — unrelated.)
…23) ## The bug The runs list returns an **empty-body 400** for everyone. Root cause is a schema/data-evolution bug from #20, not anything resource-related: `RunRow.actorId/actorLabel/actorKind` were `Schema.NullOr(...)` — present-but-nullable, which means **the key must exist**. Runs are stored as JSON documents, and every run written *before* #20 has a `data` doc with no such key. The store passes stored docs straight to the HTTP response encoder, which rejects the newest row with `Missing key at ["runs"][0]["actorId"]`. A response-encode failure emits a bare 400 (no body, no content-type) — the empty-body signature. Since no executions have happened since the actor store deployed, 100% of stored runs are legacy and every request fails. ## The fix Make the three actor keys optional with a **decoding default of null**: ```ts actorId: Schema.optional(Schema.NullOr(Schema.String)).pipe( Schema.withDecodingDefaultType(Effect.succeed(null)), ) ``` - **Decoded type stays `string | null`** (always present) — every reader still treats the field as required; it just defaults to `null` for pre-actor runs. - **Wire/storage tolerates an absent key** — legacy docs decode *and* encode unchanged. - Immunizes the collection against the next field added the same way. No data migration, no prod writes — deploy-forward and the page is back with all run history intact. ## Verification - New regression test round-trips a legacy-shaped doc (decode → null; **encode with the keys absent succeeds** — the exact failure). - execution-history suite 20/20, typecheck 41/41, lint + format clean. (Build/Deploy-preview checks fail on the fork's missing R2 secrets, as on prior PRs — unrelated.)
…y actor (#20) Records **who/what triggered each run** and lets the runs page filter by it. The engine's `ExecutionStarted` already carried an optional `trigger`, but no caller ever populated it — so every run rendered with an unknown trigger. This wires it end to end and adds an identity ("actor") dimension on top. **Trigger actor plumbing (generic, carrier-agnostic)** - `ExecutionTrigger` gains an `ExecutionActor {kind, id, label}`. - The trigger is populated at both execute call sites: the HTTP execute handler (`kind: "http"`) and the MCP session build (`kind: "mcp"`), each resolving the actor from the request principal. - `Principal`/`AuthContext` gain an `actor`, derived by `executionActorFromPrincipal` (defaults to a `user` actor keyed by `accountId`). The host-mcp `Principal` schema carries it too, so it survives cross-isolate session serialization. The HTTP handler reads `AuthContext` optionally, so it adds no new requirement. A host that authenticates a machine credential acting as a human can set `principal.actor` to keep the credential distinct from the subject it acts as. **execution-history** - `RunRow` persists an indexed `actorId` plus an `actorLabel`/`actorKind` snapshot. - The runs list filters by `actorFilter`; an `actorCounts` facet (keyed on the stable `actorId`, rendering the most-recent label + kind) joins the existing facets. - UI: an **Actor** facet in the filter rail, an **Actor** column, and an Actor field in the run detail drawer. As a side effect this also fixes the long-standing "unknown trigger" — `triggerKind` is now set (`http`/`mcp`). - `typecheck` 41/41 · `lint` + `format` clean on all changed files - tests green: execution-history 17 (incl. a new test covering actor persistence, the facet label/kind, and `actorFilter`), api 45, host-mcp 32, cloud 52 The seam is fully generic. A host that aliases a machine credential to a human subject populates `principal.actor` with the credential's own id/label so the Actor column/facet distinguish it from the human — no further UI change required.
…23) ## The bug The runs list returns an **empty-body 400** for everyone. Root cause is a schema/data-evolution bug from #20, not anything resource-related: `RunRow.actorId/actorLabel/actorKind` were `Schema.NullOr(...)` — present-but-nullable, which means **the key must exist**. Runs are stored as JSON documents, and every run written *before* #20 has a `data` doc with no such key. The store passes stored docs straight to the HTTP response encoder, which rejects the newest row with `Missing key at ["runs"][0]["actorId"]`. A response-encode failure emits a bare 400 (no body, no content-type) — the empty-body signature. Since no executions have happened since the actor store deployed, 100% of stored runs are legacy and every request fails. ## The fix Make the three actor keys optional with a **decoding default of null**: ```ts actorId: Schema.optional(Schema.NullOr(Schema.String)).pipe( Schema.withDecodingDefaultType(Effect.succeed(null)), ) ``` - **Decoded type stays `string | null`** (always present) — every reader still treats the field as required; it just defaults to `null` for pre-actor runs. - **Wire/storage tolerates an absent key** — legacy docs decode *and* encode unchanged. - Immunizes the collection against the next field added the same way. No data migration, no prod writes — deploy-forward and the page is back with all run history intact. ## Verification - New regression test round-trips a legacy-shaped doc (decode → null; **encode with the keys absent succeeds** — the exact failure). - execution-history suite 20/20, typecheck 41/41, lint + format clean. (Build/Deploy-preview checks fail on the fork's missing R2 secrets, as on prior PRs — unrelated.)
…y actor (#20) Records **who/what triggered each run** and lets the runs page filter by it. The engine's `ExecutionStarted` already carried an optional `trigger`, but no caller ever populated it — so every run rendered with an unknown trigger. This wires it end to end and adds an identity ("actor") dimension on top. **Trigger actor plumbing (generic, carrier-agnostic)** - `ExecutionTrigger` gains an `ExecutionActor {kind, id, label}`. - The trigger is populated at both execute call sites: the HTTP execute handler (`kind: "http"`) and the MCP session build (`kind: "mcp"`), each resolving the actor from the request principal. - `Principal`/`AuthContext` gain an `actor`, derived by `executionActorFromPrincipal` (defaults to a `user` actor keyed by `accountId`). The host-mcp `Principal` schema carries it too, so it survives cross-isolate session serialization. The HTTP handler reads `AuthContext` optionally, so it adds no new requirement. A host that authenticates a machine credential acting as a human can set `principal.actor` to keep the credential distinct from the subject it acts as. **execution-history** - `RunRow` persists an indexed `actorId` plus an `actorLabel`/`actorKind` snapshot. - The runs list filters by `actorFilter`; an `actorCounts` facet (keyed on the stable `actorId`, rendering the most-recent label + kind) joins the existing facets. - UI: an **Actor** facet in the filter rail, an **Actor** column, and an Actor field in the run detail drawer. As a side effect this also fixes the long-standing "unknown trigger" — `triggerKind` is now set (`http`/`mcp`). - `typecheck` 41/41 · `lint` + `format` clean on all changed files - tests green: execution-history 17 (incl. a new test covering actor persistence, the facet label/kind, and `actorFilter`), api 45, host-mcp 32, cloud 52 The seam is fully generic. A host that aliases a machine credential to a human subject populates `principal.actor` with the credential's own id/label so the Actor column/facet distinguish it from the human — no further UI change required.
…23) ## The bug The runs list returns an **empty-body 400** for everyone. Root cause is a schema/data-evolution bug from #20, not anything resource-related: `RunRow.actorId/actorLabel/actorKind` were `Schema.NullOr(...)` — present-but-nullable, which means **the key must exist**. Runs are stored as JSON documents, and every run written *before* #20 has a `data` doc with no such key. The store passes stored docs straight to the HTTP response encoder, which rejects the newest row with `Missing key at ["runs"][0]["actorId"]`. A response-encode failure emits a bare 400 (no body, no content-type) — the empty-body signature. Since no executions have happened since the actor store deployed, 100% of stored runs are legacy and every request fails. ## The fix Make the three actor keys optional with a **decoding default of null**: ```ts actorId: Schema.optional(Schema.NullOr(Schema.String)).pipe( Schema.withDecodingDefaultType(Effect.succeed(null)), ) ``` - **Decoded type stays `string | null`** (always present) — every reader still treats the field as required; it just defaults to `null` for pre-actor runs. - **Wire/storage tolerates an absent key** — legacy docs decode *and* encode unchanged. - Immunizes the collection against the next field added the same way. No data migration, no prod writes — deploy-forward and the page is back with all run history intact. ## Verification - New regression test round-trips a legacy-shaped doc (decode → null; **encode with the keys absent succeeds** — the exact failure). - execution-history suite 20/20, typecheck 41/41, lint + format clean. (Build/Deploy-preview checks fail on the fork's missing R2 secrets, as on prior PRs — unrelated.)
What
Records who/what triggered each run and lets the runs page filter by it.
The engine's
ExecutionStartedalready carried an optionaltrigger, but no caller ever populated it — so every run rendered with an unknown trigger. This wires it end to end and adds an identity ("actor") dimension on top.Changes
Trigger actor plumbing (generic, carrier-agnostic)
ExecutionTriggergains anExecutionActor {kind, id, label}.kind: "http") and the MCP session build (kind: "mcp"), each resolving the actor from the request principal.Principal/AuthContextgain anactor, derived byexecutionActorFromPrincipal(defaults to auseractor keyed byaccountId). The host-mcpPrincipalschema carries it too, so it survives cross-isolate session serialization. The HTTP handler readsAuthContextoptionally, so it adds no new requirement. A host that authenticates a machine credential acting as a human can setprincipal.actorto keep the credential distinct from the subject it acts as.execution-history
RunRowpersists an indexedactorIdplus anactorLabel/actorKindsnapshot.actorFilter; anactorCountsfacet (keyed on the stableactorId, rendering the most-recent label + kind) joins the existing facets.As a side effect this also fixes the long-standing "unknown trigger" —
triggerKindis now set (http/mcp).Verification
typecheck41/41 ·lint+formatclean on all changed filesactorFilter), api 45, host-mcp 32, cloud 52Follow-up
The seam is fully generic. A host that aliases a machine credential to a human subject populates
principal.actorwith the credential's own id/label so the Actor column/facet distinguish it from the human — no further UI change required.