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: {