From 9eac0fb773cd31d5e3a6fff593e8f34b6cd73493 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 24 Jun 2026 11:39:30 +0530 Subject: [PATCH] feat(execution-history): filter approvals by type Record denormalized approval kind flags on run rows so the runs list can filter form and URL approval runs without fetching detail blobs. Update the visible execution-history UI copy from interactions to approvals and cover the handler, schema, and store paths. --- .../execution-history/src/api/group.ts | 3 + .../src/api/handlers.test.ts | 6 +- .../execution-history/src/api/handlers.ts | 1 + .../execution-history/src/react/atoms.ts | 1 + .../src/react/column-header.tsx | 4 +- .../src/react/detail-drawer.tsx | 14 ++-- .../src/react/filter-command-parser.ts | 11 ++- .../src/react/filter-command.tsx | 11 +-- .../src/react/filter-rail.tsx | 41 +++++++++-- .../execution-history/src/react/run-row.tsx | 12 +++- .../src/react/runs-list-state.test.ts | 2 + .../src/react/use-runs-list.ts | 4 ++ .../execution-history/src/react/view.ts | 2 +- .../src/sdk/collections.test.ts | 4 ++ .../execution-history/src/sdk/collections.ts | 17 ++++- .../execution-history/src/sdk/store.test.ts | 72 +++++++++++++++++++ .../execution-history/src/sdk/store.ts | 49 ++++++++++++- 17 files changed, 225 insertions(+), 29 deletions(-) diff --git a/packages/plugins/execution-history/src/api/group.ts b/packages/plugins/execution-history/src/api/group.ts index 2e9d188bca..5eb3637297 100644 --- a/packages/plugins/execution-history/src/api/group.ts +++ b/packages/plugins/execution-history/src/api/group.ts @@ -43,6 +43,8 @@ const RunActorCount = Schema.Struct({ const RunInteractionCounts = Schema.Struct({ withInteraction: Schema.Number, withoutInteraction: Schema.Number, + formApproval: Schema.Number, + urlApproval: Schema.Number, }); const RunChartBucket = Schema.Struct({ bucketStart: Schema.Number, @@ -118,6 +120,7 @@ const ListRunsQuery = Schema.Struct({ from: Schema.optional(Schema.NumberFromString), to: Schema.optional(Schema.NumberFromString), interaction: Schema.optional(Schema.String), + approvalType: Schema.optional(Schema.Literals(["form", "url"])), // Live-tail floor: only runs newer than this `startedAt`. after: Schema.optional(Schema.NumberFromString), sort: Schema.optional(Schema.Literals(["startedAt", "durationMs"])), diff --git a/packages/plugins/execution-history/src/api/handlers.test.ts b/packages/plugins/execution-history/src/api/handlers.test.ts index ae4a022e59..e1743124a6 100644 --- a/packages/plugins/execution-history/src/api/handlers.test.ts +++ b/packages/plugins/execution-history/src/api/handlers.test.ts @@ -46,6 +46,8 @@ const runRow = (overrides: Partial): RunRow => ({ actorId: null, actorLabel: null, actorKind: null, + hadFormApproval: false, + hadUrlApproval: false, startedAt: 1000, completedAt: 2000, durationMs: 1000, @@ -129,7 +131,7 @@ describe("ExecutionHistoryHandlers", () => { const res = yield* get( web, - "http://localhost/execution-history/runs?status=completed,failed&trigger=manual&from=100&to=900&interaction=true&after=50&limit=10&sort=startedAt&dir=asc", + "http://localhost/execution-history/runs?status=completed,failed&trigger=manual&from=100&to=900&interaction=true&approvalType=form&after=50&limit=10&sort=startedAt&dir=asc", ); expect(res.status).toBe(200); const body = (yield* Effect.promise(() => res.json())) as { @@ -147,6 +149,7 @@ describe("ExecutionHistoryHandlers", () => { expect(options?.triggerFilter).toEqual(["manual"]); expect(options?.timeRange).toEqual({ from: 100, to: 900 }); expect(options?.hadInteraction).toBe(true); + expect(options?.approvalType).toBe("form"); expect(options?.after).toBe(50); expect(options?.limit).toBe(10); expect(options?.sortField).toBe("startedAt"); @@ -168,6 +171,7 @@ describe("ExecutionHistoryHandlers", () => { expect(options?.triggerFilter).toBeUndefined(); expect(options?.timeRange).toBeUndefined(); expect(options?.hadInteraction).toBeUndefined(); + expect(options?.approvalType).toBeUndefined(); }), ); diff --git a/packages/plugins/execution-history/src/api/handlers.ts b/packages/plugins/execution-history/src/api/handlers.ts index 86361fd407..f5279f6dbc 100644 --- a/packages/plugins/execution-history/src/api/handlers.ts +++ b/packages/plugins/execution-history/src/api/handlers.ts @@ -97,6 +97,7 @@ export const ExecutionHistoryHandlers = HttpApiBuilder.group( actorFilter: actorFilter.length > 0 ? actorFilter : undefined, timeRange, hadInteraction: parseBooleanFlag(query.interaction), + approvalType: query.approvalType, after: query.after, sortField: query.sort, sortDirection: query.dir, diff --git a/packages/plugins/execution-history/src/react/atoms.ts b/packages/plugins/execution-history/src/react/atoms.ts index 0dd429c597..8d09cb70d3 100644 --- a/packages/plugins/execution-history/src/react/atoms.ts +++ b/packages/plugins/execution-history/src/react/atoms.ts @@ -21,6 +21,7 @@ export interface RunsQuery { readonly from?: number; readonly to?: number; readonly interaction?: string; + readonly approvalType?: "form" | "url"; readonly after?: number; readonly sort?: "startedAt" | "durationMs"; readonly dir?: "asc" | "desc"; diff --git a/packages/plugins/execution-history/src/react/column-header.tsx b/packages/plugins/execution-history/src/react/column-header.tsx index 1a2e9f47e9..e7ba331e23 100644 --- a/packages/plugins/execution-history/src/react/column-header.tsx +++ b/packages/plugins/execution-history/src/react/column-header.tsx @@ -114,10 +114,10 @@ export function RunsColumnHeader({ ) : null} - {/* Interaction (centered) */} + {/* Approval (centered) */} {columns.interaction ? ( - interaction + approval ) : null} diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx index dc8ab875b0..fb38ac9252 100644 --- a/packages/plugins/execution-history/src/react/detail-drawer.tsx +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -216,7 +216,7 @@ function ToolCallsTab(props: { readonly run: RunRow; readonly toolCalls: readonl } // --------------------------------------------------------------------------- -// Interactions (elicitations) — rendered inline on the Properties tab +// Approvals (elicitations), rendered inline on the Properties tab // --------------------------------------------------------------------------- const INTERACTION_BADGE: Record = { @@ -228,7 +228,7 @@ const INTERACTION_BADGE: Record = { }; const interactionKindLabel = (kind: string): string => - kind === "UrlElicitation" ? "url" : kind === "FormElicitation" ? "form" : kind; + kind === "UrlElicitation" ? "URL approval" : kind === "FormElicitation" ? "Form approval" : kind; function InteractionBlock(props: { readonly interaction: InteractionRow }) { const { interaction } = props; @@ -282,14 +282,14 @@ function DetailContent(props: { const pendingCount = props.interactions.filter( (interaction) => interaction.status === "pending", ).length; - // Singular/plural only when every interaction is pending; a mix gets the - // neutral "Interactions" so the heading never misrepresents the block. + // Singular/plural only when every approval is pending; a mix gets the neutral + // "Approvals" so the heading never misrepresents the block. const interactionsHeading = pendingCount > 0 && pendingCount === props.interactions.length ? pendingCount === 1 - ? "Pending interaction" - : "Pending interactions" - : "Interactions"; + ? "Pending approval" + : "Pending approvals" + : "Approvals"; return (
diff --git a/packages/plugins/execution-history/src/react/filter-command-parser.ts b/packages/plugins/execution-history/src/react/filter-command-parser.ts index df16a071b5..978f29853c 100644 --- a/packages/plugins/execution-history/src/react/filter-command-parser.ts +++ b/packages/plugins/execution-history/src/react/filter-command-parser.ts @@ -1,8 +1,8 @@ // --------------------------------------------------------------------------- // Pure parser for the runs filter command palette. Splits a free-text query // into `key:value` tokens and produces a partial filter set. Adapted to the -// plugin's contract: only the 4 statuses, no tool/code/duration facets — the -// keys are status, trigger, interaction, after, and before. +// plugin's contract: only the 4 statuses, no tool/code/duration facets. The +// keys are status, trigger, interaction, approval, after, and before. // // Parsing is forgiving by design: unknown keys, empty values, and unparsable // dates are silently dropped (never throws). Relative durations like "1h" / @@ -14,6 +14,7 @@ export interface RunsFilterTokens { readonly status: string[]; readonly trigger: string[]; readonly interaction: "true" | "false" | null; + readonly approvalType: "form" | "url" | null; readonly from: number | null; readonly to: number | null; } @@ -27,6 +28,7 @@ export const FILTER_KEYS: readonly FilterKey[] = [ { key: "status", hint: "completed,failed,running,waiting" }, { key: "trigger", hint: "mcp, http, cli, manual" }, { key: "interaction", hint: "true | false" }, + { key: "approval", hint: "form | url" }, { key: "after", hint: "1h · 30m · 7d · ISO date" }, { key: "before", hint: "1h · 7d · ISO date" }, ]; @@ -64,6 +66,7 @@ export const parseRunsFilter = (text: string): RunsFilterTokens => { const status: string[] = []; const trigger: string[] = []; let interaction: "true" | "false" | null = null; + let approvalType: "form" | "url" | null = null; let from: number | null = null; let to: number | null = null; @@ -82,6 +85,8 @@ export const parseRunsFilter = (text: string): RunsFilterTokens => { trigger.push(...value.split(",").filter(Boolean)); } else if (key === "interaction") { if (value === "true" || value === "false") interaction = value; + } else if (key === "approval" || key === "approvalType") { + if (value === "form" || value === "url") approvalType = value; } else if (key === "after") { const epoch = tokenToEpochMs(value); if (epoch !== null) from = epoch; @@ -92,5 +97,5 @@ export const parseRunsFilter = (text: string): RunsFilterTokens => { // Unknown keys are silently dropped so the input stays forgiving. } - return { status, trigger, interaction, from, to }; + return { status, trigger, interaction, approvalType, from, to }; }; diff --git a/packages/plugins/execution-history/src/react/filter-command.tsx b/packages/plugins/execution-history/src/react/filter-command.tsx index 9e3aadfc80..c6e3084b9c 100644 --- a/packages/plugins/execution-history/src/react/filter-command.tsx +++ b/packages/plugins/execution-history/src/react/filter-command.tsx @@ -53,9 +53,9 @@ const toggleFacet = (current: readonly string[], value: string): string[] => current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value]; /** Merge parsed tokens onto the current filters: status/trigger REPLACE (only - * when present and valid), interaction/from/to are set only when the token was - * actually typed — so applying a command never wipes a time range or - * interaction filter set elsewhere (the rail, the timeline drag). The palette + * when present and valid), interaction/approval/from/to are set only when the + * token was actually typed, so applying a command never wipes a time range or + * approval filter set elsewhere (the rail, the timeline drag). The palette * sets filters; clearing is done from the rail. Status tokens are normalized to * canonical RunStatus values; an all-invalid status token leaves status * untouched. */ @@ -68,6 +68,7 @@ const mergeTokens = (filters: RunsFilters, tokens: RunsFilterTokens): RunsFilter status: statuses.length > 0 ? statuses : filters.status, trigger: tokens.trigger.length > 0 ? tokens.trigger : filters.trigger, interaction: tokens.interaction !== null ? tokens.interaction : filters.interaction, + approvalType: tokens.approvalType !== null ? tokens.approvalType : filters.approvalType, from: tokens.from !== null ? tokens.from : filters.from, to: tokens.to !== null ? tokens.to : filters.to, }; @@ -163,7 +164,7 @@ export const RunsFilterCommand = forwardRef onOpenChange(true)} - placeholder="Filter runs — status:… trigger:… interaction:… after:…" + placeholder="Filter runs — status:… trigger:… approval:… after:…" className="font-mono text-xs" /> @@ -192,7 +193,7 @@ export const RunsFilterCommand = forwardRef onOpenChange(true)} - placeholder="Filter runs — status:… trigger:… interaction:… after:…" + placeholder="Filter runs — status:… trigger:… approval:… after:…" className="font-mono text-xs" /> diff --git a/packages/plugins/execution-history/src/react/filter-rail.tsx b/packages/plugins/execution-history/src/react/filter-rail.tsx index 93ab55ed33..3e8ad64d47 100644 --- a/packages/plugins/execution-history/src/react/filter-rail.tsx +++ b/packages/plugins/execution-history/src/react/filter-rail.tsx @@ -69,6 +69,7 @@ const isFiltersActive = (filters: RunsFilters): boolean => filters.trigger.length > 0 || filters.actor.length > 0 || filters.interaction !== null || + filters.approvalType !== null || filters.from !== null || filters.to !== null; @@ -329,8 +330,8 @@ export function RunsFilterRail({ filters, meta, onChange, onReset }: RunsFilterR ) : null} - {/* Interaction facet */} - + {/* Approval facet */} +
  • @@ -353,13 +355,44 @@ export function RunsFilterRail({ filters, meta, onChange, onReset }: RunsFilterR onChange({ ...filters, interaction: filters.interaction === "false" ? null : "false", + approvalType: null, }) } dotClass="bg-muted-foreground/40" - label="Without interaction" + label="No approval" count={meta?.interactionCounts.withoutInteraction} /> +
  • + + onChange({ + ...filters, + interaction: null, + approvalType: filters.approvalType === "form" ? null : "form", + }) + } + dotClass="bg-violet-500" + label="Form approval" + count={meta?.interactionCounts.formApproval} + /> +
  • +
  • + + onChange({ + ...filters, + interaction: null, + approvalType: filters.approvalType === "url" ? null : "url", + }) + } + dotClass="bg-sky-500" + label="URL approval" + count={meta?.interactionCounts.urlApproval} + /> +
diff --git a/packages/plugins/execution-history/src/react/run-row.tsx b/packages/plugins/execution-history/src/react/run-row.tsx index ccf7381651..8c3101dd0a 100644 --- a/packages/plugins/execution-history/src/react/run-row.tsx +++ b/packages/plugins/execution-history/src/react/run-row.tsx @@ -35,6 +35,12 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList const isLive = run.status === "running" || run.status === "waiting_for_interaction"; const logErrors = run.logErrorCount; const logWarns = run.logWarnCount; + const approvalLabels = [ + run.hadFormApproval ? "form" : null, + run.hadUrlApproval ? "url" : null, + ].filter((label): label is string => label !== null); + const approvalLabel = + approvalLabels.length > 0 ? approvalLabels.join(",") : run.hadInteraction ? "yes" : null; return ( ) : null} - {/* Interaction (centered, optional) */} + {/* Approval (centered, optional) */} {columns.interaction ? ( - {run.hadInteraction ? ( + {approvalLabel ? ( - yes + {approvalLabel} ) : ( — diff --git a/packages/plugins/execution-history/src/react/runs-list-state.test.ts b/packages/plugins/execution-history/src/react/runs-list-state.test.ts index 449530be86..c515a1bb66 100644 --- a/packages/plugins/execution-history/src/react/runs-list-state.test.ts +++ b/packages/plugins/execution-history/src/react/runs-list-state.test.ts @@ -24,6 +24,8 @@ const row = (id: string, startedAt: number): RunRow => ({ durationMs: 100, toolCallCount: 0, hadInteraction: false, + hadFormApproval: false, + hadUrlApproval: false, }); const run = (actions: readonly RunsListAction[], from: RunsListState = initialRunsListState) => diff --git a/packages/plugins/execution-history/src/react/use-runs-list.ts b/packages/plugins/execution-history/src/react/use-runs-list.ts index 93477c2874..3f710c35ec 100644 --- a/packages/plugins/execution-history/src/react/use-runs-list.ts +++ b/packages/plugins/execution-history/src/react/use-runs-list.ts @@ -23,6 +23,7 @@ export interface RunsFilters { readonly trigger: readonly string[]; readonly actor: readonly string[]; readonly interaction: "true" | "false" | null; + readonly approvalType: "form" | "url" | null; readonly from: number | null; readonly to: number | null; readonly sortField: RunsSortField; @@ -37,6 +38,7 @@ export const emptyRunsFilters: RunsFilters = { trigger: [], actor: [], interaction: null, + approvalType: null, from: null, to: null, sortField: "startedAt", @@ -51,6 +53,7 @@ export const buildRunsQuery = (filters: RunsFilters, cursor: string | undefined) ...(filters.trigger.length > 0 ? { trigger: filters.trigger.join(",") } : {}), ...(filters.actor.length > 0 ? { actor: filters.actor.join(",") } : {}), ...(filters.interaction != null ? { interaction: filters.interaction } : {}), + ...(filters.approvalType != null ? { approvalType: filters.approvalType } : {}), ...(filters.from != null ? { from: filters.from } : {}), ...(filters.to != null ? { to: filters.to } : {}), ...(cursor !== undefined ? { cursor } : {}), @@ -62,6 +65,7 @@ const filtersKey = (filters: RunsFilters): string => filters.trigger, filters.actor, filters.interaction, + filters.approvalType, filters.from, filters.to, filters.sortField, diff --git a/packages/plugins/execution-history/src/react/view.ts b/packages/plugins/execution-history/src/react/view.ts index c19aae9c47..734ffaff6e 100644 --- a/packages/plugins/execution-history/src/react/view.ts +++ b/packages/plugins/execution-history/src/react/view.ts @@ -29,6 +29,6 @@ export const RUN_COLUMN_LABELS: Record = { actor: "Actor", duration: "Duration", tools: "Tools", - interaction: "Interaction", + interaction: "Approval", log: "Log", }; diff --git a/packages/plugins/execution-history/src/sdk/collections.test.ts b/packages/plugins/execution-history/src/sdk/collections.test.ts index dd0f63ee4b..8f2ddbf63c 100644 --- a/packages/plugins/execution-history/src/sdk/collections.test.ts +++ b/packages/plugins/execution-history/src/sdk/collections.test.ts @@ -40,6 +40,8 @@ describe("RunRow legacy-document tolerance", () => { expect(decoded.actorId).toBeNull(); expect(decoded.actorLabel).toBeNull(); expect(decoded.actorKind).toBeNull(); + expect(decoded.hadFormApproval).toBe(false); + expect(decoded.hadUrlApproval).toBe(false); // The decoded type is `string | null` (always present) — readers treat it as // required; it just defaults to null for pre-actor runs. expect(decoded.executionId).toBe("exec_legacy"); @@ -55,6 +57,8 @@ describe("RunRow legacy-document tolerance", () => { expect(encoded.actorId ?? null).toBeNull(); expect(encoded.actorLabel ?? null).toBeNull(); expect(encoded.actorKind ?? null).toBeNull(); + expect(encoded.hadFormApproval ?? false).toBe(false); + expect(encoded.hadUrlApproval ?? false).toBe(false); }); it("round-trips a run that DOES carry an actor through decode AND encode", () => { diff --git a/packages/plugins/execution-history/src/sdk/collections.ts b/packages/plugins/execution-history/src/sdk/collections.ts index bffe84a468..636743b66b 100644 --- a/packages/plugins/execution-history/src/sdk/collections.ts +++ b/packages/plugins/execution-history/src/sdk/collections.ts @@ -85,11 +85,26 @@ export const RunRow = Schema.Struct({ durationMs: Schema.NullOr(Schema.Number), toolCallCount: Schema.Number, hadInteraction: Schema.Boolean, + hadFormApproval: Schema.optional(Schema.Boolean).pipe( + Schema.withDecodingDefaultType(Effect.succeed(false)), + ), + hadUrlApproval: Schema.optional(Schema.Boolean).pipe( + Schema.withDecodingDefaultType(Effect.succeed(false)), + ), }); export type RunRow = typeof RunRow.Type; export const runs = definePluginStorageCollection("runs", RunRow, { - indexes: ["status", "triggerKind", "actorId", "startedAt", "durationMs", "hadInteraction"], + indexes: [ + "status", + "triggerKind", + "actorId", + "startedAt", + "durationMs", + "hadInteraction", + "hadFormApproval", + "hadUrlApproval", + ], }); // Per-tool-call and per-interaction detail rows. No longer their own D1 diff --git a/packages/plugins/execution-history/src/sdk/store.test.ts b/packages/plugins/execution-history/src/sdk/store.test.ts index 95e28a397b..5819902c95 100644 --- a/packages/plugins/execution-history/src/sdk/store.test.ts +++ b/packages/plugins/execution-history/src/sdk/store.test.ts @@ -4,10 +4,14 @@ import { Effect } from "effect"; import { ExecutionFinished, ExecutionId, + ExecutionInteractionId, ExecutionStarted, ExecutionToolCallId, + FormElicitation, + InteractionStarted, Subject, Tenant, + ToolAddress, ToolCallFinished, ToolCallStarted, } from "@executor-js/sdk"; @@ -83,6 +87,8 @@ describe("execution-history store", () => { expect(run?.toolCallCount).toBe(1); expect(run?.durationMs).toBe(2000); expect(run?.hadInteraction).toBe(false); + expect(run?.hadFormApproval).toBe(false); + expect(run?.hadUrlApproval).toBe(false); // codePreview + trigger from ExecutionStarted survive the terminal re-write. expect(run?.codePreview).toBe("await tools.shell({ command: 'ls' })"); expect(run?.triggerKind).toBe("manual"); @@ -99,4 +105,70 @@ describe("execution-history store", () => { expect(detail?.interactions).toHaveLength(0); }), ); + + it.effect("indexes approval kind for list filtering and facets", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + backend: "sqlite", + plugins: [executionHistoryPlugin()] as const, + }); + + const executionId = ExecutionId.make("exec_form"); + const interactionId = ExecutionInteractionId.make("approval_1"); + const startedAt = new Date("2026-05-29T10:00:00.000Z"); + const approvalAt = new Date("2026-05-29T10:00:01.000Z"); + const completedAt = new Date("2026-05-29T10:00:02.000Z"); + + yield* executor.executionHistory.handleEvent( + new ExecutionStarted({ + executionId, + owner, + code: "await tools.policy.requireApproval({ reason: 'deploy' })", + trigger: { kind: "manual" }, + startedAt, + }), + ); + yield* executor.executionHistory.handleEvent( + new InteractionStarted({ + executionId, + interactionId, + owner, + context: { + address: ToolAddress.make("tools.policies.org.default.requireApproval"), + args: { reason: "deploy" }, + request: FormElicitation.make({ message: "Approve deploy?", requestedSchema: {} }), + }, + startedAt: approvalAt, + }), + ); + yield* executor.executionHistory.handleEvent( + new ExecutionFinished({ + executionId, + owner, + status: "completed", + result: { ok: true }, + logs: [], + completedAt, + }), + ); + + const listed = yield* executor.executionHistory.list({ + approvalType: "form", + limit: 50, + }); + expect(listed.runs.map((run) => run.executionId)).toEqual(["exec_form"]); + expect(listed.runs[0]?.hadInteraction).toBe(true); + expect(listed.runs[0]?.hadFormApproval).toBe(true); + expect(listed.runs[0]?.hadUrlApproval).toBe(false); + expect(listed.meta?.interactionCounts).toMatchObject({ + withInteraction: 1, + withoutInteraction: 0, + formApproval: 1, + urlApproval: 0, + }); + + const detail = yield* executor.executionHistory.get("exec_form"); + expect(detail?.interactions[0]?.kind).toBe("FormElicitation"); + }), + ); }); diff --git a/packages/plugins/execution-history/src/sdk/store.ts b/packages/plugins/execution-history/src/sdk/store.ts index 802a4fda38..12d5f90e73 100644 --- a/packages/plugins/execution-history/src/sdk/store.ts +++ b/packages/plugins/execution-history/src/sdk/store.ts @@ -191,6 +191,8 @@ interface RunBuffer { actorLabel: string | null; actorKind: string | null; hadInteraction: boolean; + hadFormApproval: boolean; + hadUrlApproval: boolean; toolCalls: Map; interactions: Map; } @@ -207,6 +209,7 @@ interface RunBuffer { // --------------------------------------------------------------------------- export type RunsSortField = "startedAt" | "durationMs"; +export type ApprovalTypeFilter = "form" | "url"; /** Opaque-to-the-client keyset cursor: the sort value + storage key of the last * row on a page. The HTTP layer encodes/decodes it as a string. */ @@ -221,6 +224,7 @@ export interface ExecutionHistoryListOptions { readonly actorFilter?: readonly string[]; readonly timeRange?: { readonly from?: number; readonly to?: number }; readonly hadInteraction?: boolean; + readonly approvalType?: ApprovalTypeFilter; /** Live-tail floor: only runs whose `startedAt` is strictly greater. */ readonly after?: number; readonly sortField?: RunsSortField; @@ -250,6 +254,8 @@ export interface RunActorCount { export interface RunInteractionCounts { readonly withInteraction: number; readonly withoutInteraction: number; + readonly formApproval: number; + readonly urlApproval: number; } export interface RunChartBucket { @@ -321,6 +327,8 @@ type RunsWhere = { actorId?: { in: readonly string[] }; startedAt?: { gte?: number; lte?: number; gt?: number }; hadInteraction?: { eq: boolean }; + hadFormApproval?: { eq: boolean }; + hadUrlApproval?: { eq: boolean }; }; /** Build the indexed-field `where` from list options. `omit` drops one facet's @@ -328,7 +336,7 @@ type RunsWhere = { * shows every option for the field you're filtering on). */ const buildRunsWhere = ( options: ExecutionHistoryListOptions, - omit?: "status" | "triggerKind" | "actorId" | "hadInteraction", + omit?: "status" | "triggerKind" | "actorId" | "hadInteraction" | "approvalType", ): RunsWhere => { const where: RunsWhere = {}; if (omit !== "status" && options.statusFilter && options.statusFilter.length > 0) { @@ -348,6 +356,10 @@ const buildRunsWhere = ( if (omit !== "hadInteraction" && options.hadInteraction != null) { where.hadInteraction = { eq: options.hadInteraction }; } + if (omit !== "approvalType") { + if (options.approvalType === "form") where.hadFormApproval = { eq: true }; + if (options.approvalType === "url") where.hadUrlApproval = { eq: true }; + } return where; }; @@ -423,6 +435,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt actorLabel, actorKind, hadInteraction: false, + hadFormApproval: false, + hadUrlApproval: false, toolCalls: new Map(), interactions: new Map(), }); @@ -443,6 +457,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt durationMs: null, toolCallCount: 0, hadInteraction: false, + hadFormApproval: false, + hadUrlApproval: false, }), // Eager code-only stub: the drawer shows code while the run is live, and // the code survives a restart that loses the in-memory buffer. @@ -508,6 +524,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt const kind = Predicate.isTagged(request, "UrlElicitation") ? "UrlElicitation" : "FormElicitation"; + const isUrlApproval = kind === "UrlElicitation"; + const isFormApproval = kind === "FormElicitation"; buffer.interactions.set(event.interactionId, { interactionId: event.interactionId, status: "pending", @@ -520,6 +538,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt completedAt: null, }); buffer.hadInteraction = true; + buffer.hadFormApproval = buffer.hadFormApproval || isFormApproval; + buffer.hadUrlApproval = buffer.hadUrlApproval || isUrlApproval; return putRun(buffer.owner, { executionId: event.executionId, status: "waiting_for_interaction", @@ -535,6 +555,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt durationMs: null, toolCallCount: buffer.toolCalls.size, hadInteraction: true, + hadFormApproval: buffer.hadFormApproval, + hadUrlApproval: buffer.hadUrlApproval, }); }; @@ -580,6 +602,14 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt const codePreview = buffer ? codePreviewOf(buffer.code) : (existing?.data.codePreview ?? ""); const hadInteraction = buffer?.hadInteraction ?? (existing?.data.hadInteraction || interactionEntries.length > 0); + const hadFormApproval = + buffer?.hadFormApproval ?? + existing?.data.hadFormApproval ?? + interactionEntries.some((entry) => entry.kind === "FormElicitation"); + const hadUrlApproval = + buffer?.hadUrlApproval ?? + existing?.data.hadUrlApproval ?? + interactionEntries.some((entry) => entry.kind === "UrlElicitation"); yield* putRun(owner, { executionId: event.executionId, @@ -596,6 +626,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt durationMs: completedAt - startedAt, toolCallCount: toolCallEntries.length, hadInteraction, + hadFormApproval, + hadUrlApproval, }); // Write the full detail object. Normal path: assembled from the buffer. @@ -684,6 +716,8 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt triggerGroups, actorGroups, interactionGroups, + formApprovalCount, + urlApprovalCount, stats, startedAtStats, ] = yield* Effect.all( @@ -707,6 +741,12 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt where: buildRunsWhere(options, "hadInteraction"), valueType: "boolean", }), + runsC.aggregate.count({ + where: { ...buildRunsWhere(options, "approvalType"), hadFormApproval: { eq: true } }, + }), + runsC.aggregate.count({ + where: { ...buildRunsWhere(options, "approvalType"), hadUrlApproval: { eq: true } }, + }), runsC.aggregate.stats({ field: "durationMs", where, @@ -827,7 +867,12 @@ export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistorySt statusCounts, triggerCounts, actorCounts, - interactionCounts: { withInteraction, withoutInteraction }, + interactionCounts: { + withInteraction, + withoutInteraction, + formApproval: formApprovalCount, + urlApproval: urlApprovalCount, + }, chartBucketMs: bucketMs, chartData, durationStats: {