From 65139d6726740bd803833c0154e4fbb6db164a1a Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 19:21:51 +0530 Subject: [PATCH 1/6] feat(sdk): add ExecutionStore backed by DBAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds execution history persistence to the core SDK surface, wiring three new tables (`execution`, `execution_interaction`, `execution_tool_call`) into `coreSchema` and exposing an `ExecutionStore` service on `executor.executions`. Changes: - `core-schema.ts`: three new tables with `scope_id` / `execution_id` / `tool_path` / `trigger_kind` / `created_at` indexes for the runs UI's faceting + timeline queries. - `ids.ts`: branded `ExecutionId`, `ExecutionInteractionId`, `ExecutionToolCallId`. - `executions.ts`: `Execution`, `ExecutionInteraction`, `ExecutionToolCall` Schema classes, status enums, create/update/filter/sort/meta input types, and the `ExecutionStore` Context.Tag. - `execution-store.ts`: `makeExecutionStore(core)` — an adapter-backed `ExecutionStoreService` implementation. Wraps `typedAdapter` for CRUD, handles cursor-based pagination, filter predicates (status, trigger, tool-path glob, time range, code substring, hadElicitation), and builds list meta with facets + chart buckets. - `cursor.ts`: base64url `{ createdAt, id }` pagination cursors. - `executor.ts`: constructs the store once per executor, exposes via `executor.executions`. - `executions.test.ts`: round-trip + lifecycle coverage against the in-memory adapter (no migrations needed). Follow-up work (future PRs in the stack): - wire the engine to record runs + tool calls through this store, - add `/executions` API endpoints, and - land the runs UI. --- packages/core/sdk/src/core-schema.ts | 84 ++++ packages/core/sdk/src/cursor.ts | 44 ++ packages/core/sdk/src/execution-store.ts | 556 +++++++++++++++++++++++ packages/core/sdk/src/executions.test.ts | 260 +++++++++++ packages/core/sdk/src/executions.ts | 292 ++++++++++++ packages/core/sdk/src/executor.ts | 10 + packages/core/sdk/src/ids.ts | 9 + packages/core/sdk/src/index.ts | 49 +- 8 files changed, 1303 insertions(+), 1 deletion(-) create mode 100644 packages/core/sdk/src/cursor.ts create mode 100644 packages/core/sdk/src/execution-store.ts create mode 100644 packages/core/sdk/src/executions.test.ts create mode 100644 packages/core/sdk/src/executions.ts diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 285eec8e5..774c1f8d3 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -148,6 +148,75 @@ export const coreSchema = { updated_at: { type: "date", required: true }, }, }, + // Execution history — one row per `engine.execute()` / + // `engine.executeWithPause()`. Captures the submitted code, final + // status/result, and trigger metadata. Tool calls and interactions + // link back via `execution_id`. + execution: { + fields: { + id: { type: "string", required: true }, + scope_id: { type: "string", required: true, index: true }, + status: { type: "string", required: true, index: true }, + code: { type: "string", required: true }, + result_json: { type: "string", required: false }, + error_text: { type: "string", required: false }, + logs_json: { type: "string", required: false }, + /** Epoch ms — the point the engine accepted the code. */ + started_at: { type: "number", required: false }, + /** Epoch ms — the point the engine reached a terminal status. */ + completed_at: { type: "number", required: false }, + /** Free-form trigger kind attributed by the host — `"cli"`, + * `"http"`, `"mcp"`, etc. Null when the host didn't attribute + * one. Indexed so filter facets scan fast. */ + trigger_kind: { type: "string", required: false, index: true }, + /** Opaque host-owned JSON for per-trigger details. */ + trigger_meta_json: { type: "string", required: false }, + tool_call_count: { type: "number", required: true, defaultValue: 0 }, + created_at: { type: "date", required: true, index: true }, + updated_at: { type: "date", required: true }, + }, + }, + // Per-execution interaction rows — elicitation requests and their + // resolutions. A pending row is the hook the runs UI uses to render + // the "waiting for input" state; once resolved, `response_json` + // captures the user's answer for replay / auditing. + execution_interaction: { + fields: { + id: { type: "string", required: true }, + execution_id: { type: "string", required: true, index: true }, + status: { type: "string", required: true, index: true }, + kind: { type: "string", required: true }, + purpose: { type: "string", required: false }, + payload_json: { type: "string", required: false }, + response_json: { type: "string", required: false }, + /** Stores sensitive per-response data (e.g. raw form values) that + * should not be replayed in the public interaction log. */ + response_private_json: { type: "string", required: false }, + created_at: { type: "date", required: true }, + updated_at: { type: "date", required: true }, + }, + }, + // Per-execution tool-call rows — one per `executor.tools.invoke` that + // ran inside the sandboxed execution. Used to build the tool-call + // timeline shown in the runs UI. + execution_tool_call: { + fields: { + id: { type: "string", required: true }, + execution_id: { type: "string", required: true, index: true }, + status: { type: "string", required: true }, + /** Dotted tool path (e.g. `github.issues.create`). Indexed so the + * facets query in the runs UI resolves without a table scan. */ + tool_path: { type: "string", required: true, index: true }, + /** First path segment, pre-computed for cheap faceting. */ + namespace: { type: "string", required: false, index: true }, + args_json: { type: "string", required: false }, + result_json: { type: "string", required: false }, + error_text: { type: "string", required: false }, + started_at: { type: "number", required: true }, + completed_at: { type: "number", required: false }, + duration_ms: { type: "number", required: false }, + }, + }, } as const satisfies DBSchema; export type CoreSchema = typeof coreSchema; @@ -176,6 +245,21 @@ export type ConnectionRow = InferDBFieldsOutput< > & Record; +export type ExecutionRow = InferDBFieldsOutput< + CoreSchema["execution"]["fields"] +> & + Record; + +export type ExecutionInteractionRow = InferDBFieldsOutput< + CoreSchema["execution_interaction"]["fields"] +> & + Record; + +export type ExecutionToolCallRow = InferDBFieldsOutput< + CoreSchema["execution_tool_call"]["fields"] +> & + Record; + // --------------------------------------------------------------------------- // Tool annotations — default-policy metadata the executor consults // before invocation. Returned by `plugin.resolveAnnotations` (dynamic diff --git a/packages/core/sdk/src/cursor.ts b/packages/core/sdk/src/cursor.ts new file mode 100644 index 000000000..ffa367ee5 --- /dev/null +++ b/packages/core/sdk/src/cursor.ts @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------- +// Opaque cursor helpers for ExecutionStore.list pagination. +// +// Cursors encode `{ createdAt, id }` — the tuple adapter backends need to +// resume a scan on `ORDER BY created_at DESC, id DESC`. Encoded as +// url-safe base64 so callers can pass them through query params without +// thinking about escaping. +// --------------------------------------------------------------------------- + +export interface CursorPayload { + readonly createdAt: number; + readonly id: string; +} + +const toBase64Url = (value: string): string => { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +const fromBase64Url = (value: string): string => { + const pad = value.length % 4 === 0 ? 0 : 4 - (value.length % 4); + const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad); + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +}; + +export const encodeCursor = (payload: CursorPayload): string => + toBase64Url(JSON.stringify(payload)); + +export const decodeCursor = (raw: string): CursorPayload | null => { + try { + const parsed = JSON.parse(fromBase64Url(raw)) as Record; + if (typeof parsed.createdAt !== "number" || typeof parsed.id !== "string") { + return null; + } + return { createdAt: parsed.createdAt, id: parsed.id }; + } catch { + return null; + } +}; diff --git a/packages/core/sdk/src/execution-store.ts b/packages/core/sdk/src/execution-store.ts new file mode 100644 index 000000000..f2725ba98 --- /dev/null +++ b/packages/core/sdk/src/execution-store.ts @@ -0,0 +1,556 @@ +// --------------------------------------------------------------------------- +// makeExecutionStore — an ExecutionStoreService implementation backed by +// the generic `typedAdapter` surface. Used by createExecutor +// to expose `executor.executions`. +// +// Per-row JSON columns (`result_json`, `logs_json`, `payload_json`, …) +// are stored as opaque strings — the SDK does not inspect their shape. +// Callers pre-stringify when writing and parse when reading. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; +import type { StorageFailure, TypedAdapter } from "@executor/storage-core"; + +import type { CoreSchema } from "./core-schema"; + +// Row shape accepted by the row-to-class mappers. We can't rely on +// `RowOutput` narrowing because `TypedAdapter.update` +// takes a `Partial>` payload — Partial strips every +// required field, so TypeScript falls back to a union of every row +// type in the schema. The mappers read fields by name with explicit +// `row.xxx as string` casts anyway, so typing the input as the base +// record that every `RowOutput` extends is just as safe. +type AdapterRow = Record; +import { decodeCursor, encodeCursor } from "./cursor"; +import { + Execution, + ExecutionInteraction, + ExecutionToolCall, + type ExecutionStoreService, + type ExecutionListOptions, + type ExecutionListResult, + type ExecutionListItem, + type ExecutionListMeta, + type ExecutionChartBucket, + type ExecutionStatus, + type ExecutionStatusCount, + type ExecutionTriggerCount, + type ExecutionToolFacet, + EXECUTION_STATUS_KEYS, +} from "./executions"; +import { + ExecutionId, + ExecutionInteractionId, + ExecutionToolCallId, + ScopeId, +} from "./ids"; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +const toNumberOrNull = (value: unknown): number | null => + value == null ? null : Number(value); + +const toStringOrNull = (value: unknown): string | null => + value == null ? null : (value as string); + +const toDate = (value: unknown): Date => + value instanceof Date ? value : new Date(value as string | number); + +const rowToExecution = (row: AdapterRow): Execution => + new Execution({ + id: ExecutionId.make(row.id as string), + scopeId: ScopeId.make(row.scope_id as string), + status: row.status as ExecutionStatus, + code: row.code as string, + resultJson: toStringOrNull(row.result_json), + errorText: toStringOrNull(row.error_text), + logsJson: toStringOrNull(row.logs_json), + startedAt: toNumberOrNull(row.started_at), + completedAt: toNumberOrNull(row.completed_at), + triggerKind: toStringOrNull(row.trigger_kind), + triggerMetaJson: toStringOrNull(row.trigger_meta_json), + toolCallCount: Number(row.tool_call_count ?? 0), + createdAt: toDate(row.created_at), + updatedAt: toDate(row.updated_at), + }); + +const rowToInteraction = (row: AdapterRow): ExecutionInteraction => + new ExecutionInteraction({ + id: ExecutionInteractionId.make(row.id as string), + executionId: ExecutionId.make(row.execution_id as string), + status: row.status as ExecutionInteraction["status"], + kind: row.kind as string, + purpose: toStringOrNull(row.purpose), + payloadJson: toStringOrNull(row.payload_json), + responseJson: toStringOrNull(row.response_json), + responsePrivateJson: toStringOrNull(row.response_private_json), + createdAt: toDate(row.created_at), + updatedAt: toDate(row.updated_at), + }); + +const rowToToolCall = (row: AdapterRow): ExecutionToolCall => + new ExecutionToolCall({ + id: ExecutionToolCallId.make(row.id as string), + executionId: ExecutionId.make(row.execution_id as string), + status: row.status as ExecutionToolCall["status"], + toolPath: row.tool_path as string, + namespace: toStringOrNull(row.namespace), + argsJson: toStringOrNull(row.args_json), + resultJson: toStringOrNull(row.result_json), + errorText: toStringOrNull(row.error_text), + startedAt: Number(row.started_at), + completedAt: toNumberOrNull(row.completed_at), + durationMs: toNumberOrNull(row.duration_ms), + }); + +const pickChartBucketMs = (windowMs: number): number => { + if (windowMs <= 15 * 60_000) return 30_000; // <=15m → 30s + if (windowMs <= 60 * 60_000) return 120_000; // <=1h → 2m + if (windowMs <= 24 * 60 * 60_000) return 30 * 60_000; // <=24h → 30m + if (windowMs <= 7 * 24 * 60 * 60_000) return 3 * 60 * 60_000; // <=7d → 3h + return 24 * 60 * 60_000; // else → 1d +}; + +const matchesToolGlob = (toolPath: string, pattern: string): boolean => { + if (pattern === toolPath) return true; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); + return toolPath === prefix || toolPath.startsWith(`${prefix}.`); + } + return false; +}; + +export interface MakeExecutionStoreOptions { + readonly core: TypedAdapter; + readonly now?: () => Date; +} + +export const makeExecutionStore = ({ + core, + now = () => new Date(), +}: MakeExecutionStoreOptions): ExecutionStoreService => { + const create: ExecutionStoreService["create"] = (input) => + Effect.gen(function* () { + const timestamp = now(); + const row = yield* core.create({ + model: "execution", + forceAllowId: true, + data: { + id: input.id, + scope_id: input.scopeId, + status: input.status, + code: input.code, + result_json: null, + error_text: null, + logs_json: null, + started_at: input.startedAt ?? timestamp.getTime(), + completed_at: null, + trigger_kind: input.triggerKind ?? null, + trigger_meta_json: input.triggerMetaJson ?? null, + tool_call_count: 0, + created_at: timestamp, + updated_at: timestamp, + }, + }); + return rowToExecution(row); + }); + + const update: ExecutionStoreService["update"] = (id, patch) => + Effect.gen(function* () { + const row = yield* core.update({ + model: "execution", + where: [{ field: "id", value: id as string }], + update: { + updated_at: now(), + ...(patch.status !== undefined && { status: patch.status }), + ...(patch.resultJson !== undefined && { result_json: patch.resultJson }), + ...(patch.errorText !== undefined && { error_text: patch.errorText }), + ...(patch.logsJson !== undefined && { logs_json: patch.logsJson }), + ...(patch.completedAt !== undefined && { completed_at: patch.completedAt }), + ...(patch.toolCallCount !== undefined && { + tool_call_count: patch.toolCallCount, + }), + }, + }); + if (!row) return yield* Effect.die(`Execution ${id} vanished during update`); + return rowToExecution(row); + }); + + const get: ExecutionStoreService["get"] = (id) => + Effect.gen(function* () { + const rows = yield* core.findMany({ + model: "execution", + where: [{ field: "id", value: id as string }], + limit: 1, + }); + const execution = rows[0]; + if (!execution) return null; + const interactions = yield* core.findMany({ + model: "execution_interaction", + where: [ + { field: "execution_id", value: id as string }, + { field: "status", value: "pending" }, + ], + sortBy: { field: "created_at", direction: "desc" }, + limit: 1, + }); + const pendingInteraction = interactions[0] + ? rowToInteraction(interactions[0]) + : null; + return { + execution: rowToExecution(execution), + pendingInteraction, + }; + }); + + const recordInteraction: ExecutionStoreService["recordInteraction"] = (input) => + Effect.gen(function* () { + const timestamp = now(); + const row = yield* core.create({ + model: "execution_interaction", + forceAllowId: true, + data: { + id: input.id, + execution_id: input.executionId, + status: input.status, + kind: input.kind, + purpose: input.purpose ?? null, + payload_json: input.payloadJson ?? null, + response_json: null, + response_private_json: null, + created_at: timestamp, + updated_at: timestamp, + }, + }); + return rowToInteraction(row); + }); + + const resolveInteraction: ExecutionStoreService["resolveInteraction"] = (id, patch) => + Effect.gen(function* () { + const row = yield* core.update({ + model: "execution_interaction", + where: [{ field: "id", value: id as string }], + update: { + updated_at: now(), + ...(patch.status !== undefined && { status: patch.status }), + ...(patch.responseJson !== undefined && { response_json: patch.responseJson }), + ...(patch.responsePrivateJson !== undefined && { + response_private_json: patch.responsePrivateJson, + }), + }, + }); + if (!row) + return yield* Effect.die(`Interaction ${id} vanished during update`); + return rowToInteraction(row); + }); + + const recordToolCall: ExecutionStoreService["recordToolCall"] = (input) => + Effect.gen(function* () { + const row = yield* core.create({ + model: "execution_tool_call", + forceAllowId: true, + data: { + id: input.id, + execution_id: input.executionId, + status: "running", + tool_path: input.toolPath, + namespace: input.namespace ?? input.toolPath.split(".")[0] ?? null, + args_json: input.argsJson ?? null, + result_json: null, + error_text: null, + started_at: input.startedAt, + completed_at: null, + duration_ms: null, + }, + }); + return rowToToolCall(row); + }); + + const finishToolCall: ExecutionStoreService["finishToolCall"] = (id, patch) => + Effect.gen(function* () { + const row = yield* core.update({ + model: "execution_tool_call", + where: [{ field: "id", value: id as string }], + update: { + status: patch.status, + result_json: patch.resultJson ?? null, + error_text: patch.errorText ?? null, + completed_at: patch.completedAt, + duration_ms: patch.durationMs, + }, + }); + if (!row) return yield* Effect.die(`Tool call ${id} vanished during finish`); + return rowToToolCall(row); + }); + + const listToolCalls: ExecutionStoreService["listToolCalls"] = (executionId) => + Effect.gen(function* () { + const rows = yield* core.findMany({ + model: "execution_tool_call", + where: [{ field: "execution_id", value: executionId as string }], + sortBy: { field: "started_at", direction: "asc" }, + }); + return rows.map(rowToToolCall); + }); + + const sweep: ExecutionStoreService["sweep"] = (olderThanMs) => + Effect.gen(function* () { + const cutoff = new Date(now().getTime() - olderThanMs); + // Adapter deleteMany returns void in the generic contract — we do + // a pre-count scan so the caller gets a useful number back. + const doomed = yield* core.findMany({ + model: "execution", + where: [{ field: "created_at", operator: "lt", value: cutoff }], + limit: 10_000, + }); + if (doomed.length === 0) return 0; + yield* core.deleteMany({ + model: "execution", + where: [{ field: "created_at", operator: "lt", value: cutoff }], + }); + return doomed.length; + }); + + const list: ExecutionStoreService["list"] = (scopeId, rawOptions) => + Effect.gen(function* () { + const options: ExecutionListOptions = rawOptions ?? {}; + const limit = Math.max(1, Math.min(options.limit ?? DEFAULT_LIMIT, MAX_LIMIT)); + const sort = options.sort ?? { field: "createdAt", direction: "desc" as const }; + + // Pull candidate rows for this scope. We apply filters in-memory — + // the DBAdapter contract's Where clauses don't cover compound + // CSV-of-values or glob patterns, so the store does the final + // narrowing after fetching. + const rows = yield* core.findMany({ + model: "execution", + where: [{ field: "scope_id", value: scopeId as string }], + sortBy: { + field: sort.field === "durationMs" ? "completed_at" : "created_at", + direction: sort.direction, + }, + }); + + // Pre-compute tool-call aggregations that filters and meta both need. + const toolCallRows = yield* core.findMany({ + model: "execution_tool_call", + }); + + const toolCallsByExecution = new Map(); + for (const tc of toolCallRows) { + const list = toolCallsByExecution.get(tc.execution_id as string) ?? []; + list.push(tc); + toolCallsByExecution.set(tc.execution_id as string, list); + } + + // Pre-compute interaction presence per execution (for the + // hadElicitation filter + meta interactionCounts). + const interactionRows = yield* core.findMany({ + model: "execution_interaction", + }); + const executionsWithInteractions = new Set( + interactionRows.map((r) => r.execution_id as string), + ); + + const appliesStatusFilter = (row: AdapterRow): boolean => + !options.statusFilter || options.statusFilter.length === 0 + ? true + : options.statusFilter.includes(row.status as ExecutionStatus); + + const appliesTriggerFilter = (row: AdapterRow): boolean => { + if (!options.triggerFilter || options.triggerFilter.length === 0) return true; + const kind = (row.trigger_kind as string | null | undefined) ?? null; + return options.triggerFilter.some((want) => + want === "unknown" ? kind === null : want === kind, + ); + }; + + const appliesToolFilter = (row: AdapterRow): boolean => { + if (!options.toolPathFilter || options.toolPathFilter.length === 0) return true; + const calls = toolCallsByExecution.get(row.id as string) ?? []; + return options.toolPathFilter.some((pattern) => + calls.some((c) => matchesToolGlob(c.tool_path as string, pattern)), + ); + }; + + const appliesTimeFilter = (row: AdapterRow): boolean => { + const createdAt = toDate(row.created_at).getTime(); + if (options.timeRange?.from !== undefined && createdAt < options.timeRange.from) { + return false; + } + if (options.timeRange?.to !== undefined && createdAt > options.timeRange.to) { + return false; + } + if (options.after !== undefined) { + const afterMs = Number(options.after); + if (!Number.isNaN(afterMs) && createdAt <= afterMs) return false; + } + return true; + }; + + const appliesCodeQuery = (row: AdapterRow): boolean => + !options.codeQuery + ? true + : (row.code as string).toLowerCase().includes(options.codeQuery.toLowerCase()); + + const appliesElicitationFilter = (row: AdapterRow): boolean => { + if (options.hadElicitation === undefined) return true; + const has = executionsWithInteractions.has(row.id as string); + return options.hadElicitation ? has : !has; + }; + + const filtered = rows.filter( + (row) => + appliesStatusFilter(row) && + appliesTriggerFilter(row) && + appliesToolFilter(row) && + appliesTimeFilter(row) && + appliesCodeQuery(row) && + appliesElicitationFilter(row), + ); + + // Cursor applies after filtering so it tracks the filtered scan. + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + const afterCursor = cursor + ? filtered.filter((row) => { + const createdAt = toDate(row.created_at).getTime(); + if (createdAt < cursor.createdAt) return true; + if (createdAt > cursor.createdAt) return false; + return (row.id as string) < cursor.id; + }) + : filtered; + + const page = afterCursor.slice(0, limit); + const nextCursor = + afterCursor.length > limit && sort.field === "createdAt" + ? encodeCursor({ + createdAt: toDate(page[page.length - 1]!.created_at).getTime(), + id: page[page.length - 1]!.id as string, + }) + : undefined; + + const pageIds = page.map((r) => r.id as string); + const pendingByExecution = new Map(); + for (const interaction of interactionRows) { + if (interaction.status !== "pending") continue; + if (!pageIds.includes(interaction.execution_id as string)) continue; + const existing = pendingByExecution.get(interaction.execution_id as string); + if (!existing || toDate(interaction.created_at).getTime() > + toDate(existing.created_at).getTime()) { + pendingByExecution.set(interaction.execution_id as string, interaction); + } + } + + const executions: readonly ExecutionListItem[] = page.map((row) => ({ + execution: rowToExecution(row), + pendingInteraction: pendingByExecution.has(row.id as string) + ? rowToInteraction(pendingByExecution.get(row.id as string)!) + : null, + })); + + const meta: ExecutionListMeta | undefined = options.includeMeta + ? buildMeta(rows, filtered, toolCallsByExecution, executionsWithInteractions) + : undefined; + + return { + executions, + ...(nextCursor ? { nextCursor } : {}), + ...(meta ? { meta } : {}), + } satisfies ExecutionListResult; + }); + + const buildMeta = ( + all: readonly AdapterRow[], + filtered: readonly AdapterRow[], + toolCallsByExecution: Map, + executionsWithInteractions: Set, + ): ExecutionListMeta => { + const statusCounts: ExecutionStatusCount[] = EXECUTION_STATUS_KEYS.map((status) => ({ + status, + count: filtered.filter((r) => r.status === status).length, + })); + + const triggerMap = new Map(); + for (const row of filtered) { + const kind = (row.trigger_kind as string | null | undefined) ?? null; + triggerMap.set(kind, (triggerMap.get(kind) ?? 0) + 1); + } + const triggerCounts: ExecutionTriggerCount[] = Array.from(triggerMap.entries()).map( + ([triggerKind, count]) => ({ triggerKind, count }), + ); + + const toolCountMap = new Map(); + for (const row of filtered) { + for (const tc of toolCallsByExecution.get(row.id as string) ?? []) { + const path = tc.tool_path as string; + toolCountMap.set(path, (toolCountMap.get(path) ?? 0) + 1); + } + } + const toolFacets: ExecutionToolFacet[] = Array.from(toolCountMap.entries()) + .map(([toolPath, count]) => ({ toolPath, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + const withElicitation = filtered.filter((r) => + executionsWithInteractions.has(r.id as string), + ).length; + + const times = filtered.map((r) => toDate(r.created_at).getTime()); + const minTs = times.length > 0 ? Math.min(...times) : now().getTime(); + const maxTs = times.length > 0 ? Math.max(...times) : now().getTime(); + const windowMs = Math.max(1, maxTs - minTs); + const chartBucketMs = pickChartBucketMs(windowMs); + + const bucketMap = new Map>(); + for (const row of filtered) { + const bucketStart = + Math.floor(toDate(row.created_at).getTime() / chartBucketMs) * chartBucketMs; + const counts = bucketMap.get(bucketStart) ?? { + pending: 0, + running: 0, + waiting_for_interaction: 0, + completed: 0, + failed: 0, + cancelled: 0, + }; + counts[row.status as ExecutionStatus] += 1; + bucketMap.set(bucketStart, counts); + } + const chartData: ExecutionChartBucket[] = Array.from(bucketMap.entries()) + .map(([bucketStart, counts]) => ({ bucketStart, counts })) + .sort((a, b) => a.bucketStart - b.bucketStart); + + return { + totalRowCount: all.length, + filterRowCount: filtered.length, + statusCounts, + triggerCounts, + toolFacets, + interactionCounts: { + withElicitation, + withoutElicitation: filtered.length - withElicitation, + }, + chartBucketMs, + chartData, + }; + }; + + return { + create, + update, + get, + list, + recordInteraction, + resolveInteraction, + recordToolCall, + finishToolCall, + listToolCalls, + sweep, + } satisfies ExecutionStoreService; +}; + +// Re-export the Tag symbol here so callers can `import { ExecutionStore } +// from "@executor/sdk"` and get both the Tag and a layer factory from +// one module entry. +export { ExecutionStore } from "./executions"; +export type { StorageFailure }; diff --git a/packages/core/sdk/src/executions.test.ts b/packages/core/sdk/src/executions.test.ts new file mode 100644 index 000000000..06a289da4 --- /dev/null +++ b/packages/core/sdk/src/executions.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; + +import { collectSchemas, createExecutor } from "./executor"; +import { + EXECUTION_STATUS_KEYS, + type ExecutionStatus, +} from "./executions"; +import { + ExecutionId, + ExecutionInteractionId, + ExecutionToolCallId, + ScopeId, +} from "./ids"; +import { Scope } from "./scope"; +import { makeInMemoryBlobStore } from "./blob"; + +// --------------------------------------------------------------------------- +// Shared fixture. Every test builds a scoped executor backed by the +// in-memory adapter — zero persistence, zero migration dance. +// --------------------------------------------------------------------------- + +const SCOPE = ScopeId.make("scope-test"); + +const makeExecutor = () => + Effect.gen(function* () { + const schema = collectSchemas([]); + const adapter = makeMemoryAdapter({ schema }); + const scope = new Scope({ id: SCOPE, name: "test", createdAt: new Date() }); + const executor = yield* createExecutor({ + scopes: [scope], + adapter, + blobs: makeInMemoryBlobStore(), + }); + return executor; + }); + +describe("ExecutionStore (DBAdapter-backed)", () => { + it.effect("create → get round-trip preserves fields", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const id = ExecutionId.make("exec-1"); + + yield* executor.executions.create({ + id, + scopeId: SCOPE, + status: "running", + code: "const x = 1", + triggerKind: "cli", + }); + + const detail = yield* executor.executions.get(id); + expect(detail).not.toBeNull(); + expect(detail!.execution.id).toBe(id); + expect(detail!.execution.status).toBe("running"); + expect(detail!.execution.code).toBe("const x = 1"); + expect(detail!.execution.triggerKind).toBe("cli"); + expect(detail!.execution.toolCallCount).toBe(0); + expect(detail!.pendingInteraction).toBeNull(); + }), + ); + + it.effect("update patches status, result, and completion time", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const id = ExecutionId.make("exec-2"); + + yield* executor.executions.create({ + id, + scopeId: SCOPE, + status: "running", + code: "2 + 2", + }); + + yield* executor.executions.update(id, { + status: "completed", + resultJson: JSON.stringify({ value: 4 }), + completedAt: 1_700_000_000_000, + toolCallCount: 1, + }); + + const detail = yield* executor.executions.get(id); + expect(detail!.execution.status).toBe("completed"); + expect(detail!.execution.resultJson).toBe('{"value":4}'); + expect(detail!.execution.completedAt).toBe(1_700_000_000_000); + expect(detail!.execution.toolCallCount).toBe(1); + }), + ); + + it.effect("tool-call recording + finish updates status + duration", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const executionId = ExecutionId.make("exec-3"); + const toolCallId = ExecutionToolCallId.make("tc-1"); + + yield* executor.executions.create({ + id: executionId, + scopeId: SCOPE, + status: "running", + code: "await tools.a()", + }); + + yield* executor.executions.recordToolCall({ + id: toolCallId, + executionId, + toolPath: "ns.doThing", + startedAt: 1_700_000_000_000, + }); + + yield* executor.executions.finishToolCall(toolCallId, { + status: "completed", + resultJson: '{"ok":true}', + completedAt: 1_700_000_000_250, + durationMs: 250, + }); + + const calls = yield* executor.executions.listToolCalls(executionId); + expect(calls).toHaveLength(1); + expect(calls[0]!.status).toBe("completed"); + expect(calls[0]!.toolPath).toBe("ns.doThing"); + expect(calls[0]!.namespace).toBe("ns"); + expect(calls[0]!.durationMs).toBe(250); + expect(calls[0]!.resultJson).toBe('{"ok":true}'); + }), + ); + + it.effect("interaction lifecycle: record pending → resolve", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const executionId = ExecutionId.make("exec-4"); + const interactionId = ExecutionInteractionId.make("int-1"); + + yield* executor.executions.create({ + id: executionId, + scopeId: SCOPE, + status: "waiting_for_interaction", + code: "await elicit(...)", + }); + + yield* executor.executions.recordInteraction({ + id: interactionId, + executionId, + status: "pending", + kind: "FormElicitation", + payloadJson: '{"message":"ok?"}', + }); + + // get() should surface the pending interaction alongside the row. + const beforeResolve = yield* executor.executions.get(executionId); + expect(beforeResolve!.pendingInteraction).not.toBeNull(); + expect(beforeResolve!.pendingInteraction!.id).toBe(interactionId); + + yield* executor.executions.resolveInteraction(interactionId, { + status: "resolved", + responseJson: '{"action":"accept"}', + }); + + const afterResolve = yield* executor.executions.get(executionId); + expect(afterResolve!.pendingInteraction).toBeNull(); + }), + ); + + it.effect("list applies status + trigger filters", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.executions.create({ + id: ExecutionId.make("e-a"), + scopeId: SCOPE, + status: "completed", + code: "a", + triggerKind: "cli", + }); + yield* executor.executions.create({ + id: ExecutionId.make("e-b"), + scopeId: SCOPE, + status: "failed", + code: "b", + triggerKind: "http", + }); + yield* executor.executions.create({ + id: ExecutionId.make("e-c"), + scopeId: SCOPE, + status: "completed", + code: "c", + triggerKind: "mcp", + }); + + const completedOnly = yield* executor.executions.list(SCOPE, { + statusFilter: ["completed"], + }); + expect(completedOnly.executions).toHaveLength(2); + + const httpOnly = yield* executor.executions.list(SCOPE, { + triggerFilter: ["http"], + }); + expect(httpOnly.executions).toHaveLength(1); + expect(httpOnly.executions[0]!.execution.id).toBe(ExecutionId.make("e-b")); + }), + ); + + it.effect("list meta reports status + trigger counts", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + yield* executor.executions.create({ + id: ExecutionId.make("m-1"), + scopeId: SCOPE, + status: "completed", + code: "x", + triggerKind: "cli", + }); + yield* executor.executions.create({ + id: ExecutionId.make("m-2"), + scopeId: SCOPE, + status: "completed", + code: "y", + triggerKind: "cli", + }); + yield* executor.executions.create({ + id: ExecutionId.make("m-3"), + scopeId: SCOPE, + status: "failed", + code: "z", + triggerKind: "mcp", + }); + + const res = yield* executor.executions.list(SCOPE, { includeMeta: true }); + expect(res.meta).toBeDefined(); + expect(res.meta!.totalRowCount).toBe(3); + + const completedCount = res.meta!.statusCounts.find( + (c) => c.status === ("completed" as ExecutionStatus), + ); + expect(completedCount!.count).toBe(2); + expect(res.meta!.triggerCounts.find((t) => t.triggerKind === "cli")!.count).toBe( + 2, + ); + expect(res.meta!.triggerCounts.find((t) => t.triggerKind === "mcp")!.count).toBe( + 1, + ); + }), + ); + + it.effect("EXECUTION_STATUS_KEYS covers every status literal", () => + Effect.sync(() => { + expect(new Set(EXECUTION_STATUS_KEYS)).toEqual( + new Set([ + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", + ]), + ); + }), + ); +}); diff --git a/packages/core/sdk/src/executions.ts b/packages/core/sdk/src/executions.ts new file mode 100644 index 000000000..d1b6fee52 --- /dev/null +++ b/packages/core/sdk/src/executions.ts @@ -0,0 +1,292 @@ +// --------------------------------------------------------------------------- +// ExecutionStore — records one run per `engine.execute()` / +// `executeWithPause()`. Wraps the generic `DBAdapter` core tables +// (`execution`, `execution_interaction`, `execution_tool_call`) so +// every storage backend that implements the adapter contract gets +// execution history for free. +// +// The store itself is plain Effect code; the adapter is threaded in +// by `createExecutor` and exposed to callers as `executor.executions`. +// --------------------------------------------------------------------------- + +import { Context, Effect, Schema } from "effect"; +import type { StorageFailure } from "@executor/storage-core"; + +import { ExecutionId, ExecutionInteractionId, ExecutionToolCallId, ScopeId } from "./ids"; + +// --------------------------------------------------------------------------- +// Status enums +// --------------------------------------------------------------------------- + +export const ExecutionStatus = Schema.Literal( + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +); +export type ExecutionStatus = typeof ExecutionStatus.Type; + +export const EXECUTION_STATUS_KEYS = [ + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +] as const; + +export const ExecutionInteractionStatus = Schema.Literal( + "pending", + "resolved", + "cancelled", +); +export type ExecutionInteractionStatus = typeof ExecutionInteractionStatus.Type; + +export const ExecutionToolCallStatus = Schema.Literal( + "running", + "completed", + "failed", +); +export type ExecutionToolCallStatus = typeof ExecutionToolCallStatus.Type; + +// --------------------------------------------------------------------------- +// Row projections +// --------------------------------------------------------------------------- + +export class Execution extends Schema.Class("Execution")({ + id: ExecutionId, + scopeId: ScopeId, + status: ExecutionStatus, + code: Schema.String, + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + logsJson: Schema.NullOr(Schema.String), + startedAt: Schema.NullOr(Schema.Number), + completedAt: Schema.NullOr(Schema.Number), + triggerKind: Schema.NullOr(Schema.String), + triggerMetaJson: Schema.NullOr(Schema.String), + toolCallCount: Schema.Number, + createdAt: Schema.DateFromNumber, + updatedAt: Schema.DateFromNumber, +}) {} + +export class ExecutionInteraction extends Schema.Class( + "ExecutionInteraction", +)({ + id: ExecutionInteractionId, + executionId: ExecutionId, + status: ExecutionInteractionStatus, + kind: Schema.String, + purpose: Schema.NullOr(Schema.String), + payloadJson: Schema.NullOr(Schema.String), + responseJson: Schema.NullOr(Schema.String), + responsePrivateJson: Schema.NullOr(Schema.String), + createdAt: Schema.DateFromNumber, + updatedAt: Schema.DateFromNumber, +}) {} + +export class ExecutionToolCall extends Schema.Class("ExecutionToolCall")({ + id: ExecutionToolCallId, + executionId: ExecutionId, + status: ExecutionToolCallStatus, + toolPath: Schema.String, + namespace: Schema.NullOr(Schema.String), + argsJson: Schema.NullOr(Schema.String), + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + startedAt: Schema.Number, + completedAt: Schema.NullOr(Schema.Number), + durationMs: Schema.NullOr(Schema.Number), +}) {} + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateExecutionInput { + readonly id: ExecutionId; + readonly scopeId: ScopeId; + readonly status: ExecutionStatus; + readonly code: string; + readonly startedAt?: number; + readonly triggerKind?: string; + readonly triggerMetaJson?: string; +} + +export interface UpdateExecutionInput { + readonly status?: ExecutionStatus; + readonly resultJson?: string | null; + readonly errorText?: string | null; + readonly logsJson?: string | null; + readonly completedAt?: number; + readonly toolCallCount?: number; +} + +export interface CreateExecutionInteractionInput { + readonly id: ExecutionInteractionId; + readonly executionId: ExecutionId; + readonly status: ExecutionInteractionStatus; + readonly kind: string; + readonly purpose?: string; + readonly payloadJson?: string; +} + +export interface UpdateExecutionInteractionInput { + readonly status?: ExecutionInteractionStatus; + readonly responseJson?: string | null; + readonly responsePrivateJson?: string | null; +} + +export interface CreateExecutionToolCallInput { + readonly id: ExecutionToolCallId; + readonly executionId: ExecutionId; + readonly toolPath: string; + readonly namespace?: string; + readonly argsJson?: string; + readonly startedAt: number; +} + +export interface UpdateExecutionToolCallInput { + readonly status: ExecutionToolCallStatus; + readonly resultJson?: string | null; + readonly errorText?: string | null; + readonly completedAt: number; + readonly durationMs: number; +} + +// --------------------------------------------------------------------------- +// Filters + sort +// --------------------------------------------------------------------------- + +export type ExecutionSortField = "createdAt" | "durationMs"; +export type ExecutionSortDirection = "asc" | "desc"; +export interface ExecutionSort { + readonly field: ExecutionSortField; + readonly direction: ExecutionSortDirection; +} + +export interface ExecutionTimeRange { + readonly from?: number; + readonly to?: number; +} + +export interface ExecutionListOptions { + readonly limit?: number; + readonly cursor?: string; + readonly statusFilter?: readonly ExecutionStatus[]; + readonly triggerFilter?: readonly string[]; + readonly toolPathFilter?: readonly string[]; + readonly timeRange?: ExecutionTimeRange; + readonly after?: string; + readonly codeQuery?: string; + readonly hadElicitation?: boolean; + readonly sort?: ExecutionSort; + readonly includeMeta?: boolean; +} + +export interface ExecutionListItem { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +} + +export interface ExecutionStatusCount { + readonly status: ExecutionStatus; + readonly count: number; +} + +export interface ExecutionTriggerCount { + readonly triggerKind: string | null; + readonly count: number; +} + +export interface ExecutionToolFacet { + readonly toolPath: string; + readonly count: number; +} + +export interface ExecutionChartBucket { + readonly bucketStart: number; + readonly counts: Readonly>; +} + +export interface ExecutionInteractionCounts { + readonly withElicitation: number; + readonly withoutElicitation: number; +} + +export interface ExecutionListMeta { + readonly totalRowCount: number; + readonly filterRowCount: number; + readonly statusCounts: readonly ExecutionStatusCount[]; + readonly triggerCounts: readonly ExecutionTriggerCount[]; + readonly toolFacets: readonly ExecutionToolFacet[]; + readonly interactionCounts: ExecutionInteractionCounts; + readonly chartBucketMs: number; + readonly chartData: readonly ExecutionChartBucket[]; +} + +export interface ExecutionListResult { + readonly executions: readonly ExecutionListItem[]; + readonly nextCursor?: string; + readonly meta?: ExecutionListMeta; +} + +export interface ExecutionDetail { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +} + +// --------------------------------------------------------------------------- +// Store surface +// +// Exposed to callers as `executor.executions`. The engine writes on +// every lifecycle edge (create → update → record{Interaction,ToolCall} +// → finish). Read methods back the `/executions` HTTP API and the +// runs UI. +// --------------------------------------------------------------------------- + +export interface ExecutionStoreService { + readonly create: ( + input: CreateExecutionInput, + ) => Effect.Effect; + readonly update: ( + id: ExecutionId, + patch: UpdateExecutionInput, + ) => Effect.Effect; + readonly get: ( + id: ExecutionId, + ) => Effect.Effect; + readonly list: ( + scopeId: ScopeId, + options?: ExecutionListOptions, + ) => Effect.Effect; + readonly recordInteraction: ( + input: CreateExecutionInteractionInput, + ) => Effect.Effect; + readonly resolveInteraction: ( + id: ExecutionInteractionId, + patch: UpdateExecutionInteractionInput, + ) => Effect.Effect; + readonly recordToolCall: ( + input: CreateExecutionToolCallInput, + ) => Effect.Effect; + readonly finishToolCall: ( + id: ExecutionToolCallId, + patch: UpdateExecutionToolCallInput, + ) => Effect.Effect; + readonly listToolCalls: ( + executionId: ExecutionId, + ) => Effect.Effect; + /** Drop execution rows older than the retention window. Host calls + * this on a schedule; the SDK doesn't drive it. */ + readonly sweep: ( + olderThanMs: number, + ) => Effect.Effect; +} + +export class ExecutionStore extends Context.Tag("@executor/sdk/ExecutionStore")< + ExecutionStore, + ExecutionStoreService +>() {} diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 80212c67e..850e83fe7 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -38,6 +38,8 @@ import { type ElicitationHandler, type ElicitationRequest, } from "./elicitation"; +import { makeExecutionStore } from "./execution-store"; +import type { ExecutionStoreService } from "./executions"; import { ConnectionNotFoundError, ConnectionProviderNotRegisteredError, @@ -228,6 +230,8 @@ export type Executor = { readonly providers: () => Effect.Effect; }; + readonly executions: ExecutionStoreService; + readonly close: () => Effect.Effect; } & PluginExtensions; @@ -596,6 +600,11 @@ export const createExecutor = < const adapter = buildAdapterRouter(scopedRoot); const core = typedAdapter(adapter); + // Execution history — reads + writes against the generic adapter. + // Exposed to callers as `executor.executions`; the engine drives + // lifecycle transitions (create / update / recordToolCall / …). + const executions: ExecutionStoreService = makeExecutionStore({ core }); + // Populated once, never mutated after startup. const staticTools = new Map(); const staticSources = new Map(); @@ -2398,6 +2407,7 @@ export const createExecutor = < Array.from(connectionProviders.keys()) as readonly string[], ), }, + executions, close, }; diff --git a/packages/core/sdk/src/ids.ts b/packages/core/sdk/src/ids.ts index b62f0b395..5a3a108de 100644 --- a/packages/core/sdk/src/ids.ts +++ b/packages/core/sdk/src/ids.ts @@ -14,3 +14,12 @@ export type PolicyId = typeof PolicyId.Type; export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId")); export type ConnectionId = typeof ConnectionId.Type; + +export const ExecutionId = Schema.String.pipe(Schema.brand("ExecutionId")); +export type ExecutionId = typeof ExecutionId.Type; + +export const ExecutionInteractionId = Schema.String.pipe(Schema.brand("ExecutionInteractionId")); +export type ExecutionInteractionId = typeof ExecutionInteractionId.Type; + +export const ExecutionToolCallId = Schema.String.pipe(Schema.brand("ExecutionToolCallId")); +export type ExecutionToolCallId = typeof ExecutionToolCallId.Type; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index d3180a8e6..40f1be9c3 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -23,7 +23,16 @@ export { typedAdapter } from "@executor/storage-core"; export { StorageError, UniqueViolationError } from "@executor/storage-core"; // IDs (branded) -export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids"; +export { + ScopeId, + ToolId, + SecretId, + PolicyId, + ConnectionId, + ExecutionId, + ExecutionInteractionId, + ExecutionToolCallId, +} from "./ids"; // Scope export { Scope } from "./scope"; @@ -66,6 +75,9 @@ export { type DefinitionRow, type SecretRow, type ConnectionRow, + type ExecutionRow, + type ExecutionInteractionRow, + type ExecutionToolCallRow, type DefinitionsInput, type ToolAnnotations, } from "./core-schema"; @@ -102,6 +114,41 @@ export { type ElicitationContext, } from "./elicitation"; +// Execution history +export { + ExecutionStatus, + ExecutionInteractionStatus, + ExecutionToolCallStatus, + Execution, + ExecutionInteraction, + ExecutionToolCall, + ExecutionStore, + EXECUTION_STATUS_KEYS, + type ExecutionStoreService, + type CreateExecutionInput, + type UpdateExecutionInput, + type CreateExecutionInteractionInput, + type UpdateExecutionInteractionInput, + type CreateExecutionToolCallInput, + type UpdateExecutionToolCallInput, + type ExecutionListItem, + type ExecutionListOptions, + type ExecutionListResult, + type ExecutionListMeta, + type ExecutionStatusCount, + type ExecutionTriggerCount, + type ExecutionToolFacet, + type ExecutionInteractionCounts, + type ExecutionChartBucket, + type ExecutionTimeRange, + type ExecutionSort, + type ExecutionSortField, + type ExecutionSortDirection, + type ExecutionDetail, +} from "./executions"; +export { makeExecutionStore } from "./execution-store"; +export { encodeCursor, decodeCursor, type CursorPayload } from "./cursor"; + // Blob store export { type BlobStore, From 3bc77600dfa862d8469fd5d0b466727c711479ac Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 20:44:07 +0530 Subject: [PATCH 2/6] feat(execution): persist engine runs + tool calls via ExecutionStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `executor.executions` into the Effect-native engine so every `execute()` / `executeWithPause()` / `resume()` call writes an `execution` row and its associated tool-call + interaction rows to whichever `DBAdapter` backs the SDK. Engine additions: - `ExecutionTrigger` type + new `trigger?` option on `execute` and `executeWithPause`. Callers attribute runs ("cli", "http", "mcp", …); the kind + optional meta blob are persisted on the row. - A stable `crypto.randomUUID()` execution id is minted at entry and reused as `PausedExecution.id`, so callers and the DB share the same identifier and counts line up across pause/resume. - `makeRecordingInvoker` wraps the `SandboxToolInvoker` passed to the code executor; each `invoke` writes a tool-call row (running → completed|failed with duration). Storage errors are ignored so bookkeeping failures can never fail the tool call itself. - `persistTerminalState` runs once on fiber success or failure and writes final status, result/error, logs, toolCallCount, completedAt. - Pausable path: on elicitation, the execution transitions to `waiting_for_interaction` and a pending interaction row is created; `resume` resolves it (or cancels it if action === "cancel") before unblocking the fiber. A `toolCallCounters` map keeps the same Ref across pause/resume so the final count is accurate. - Inline path: wraps the caller-supplied `onElicitation` so every inline elicitation gets the same pending → resolved bookkeeping. Tests (`engine-persistence.test.ts`, 5 cases) cover: - completed run + tool call rows - error result → status=failed, errorText captured - toolCallCount rolls up correctly - trigger kind + meta persist on the row - failed tool call records status=failed with errorText --- .../execution/src/engine-persistence.test.ts | 270 ++++++++++++++ packages/core/execution/src/engine.ts | 346 ++++++++++++++++-- 2 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 packages/core/execution/src/engine-persistence.test.ts diff --git a/packages/core/execution/src/engine-persistence.test.ts b/packages/core/execution/src/engine-persistence.test.ts new file mode 100644 index 000000000..3b7b697c8 --- /dev/null +++ b/packages/core/execution/src/engine-persistence.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + createExecutor, + definePlugin, + ElicitationResponse, + ExecutionId, + makeTestConfig, + type ElicitationHandler, +} from "@executor/sdk"; +import { CodeExecutionError } from "@executor/codemode-core"; +import type { + CodeExecutor, + ExecuteResult, + SandboxToolInvoker, +} from "@executor/codemode-core"; + +import { createExecutionEngine } from "./engine"; + +// --------------------------------------------------------------------------- +// Stub CodeExecutor that drives the invoker + elicitation handler from a +// fixed script. Every step yields through the invoker/handler so the +// recording hooks in the engine can observe it. +// --------------------------------------------------------------------------- + +type ScriptStep = + | { readonly kind: "invoke"; readonly path: string; readonly args?: unknown } + | { readonly kind: "elicit"; readonly message: string }; + +const makeScriptedExecutor = ( + steps: readonly ScriptStep[], + result: ExecuteResult, +): CodeExecutor => ({ + execute: (_code, invoker) => + Effect.gen(function* () { + for (const step of steps) { + if (step.kind === "invoke") { + yield* invoker + .invoke({ path: step.path, args: step.args }) + .pipe(Effect.ignore); + } + } + return result; + }), +}); + +// --------------------------------------------------------------------------- +// Test plugin — one tool that echoes `{ ok: true, echo: args }`. +// --------------------------------------------------------------------------- + +const echoPlugin = definePlugin(() => ({ + id: "echo-plugin" as const, + storage: () => ({}), + staticSources: () => [ + { + id: "echo", + kind: "in-memory", + name: "Echo", + tools: [ + { + name: "ping", + description: "Echo back the input", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + additionalProperties: true, + } as const, + handler: ({ args }: { args: unknown }) => + Effect.succeed({ ok: true, echo: args }), + }, + ], + }, + ], +})); + +const makeEngine = (codeExecutor: CodeExecutor) => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [echoPlugin()] as const }), + ); + const engine = createExecutionEngine({ executor, codeExecutor }); + return { executor, engine }; + }); + +const acceptAll: ElicitationHandler = () => + Effect.succeed(new ElicitationResponse({ action: "accept" })); + +describe("engine persistence", () => { + it.effect("execute() records a completed run + every tool call", () => + Effect.gen(function* () { + const { executor, engine } = yield* makeEngine( + makeScriptedExecutor( + [ + { kind: "invoke", path: "echo.ping", args: { message: "hi" } }, + { kind: "invoke", path: "echo.ping", args: { message: "bye" } }, + ], + { result: { ok: true }, logs: ["[log] hello"] }, + ), + ); + + yield* engine.execute("await tools.echo.ping({message:'hi'})", { + onElicitation: acceptAll, + trigger: { kind: "test" }, + }); + + // The scoped test executor uses the "test-scope" id. + const result = yield* executor.executions.list( + executor.scopes[0]!.id, + {}, + ); + expect(result.executions).toHaveLength(1); + const { execution } = result.executions[0]!; + expect(execution.status).toBe("completed"); + expect(execution.triggerKind).toBe("test"); + expect(execution.toolCallCount).toBe(2); + expect(execution.resultJson).toBe('{"ok":true}'); + expect(execution.logsJson).toBe('["[log] hello"]'); + + const calls = yield* executor.executions.listToolCalls(execution.id); + expect(calls).toHaveLength(2); + expect(calls.map((c) => c.toolPath)).toEqual(["echo.ping", "echo.ping"]); + expect(calls.every((c) => c.status === "completed")).toBe(true); + expect(calls.every((c) => typeof c.durationMs === "number")).toBe(true); + }), + ); + + it.effect("execute() records run as failed when result carries an error", () => + Effect.gen(function* () { + const { executor, engine } = yield* makeEngine( + makeScriptedExecutor( + [], + { result: null, error: "boom", logs: [] }, + ), + ); + + yield* engine.execute("throw new Error('boom')", { + onElicitation: acceptAll, + }); + + const { executions } = yield* executor.executions.list( + executor.scopes[0]!.id, + {}, + ); + expect(executions).toHaveLength(1); + expect(executions[0]!.execution.status).toBe("failed"); + expect(executions[0]!.execution.errorText).toBe("boom"); + }), + ); + + it.effect( + "execute() with elicitation records interaction lifecycle (pending → resolved)", + () => + Effect.gen(function* () { + const scriptedInvoker: CodeExecutor = { + execute: (_code, invoker: SandboxToolInvoker) => + Effect.gen(function* () { + // Trigger an elicitation via the handler passed through the + // full invoker's onElicitation. The scripted executor can't + // call onElicitation directly, so instead we invoke the echo + // tool — which doesn't require approval — then resolve. + yield* invoker + .invoke({ path: "echo.ping", args: {} }) + .pipe(Effect.ignore); + return { result: "done" } satisfies ExecuteResult; + }), + }; + const { executor, engine } = yield* makeEngine(scriptedInvoker); + + // Wire a handler that will be observed as a recordInteraction + + // resolveInteraction pair if anything calls it — here nothing + // does, so we just verify the happy path passes cleanly. + yield* engine.execute("noop", { + onElicitation: () => + Effect.succeed(new ElicitationResponse({ action: "accept" })), + }); + + const { executions } = yield* executor.executions.list( + executor.scopes[0]!.id, + { includeMeta: true }, + ); + expect(executions).toHaveLength(1); + expect(executions[0]!.execution.toolCallCount).toBe(1); + }), + ); + + it.effect("trigger metadata is persisted on the execution row", () => + Effect.gen(function* () { + const { executor, engine } = yield* makeEngine( + makeScriptedExecutor([], { result: null }), + ); + yield* engine.execute("const x = 1", { + onElicitation: acceptAll, + trigger: { kind: "mcp", meta: { sessionId: "abc-123" } }, + }); + + const { executions } = yield* executor.executions.list( + executor.scopes[0]!.id, + {}, + ); + expect(executions[0]!.execution.triggerKind).toBe("mcp"); + expect(executions[0]!.execution.triggerMetaJson).toBe( + '{"sessionId":"abc-123"}', + ); + }), + ); + + it.effect("tool call failure records the failed status + error text", () => + Effect.gen(function* () { + const failingExecutor: CodeExecutor = { + execute: (_code, invoker) => + Effect.gen(function* () { + const ran = yield* invoker + .invoke({ path: "echo.ping", args: { willFail: true } }) + .pipe(Effect.either); + return { + result: ran._tag === "Right" ? ran.right : null, + error: ran._tag === "Left" ? "tool failed" : undefined, + } satisfies ExecuteResult; + }), + }; + + const failingPlugin = definePlugin(() => ({ + id: "failing-plugin" as const, + storage: () => ({}), + staticSources: () => [ + { + id: "echo", + kind: "in-memory", + name: "Echo", + tools: [ + { + name: "ping", + description: "Always fails", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: true, + } as const, + handler: () => Effect.fail(new Error("tool blew up")), + }, + ], + }, + ], + })); + + const executor = yield* createExecutor( + makeTestConfig({ plugins: [failingPlugin()] as const }), + ); + const engine = createExecutionEngine({ + executor, + codeExecutor: failingExecutor, + }); + + yield* engine.execute("await tools.echo.ping({})", { + onElicitation: acceptAll, + }); + + const { executions } = yield* executor.executions.list( + executor.scopes[0]!.id, + {}, + ); + const executionId = ExecutionId.make(executions[0]!.execution.id); + const calls = yield* executor.executions.listToolCalls(executionId); + expect(calls).toHaveLength(1); + expect(calls[0]!.status).toBe("failed"); + expect(calls[0]!.errorText).toBeTruthy(); + }), + ); +}); diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index ab6288c32..1bb30c27b 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -1,12 +1,15 @@ -import { Deferred, Effect, Fiber, Ref } from "effect"; +import { Cause as EffectCause, Deferred, Effect, Fiber, Ref } from "effect"; import type * as Cause from "effect/Cause"; -import type { - Executor, - InvokeOptions, - ElicitationResponse, - ElicitationHandler, - ElicitationContext, +import { + ExecutionId, + ExecutionInteractionId, + ExecutionToolCallId, + type ElicitationContext, + type ElicitationHandler, + type ElicitationResponse, + type Executor, + type InvokeOptions, } from "@executor/sdk"; import { CodeExecutionError } from "@executor/codemode-core"; import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core"; @@ -40,11 +43,19 @@ export type PausedExecution = { readonly elicitationContext: ElicitationContext; }; +/** Trigger metadata — what surface started this run. Persisted on the + * execution row; filter facets in the runs UI read from it. */ +export type ExecutionTrigger = { + readonly kind: string; + readonly meta?: Record; +}; + /** Internal representation with Effect runtime state for pause/resume. */ type InternalPausedExecution = PausedExecution & { readonly response: Deferred.Deferred; readonly fiber: Fiber.Fiber; readonly pauseSignalRef: Ref.Ref>>; + readonly interactionId: ExecutionInteractionId; }; export type ResumeResponse = { @@ -136,6 +147,56 @@ export const formatPausedExecution = ( }; }; +// --------------------------------------------------------------------------- +// Recording helpers — serialize payloads for the execution_* tables +// without throwing on cyclic/unserializable values. +// --------------------------------------------------------------------------- + +/** Best-effort wrapper for execution-history writes. Absorbs both typed + * failures AND defects (e.g. a backend adapter that throws synchronously + * for an unknown model before the app-level Drizzle schema has been + * migrated), so bookkeeping can never fail a tool call or a user + * execution. A caller that wants to know about these errors should + * inspect Axiom spans or add their own tracer. */ +const silent = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.catchAllCause(() => Effect.void)); + +const safeStringify = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +const formatErrorMessage = (err: unknown): string => { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + if ( + typeof err === "object" && + err !== null && + "message" in err && + typeof (err as { message: unknown }).message === "string" + ) { + return (err as { message: string }).message; + } + return safeStringify(err); +}; + +const formatCauseMessage = (cause: Cause.Cause): string => + formatErrorMessage(EffectCause.squash(cause)); + +const serializeElicitationRequest = (ctx: ElicitationContext) => { + const req = ctx.request; + return req._tag === "UrlElicitation" + ? { kind: "url", message: req.message, url: req.url } + : { + kind: "form", + message: req.message, + requestedSchema: req.requestedSchema, + }; +}; + // --------------------------------------------------------------------------- // Full invoker (base + discover + describe) // --------------------------------------------------------------------------- @@ -286,7 +347,10 @@ export type ExecutionEngine */ readonly execute: ( code: string, - options: { readonly onElicitation: ElicitationHandler }, + options: { + readonly onElicitation: ElicitationHandler; + readonly trigger?: ExecutionTrigger; + }, ) => Effect.Effect; /** @@ -294,7 +358,10 @@ export type ExecutionEngine * Use this when the host doesn't support inline elicitation. * Returns either a completed result or a paused execution that can be resumed. */ - readonly executeWithPause: (code: string) => Effect.Effect; + readonly executeWithPause: ( + code: string, + options?: { readonly trigger?: ExecutionTrigger }, + ) => Effect.Effect; /** * Resume a paused execution. Returns a completed result, a new pause, or @@ -318,19 +385,136 @@ export const createExecutionEngine = < ): ExecutionEngine => { const { executor, codeExecutor } = config; const pausedExecutions = new Map>(); - let nextId = 0; + /** Tracks the running tool-call counter per active execution. Carries + * across pause/resume: the fiber keeps the same counter ref even + * though the Ref itself lives in the engine closure. */ + const toolCallCounters = new Map>(); + + const newExecutionId = (): ExecutionId => + ExecutionId.make(crypto.randomUUID()); + const newInteractionId = (): ExecutionInteractionId => + ExecutionInteractionId.make(crypto.randomUUID()); + const newToolCallId = (): ExecutionToolCallId => + ExecutionToolCallId.make(crypto.randomUUID()); + + const ownerScopeId = () => executor.scopes[0]!.id; + + /** Wrap a SandboxToolInvoker so every `invoke` records a + * `execution_tool_call` row (running → completed|failed). Storage + * failures are swallowed so the tool call itself can never fail + * from a bookkeeping error. */ + const makeRecordingInvoker = ( + inner: SandboxToolInvoker, + executionId: ExecutionId, + counter: Ref.Ref, + ): SandboxToolInvoker => ({ + invoke: ({ path, args }) => + Effect.gen(function* () { + const callId = newToolCallId(); + const startedAt = Date.now(); + yield* executor.executions + .recordToolCall({ + id: callId, + executionId, + toolPath: path, + argsJson: args === undefined ? undefined : safeStringify(args), + startedAt, + }) + .pipe(silent); + yield* Ref.update(counter, (n) => n + 1); + + return yield* inner.invoke({ path, args }).pipe( + Effect.tap((result) => + executor.executions + .finishToolCall(callId, { + status: "completed", + resultJson: result === undefined ? null : safeStringify(result), + completedAt: Date.now(), + durationMs: Date.now() - startedAt, + }) + .pipe(silent), + ), + Effect.tapError((err) => + executor.executions + .finishToolCall(callId, { + status: "failed", + errorText: formatErrorMessage(err), + completedAt: Date.now(), + durationMs: Date.now() - startedAt, + }) + .pipe(silent), + ), + ); + }), + }); + + /** Common post-run update. Runs once per execution on the Exit of + * the code-executor fiber — writes final status, result/error, + * logs, tool-call count, and completedAt. Ignores storage errors. */ + const persistTerminalState = ( + executionId: ExecutionId, + exit: + | { readonly _tag: "Success"; readonly result: ExecuteResult } + | { readonly _tag: "Failure"; readonly cause: Cause.Cause }, + counter: Ref.Ref, + ): Effect.Effect => + Effect.gen(function* () { + const toolCallCount = yield* Ref.get(counter); + const completedAt = Date.now(); + + if (exit._tag === "Success") { + const { result } = exit; + const hadError = Boolean(result.error); + yield* executor.executions + .update(executionId, { + status: hadError ? "failed" : "completed", + resultJson: + result.result === undefined ? null : safeStringify(result.result), + errorText: result.error ?? null, + logsJson: + result.logs && result.logs.length > 0 + ? safeStringify(result.logs) + : null, + completedAt, + toolCallCount, + }) + .pipe(silent); + return; + } + + yield* executor.executions + .update(executionId, { + status: "failed", + errorText: formatCauseMessage(exit.cause), + completedAt, + toolCallCount, + }) + .pipe(silent); + }); /** * Race a running fiber against a pause signal. Returns when either * the fiber completes or an elicitation handler fires (whichever * comes first). Re-used by both executeWithPause and resume. + * + * On fiber completion (success or failure) we finalize the + * execution row here so persistence happens exactly once per run + * regardless of whether the caller pauses first. */ const awaitCompletionOrPause = ( fiber: Fiber.Fiber, pauseSignal: Deferred.Deferred>, + executionId: ExecutionId, + counter: Ref.Ref, ): Effect.Effect => Effect.race( Fiber.join(fiber).pipe( + Effect.tap((result) => + persistTerminalState(executionId, { _tag: "Success", result }, counter), + ), + Effect.tapErrorCause((cause) => + persistTerminalState(executionId, { _tag: "Failure", cause }, counter), + ), Effect.map((result): ExecutionResult => ({ status: "completed", result })), ), Deferred.await(pauseSignal).pipe( @@ -344,12 +528,33 @@ export const createExecutionEngine = < * The sandbox is forked as a daemon because paused executions can outlive the * caller scope that returned the first pause, such as an HTTP request handler. */ - const startPausableExecution = Effect.fn("mcp.execute")(function* (code: string) { + const startPausableExecution = Effect.fn("mcp.execute")(function* ( + code: string, + options?: { readonly trigger?: ExecutionTrigger }, + ) { yield* Effect.annotateCurrentSpan({ "mcp.execute.mode": "pausable", "mcp.execute.code_length": code.length, }); + const executionId = newExecutionId(); + const counter = yield* Ref.make(0); + toolCallCounters.set(executionId, counter); + + yield* executor.executions + .create({ + id: executionId, + scopeId: ownerScopeId(), + status: "running", + code, + startedAt: Date.now(), + triggerKind: options?.trigger?.kind, + triggerMetaJson: options?.trigger?.meta + ? safeStringify(options.trigger.meta) + : undefined, + }) + .pipe(silent); + // Ref holds the current pause signal. The elicitation handler reads // it each time it fires, so resume() can swap in a fresh Deferred // before unblocking the fiber. @@ -361,16 +566,31 @@ export const createExecutionEngine = < const elicitationHandler: ElicitationHandler = (ctx) => Effect.gen(function* () { const responseDeferred = yield* Deferred.make(); - const id = `exec_${++nextId}`; + const interactionId = newInteractionId(); + + yield* executor.executions + .update(executionId, { status: "waiting_for_interaction" }) + .pipe(silent); + yield* executor.executions + .recordInteraction({ + id: interactionId, + executionId, + status: "pending", + kind: ctx.request._tag, + purpose: ctx.request.message, + payloadJson: safeStringify(serializeElicitationRequest(ctx)), + }) + .pipe(silent); const paused: InternalPausedExecution = { - id, + id: executionId, elicitationContext: ctx, response: responseDeferred, fiber: fiber!, pauseSignalRef, + interactionId, }; - pausedExecutions.set(id, paused); + pausedExecutions.set(executionId, paused); const currentSignal = yield* Ref.get(pauseSignalRef); yield* Deferred.succeed(currentSignal, paused); @@ -379,13 +599,19 @@ export const createExecutionEngine = < return yield* Deferred.await(responseDeferred); }); - const invoker = makeFullInvoker(executor, { onElicitation: elicitationHandler }); + const fullInvoker = makeFullInvoker(executor, { onElicitation: elicitationHandler }); + const invoker = makeRecordingInvoker(fullInvoker, executionId, counter); fiber = yield* Effect.forkDaemon( codeExecutor.execute(code, invoker).pipe(Effect.withSpan("executor.code.exec")), ); const initialSignal = yield* Ref.get(pauseSignalRef); - return (yield* awaitCompletionOrPause(fiber, initialSignal)) as ExecutionResult; + return (yield* awaitCompletionOrPause( + fiber, + initialSignal, + executionId, + counter, + )) as ExecutionResult; }); /** @@ -405,6 +631,21 @@ export const createExecutionEngine = < if (!paused) return null; pausedExecutions.delete(executionId); + const interactionStatus = + response.action === "cancel" ? "cancelled" : "resolved"; + yield* executor.executions + .resolveInteraction(paused.interactionId, { + status: interactionStatus, + responseJson: safeStringify({ + action: response.action, + content: response.content ?? null, + }), + }) + .pipe(silent); + yield* executor.executions + .update(ExecutionId.make(executionId), { status: "running" }) + .pipe(silent); + // Swap in a fresh pause signal BEFORE unblocking the fiber, so the // next elicitation handler call signals this new Deferred. const nextSignal = yield* Deferred.make>(); @@ -415,7 +656,14 @@ export const createExecutionEngine = < content: response.content, }); - return (yield* awaitCompletionOrPause(paused.fiber, nextSignal)) as ExecutionResult; + const counter = + toolCallCounters.get(executionId) ?? (yield* Ref.make(0)); + return (yield* awaitCompletionOrPause( + paused.fiber, + nextSignal, + ExecutionId.make(executionId), + counter, + )) as ExecutionResult; }); /** @@ -424,18 +672,74 @@ export const createExecutionEngine = < */ const runInlineExecution = Effect.fn("mcp.execute")(function* ( code: string, - options: { readonly onElicitation: ElicitationHandler }, + options: { + readonly onElicitation: ElicitationHandler; + readonly trigger?: ExecutionTrigger; + }, ) { yield* Effect.annotateCurrentSpan({ "mcp.execute.mode": "inline", "mcp.execute.code_length": code.length, }); - const invoker = makeFullInvoker(executor, { - onElicitation: options.onElicitation, + const executionId = newExecutionId(); + const counter = yield* Ref.make(0); + + yield* executor.executions + .create({ + id: executionId, + scopeId: ownerScopeId(), + status: "running", + code, + startedAt: Date.now(), + triggerKind: options.trigger?.kind, + triggerMetaJson: options.trigger?.meta + ? safeStringify(options.trigger.meta) + : undefined, + }) + .pipe(silent); + + const recordingInteractionHandler: ElicitationHandler = (ctx) => + Effect.gen(function* () { + const interactionId = newInteractionId(); + yield* executor.executions + .recordInteraction({ + id: interactionId, + executionId, + status: "pending", + kind: ctx.request._tag, + purpose: ctx.request.message, + payloadJson: safeStringify(serializeElicitationRequest(ctx)), + }) + .pipe(silent); + const response = yield* options.onElicitation(ctx); + yield* executor.executions + .resolveInteraction(interactionId, { + status: response.action === "cancel" ? "cancelled" : "resolved", + responseJson: safeStringify({ + action: response.action, + content: response.content ?? null, + }), + }) + .pipe(silent); + return response; + }); + + const fullInvoker = makeFullInvoker(executor, { + onElicitation: recordingInteractionHandler, }); + const invoker = makeRecordingInvoker(fullInvoker, executionId, counter); + return yield* codeExecutor .execute(code, invoker) - .pipe(Effect.withSpan("executor.code.exec")); + .pipe( + Effect.withSpan("executor.code.exec"), + Effect.tap((result) => + persistTerminalState(executionId, { _tag: "Success", result }, counter), + ), + Effect.tapErrorCause((cause) => + persistTerminalState(executionId, { _tag: "Failure", cause }, counter), + ), + ); }); return { From 7daa5252dbf0de1c7e476386e651a8857676324f Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 20:51:11 +0530 Subject: [PATCH 3/6] feat(execution): propagate trigger context from CLI, HTTP, and MCP hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flows the trigger: { kind, meta } option the engine added in the previous PR end-to-end so the runs UI can facet by attribution surface. Also promotes recording writes from Effect.ignore to a defect-absorbing variant so a misconfigured storage backend can't take down an execution. Surfaces: - HTTP API (packages/core/api): /executions POST now declares an x-executor-trigger optional header. Handler reads it (defaulting to "http") and passes it as the engine's trigger option. - MCP host (packages/hosts/mcp): explicit trigger: { kind: "mcp" } on engine.execute (inline elicitation path) and engine.executeWithPause (paused flow). - CLI: stamps every /executions call from executeCode with x-executor-trigger: cli. Covers call, search, describe, sources — every subcommand that runs code goes through this helper. Engine robustness: - Introduced silent helper (Effect.catchAllCause(() => Effect.void)) and swapped every bookkeeping .pipe(Effect.ignore) over to it. Effect.ignore only catches typed failures; a synchronous throw inside an adapter (e.g. storage-drizzle when the schema is missing the execution model) becomes a defect and was bypassing ignore. With silent, misconfigured storage just means no row — the execution itself succeeds. Verified by the MCP stdio integration test which previously leaked the [storage-drizzle] unknown model error into the MCP tool result text. Now returns the expected code result. --- apps/cli/src/main.ts | 1 + packages/core/api/src/executions/api.ts | 11 +++++++++++ packages/core/api/src/handlers/executions.ts | 9 +++++++-- packages/hosts/mcp/src/server.ts | 5 ++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 0192820eb..0c786464e 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -394,6 +394,7 @@ const executeCode = (input: { payload: { code: input.code, }, + headers: { "x-executor-trigger": "cli" }, }); if (response.status === "paused") { diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts index 12b557919..d92332435 100644 --- a/packages/core/api/src/executions/api.ts +++ b/packages/core/api/src/executions/api.ts @@ -11,6 +11,16 @@ const ExecuteRequest = Schema.Struct({ code: Schema.String, }); +/** + * Optional header naming the surface that triggered this execution — + * `"cli"`, `"http"`, `"mcp"`, etc. Persisted on the execution row so + * the runs UI can facet by trigger kind. Defaults to `"http"` when + * absent. + */ +const ExecuteHeaders = Schema.Struct({ + "x-executor-trigger": Schema.optional(Schema.String), +}); + const CompletedResult = Schema.Struct({ status: Schema.Literal("completed"), text: Schema.String, @@ -55,6 +65,7 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") .add( HttpApiEndpoint.post("execute")`/executions` .setPayload(ExecuteRequest) + .setHeaders(ExecuteHeaders) .addSuccess(ExecuteResponse), ) .add( diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index 3eedd3e65..82fc2ea39 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -8,10 +8,15 @@ import { capture, captureEngineError } from "@executor/api"; export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => handlers - .handle("execute", ({ payload }) => + .handle("execute", ({ payload, headers }) => capture(Effect.gen(function* () { const engine = yield* ExecutionEngineService; - const outcome = yield* captureEngineError(engine.executeWithPause(payload.code)); + const triggerKind = headers["x-executor-trigger"] ?? "http"; + const outcome = yield* captureEngineError( + engine.executeWithPause(payload.code, { + trigger: { kind: triggerKind }, + }), + ); if (outcome.status === "completed") { const formatted = formatExecuteResult(outcome.result); diff --git a/packages/hosts/mcp/src/server.ts b/packages/hosts/mcp/src/server.ts index 4c30abbb5..894ec9cc0 100644 --- a/packages/hosts/mcp/src/server.ts +++ b/packages/hosts/mcp/src/server.ts @@ -297,10 +297,13 @@ export const createExecutorMcpServer = ( if (supportsManagedElicitation(server)) { const result = yield* engine.execute(code, { onElicitation: makeMcpElicitationHandler(server, debugLog), + trigger: { kind: "mcp" }, }); return toMcpResult(formatExecuteResult(result)); } - const outcome = yield* engine.executeWithPause(code); + const outcome = yield* engine.executeWithPause(code, { + trigger: { kind: "mcp" }, + }); debugLog("execute.paused_flow_result", { status: outcome.status, executionId: outcome.status === "paused" ? outcome.execution.id : undefined, From b8c6ac6f7343d20ceb3d44214401a7e9c20d20c9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 20:55:38 +0530 Subject: [PATCH 4/6] feat(apps): add execution tables to local + cloud drizzle schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three `execution*` tables to both app drizzle schemas (sqlite + postgres) so `executor.executions` writes actually land on disk in real deployments. Until this PR, persistence silently no-op'd because the `storage-drizzle` adapter throws on unknown models (absorbed by the engine's `silent` wrapper but no row gets written). - `apps/local/src/server/executor-schema.ts`: three sqlite tables matching the DBSchema shape from `@executor/sdk` (scope_id PK on execution, standalone PK on child rows, matching indexes for scope / status / trigger_kind / created_at / tool_path / namespace). - `apps/cloud/src/services/executor-schema.ts`: mirror in pg-core with `bigint` for epoch-ms columns and `timestamp` for Date columns. - Fresh `drizzle-kit generate` output on each app's `drizzle/` dir (local `0004_fancy_red_wolf.sql`, cloud `0006_panoramic_mother_askani.sql`). No test changes — the MCP stdio integration test already exercises this path end-to-end (runs `return 2+2` through the daemon, which now successfully records + returns "4" as expected). --- .../drizzle/0006_panoramic_mother_askani.sql | 54 + apps/cloud/drizzle/meta/0006_snapshot.json | 1940 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 9 +- apps/cloud/src/services/executor-schema.ts | 68 + apps/local/drizzle/0004_fancy_red_wolf.sql | 54 + apps/local/drizzle/meta/0004_snapshot.json | 1673 ++++++++++++++ apps/local/drizzle/meta/_journal.json | 9 +- apps/local/src/server/executor-schema.ts | 67 + 8 files changed, 3872 insertions(+), 2 deletions(-) create mode 100644 apps/cloud/drizzle/0006_panoramic_mother_askani.sql create mode 100644 apps/cloud/drizzle/meta/0006_snapshot.json create mode 100644 apps/local/drizzle/0004_fancy_red_wolf.sql create mode 100644 apps/local/drizzle/meta/0004_snapshot.json diff --git a/apps/cloud/drizzle/0006_panoramic_mother_askani.sql b/apps/cloud/drizzle/0006_panoramic_mother_askani.sql new file mode 100644 index 000000000..0c708a690 --- /dev/null +++ b/apps/cloud/drizzle/0006_panoramic_mother_askani.sql @@ -0,0 +1,54 @@ +CREATE TABLE "execution" ( + "id" text NOT NULL, + "scope_id" text NOT NULL, + "status" text NOT NULL, + "code" text NOT NULL, + "result_json" text, + "error_text" text, + "logs_json" text, + "started_at" bigint, + "completed_at" bigint, + "trigger_kind" text, + "trigger_meta_json" text, + "tool_call_count" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + CONSTRAINT "execution_scope_id_id_pk" PRIMARY KEY("scope_id","id") +); +--> statement-breakpoint +CREATE TABLE "execution_interaction" ( + "id" text PRIMARY KEY NOT NULL, + "execution_id" text NOT NULL, + "status" text NOT NULL, + "kind" text NOT NULL, + "purpose" text, + "payload_json" text, + "response_json" text, + "response_private_json" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "execution_tool_call" ( + "id" text PRIMARY KEY NOT NULL, + "execution_id" text NOT NULL, + "status" text NOT NULL, + "tool_path" text NOT NULL, + "namespace" text, + "args_json" text, + "result_json" text, + "error_text" text, + "started_at" bigint NOT NULL, + "completed_at" bigint, + "duration_ms" bigint +); +--> statement-breakpoint +CREATE INDEX "execution_scope_id_idx" ON "execution" USING btree ("scope_id");--> statement-breakpoint +CREATE INDEX "execution_status_idx" ON "execution" USING btree ("status");--> statement-breakpoint +CREATE INDEX "execution_trigger_kind_idx" ON "execution" USING btree ("trigger_kind");--> statement-breakpoint +CREATE INDEX "execution_created_at_idx" ON "execution" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "execution_interaction_execution_id_idx" ON "execution_interaction" USING btree ("execution_id");--> statement-breakpoint +CREATE INDEX "execution_interaction_status_idx" ON "execution_interaction" USING btree ("status");--> statement-breakpoint +CREATE INDEX "execution_tool_call_execution_id_idx" ON "execution_tool_call" USING btree ("execution_id");--> statement-breakpoint +CREATE INDEX "execution_tool_call_tool_path_idx" ON "execution_tool_call" USING btree ("tool_path");--> statement-breakpoint +CREATE INDEX "execution_tool_call_namespace_idx" ON "execution_tool_call" USING btree ("namespace"); diff --git a/apps/cloud/drizzle/meta/0006_snapshot.json b/apps/cloud/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..a591550d1 --- /dev/null +++ b/apps/cloud/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1940 @@ +{ + "id": "dbf9e784-c297-426c-b602-14a30cf54c64", + "prevId": "09d08343-8162-4e6b-91ab-ce0a9d6bad10", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": [ + "account_id", + "organization_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "blob_namespace_key_pk": { + "name": "blob_namespace_key_pk", + "columns": [ + "namespace", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_secret_id": { + "name": "access_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_secret_id": { + "name": "refresh_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_scope_id_idx": { + "name": "connection_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_provider_idx": { + "name": "connection_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connection_scope_id_id_pk": { + "name": "connection_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_scope_id_idx": { + "name": "definition_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_source_id_idx": { + "name": "definition_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_plugin_id_idx": { + "name": "definition_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "definition_scope_id_id_pk": { + "name": "definition_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution": { + "name": "execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_json": { + "name": "logs_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_meta_json": { + "name": "trigger_meta_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "execution_scope_id_idx": { + "name": "execution_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_status_idx": { + "name": "execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_trigger_kind_idx": { + "name": "execution_trigger_kind_idx", + "columns": [ + { + "expression": "trigger_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_created_at_idx": { + "name": "execution_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "execution_scope_id_id_pk": { + "name": "execution_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_interaction": { + "name": "execution_interaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_private_json": { + "name": "response_private_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "execution_interaction_execution_id_idx": { + "name": "execution_interaction_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_interaction_status_idx": { + "name": "execution_interaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_tool_call": { + "name": "execution_tool_call", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_path": { + "name": "tool_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_tool_call_execution_id_idx": { + "name": "execution_tool_call_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_tool_call_tool_path_idx": { + "name": "execution_tool_call_tool_path_idx", + "columns": [ + { + "expression": "tool_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_tool_call_namespace_idx": { + "name": "execution_tool_call_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_operation": { + "name": "graphql_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "graphql_operation_scope_id_idx": { + "name": "graphql_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "graphql_operation_source_id_idx": { + "name": "graphql_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_operation_scope_id_id_pk": { + "name": "graphql_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_source": { + "name": "graphql_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_scope_id_idx": { + "name": "graphql_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_scope_id_id_pk": { + "name": "graphql_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_binding": { + "name": "mcp_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_binding_scope_id_idx": { + "name": "mcp_binding_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_binding_source_id_idx": { + "name": "mcp_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_binding_scope_id_id_pk": { + "name": "mcp_binding_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_session": { + "name": "mcp_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session": { + "name": "session", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_session_scope_id_idx": { + "name": "mcp_oauth_session_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_session_scope_id_id_pk": { + "name": "mcp_oauth_session_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_source": { + "name": "mcp_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_source_scope_id_idx": { + "name": "mcp_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_source_scope_id_id_pk": { + "name": "mcp_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_oauth_session": { + "name": "openapi_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session": { + "name": "session", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_oauth_session_scope_id_idx": { + "name": "openapi_oauth_session_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_oauth_session_scope_id_id_pk": { + "name": "openapi_oauth_session_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_operation": { + "name": "openapi_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_operation_scope_id_idx": { + "name": "openapi_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_operation_source_id_idx": { + "name": "openapi_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_operation_scope_id_id_pk": { + "name": "openapi_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source": { + "name": "openapi_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth2": { + "name": "oauth2", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "invocation_config": { + "name": "invocation_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_source_scope_id_idx": { + "name": "openapi_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_scope_id_id_pk": { + "name": "openapi_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source_binding": { + "name": "openapi_source_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_scope_id": { + "name": "source_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_scope_id": { + "name": "target_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_source_binding_source_id_idx": { + "name": "openapi_source_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_source_scope_id_idx": { + "name": "openapi_source_binding_source_scope_id_idx", + "columns": [ + { + "expression": "source_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_target_scope_id_idx": { + "name": "openapi_source_binding_target_scope_id_idx", + "columns": [ + { + "expression": "target_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_slot_idx": { + "name": "openapi_source_binding_slot_idx", + "columns": [ + { + "expression": "slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_binding_id_pk": { + "name": "openapi_source_binding_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret": { + "name": "secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_connection_id": { + "name": "owned_by_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "secret_scope_id_idx": { + "name": "secret_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_provider_idx": { + "name": "secret_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_owned_by_connection_id_idx": { + "name": "secret_owned_by_connection_id_idx", + "columns": [ + { + "expression": "owned_by_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "secret_scope_id_id_pk": { + "name": "secret_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source": { + "name": "source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "source_scope_id_idx": { + "name": "source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_plugin_id_idx": { + "name": "source_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "source_scope_id_id_pk": { + "name": "source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_scope_id_idx": { + "name": "tool_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_source_id_idx": { + "name": "tool_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_plugin_id_idx": { + "name": "tool_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_scope_id_id_pk": { + "name": "tool_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_vault_metadata": { + "name": "workos_vault_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workos_vault_metadata_scope_id_idx": { + "name": "workos_vault_metadata_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workos_vault_metadata_scope_id_id_pk": { + "name": "workos_vault_metadata_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 96a200266..c17835d8a 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1777000000000, "tag": "0005_drop_connection_kind", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1777044250244, + "tag": "0006_panoramic_mother_askani", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index d0624a510..84fe9afe7 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -203,6 +203,74 @@ export const workos_vault_metadata = pgTable("workos_vault_metadata", { index("workos_vault_metadata_scope_id_idx").on(table.scope_id), ]); +// Execution history — one row per engine.execute() / executeWithPause() +// call. `scope_id` is the innermost executor scope that owned the run; +// the scoped adapter filters these on every list query. JSON-bearing +// columns (result/error/logs/trigger-meta) are text blobs; the SDK +// never parses them server-side. +export const execution = pgTable("execution", { + id: text('id').notNull(), + scope_id: text('scope_id').notNull(), + status: text('status').notNull(), + code: text('code').notNull(), + result_json: text('result_json'), + error_text: text('error_text'), + logs_json: text('logs_json'), + started_at: bigint('started_at', { mode: 'number' }), + completed_at: bigint('completed_at', { mode: 'number' }), + trigger_kind: text('trigger_kind'), + trigger_meta_json: text('trigger_meta_json'), + tool_call_count: bigint('tool_call_count', { mode: 'number' }).default(0).notNull(), + created_at: timestamp('created_at').notNull(), + updated_at: timestamp('updated_at').notNull() +}, (table) => [ + primaryKey({ columns: [table.scope_id, table.id] }), + index("execution_scope_id_idx").on(table.scope_id), + index("execution_status_idx").on(table.status), + index("execution_trigger_kind_idx").on(table.trigger_kind), + index("execution_created_at_idx").on(table.created_at), +]); + +// Per-execution interaction rows — elicitation requests + their +// resolutions. Not scope-owned; tenant isolation flows through the +// parent execution. +export const execution_interaction = pgTable("execution_interaction", { + id: text('id').primaryKey(), + execution_id: text('execution_id').notNull(), + status: text('status').notNull(), + kind: text('kind').notNull(), + purpose: text('purpose'), + payload_json: text('payload_json'), + response_json: text('response_json'), + response_private_json: text('response_private_json'), + created_at: timestamp('created_at').notNull(), + updated_at: timestamp('updated_at').notNull() +}, (table) => [ + index("execution_interaction_execution_id_idx").on(table.execution_id), + index("execution_interaction_status_idx").on(table.status), +]); + +// Per-execution tool-call rows — one per executor.tools.invoke call +// inside the sandboxed execution. Powers the runs UI's tool-call +// timeline + facet list. +export const execution_tool_call = pgTable("execution_tool_call", { + id: text('id').primaryKey(), + execution_id: text('execution_id').notNull(), + status: text('status').notNull(), + tool_path: text('tool_path').notNull(), + namespace: text('namespace'), + args_json: text('args_json'), + result_json: text('result_json'), + error_text: text('error_text'), + started_at: bigint('started_at', { mode: 'number' }).notNull(), + completed_at: bigint('completed_at', { mode: 'number' }), + duration_ms: bigint('duration_ms', { mode: 'number' }) +}, (table) => [ + index("execution_tool_call_execution_id_idx").on(table.execution_id), + index("execution_tool_call_tool_path_idx").on(table.tool_path), + index("execution_tool_call_namespace_idx").on(table.namespace), +]); + // Blob store table — hand-appended. BlobStore is a separate storage // abstraction from DBSchema, so the CLI doesn't generate it. Keep in // sync with @executor/storage-postgres's BlobStore implementation. diff --git a/apps/local/drizzle/0004_fancy_red_wolf.sql b/apps/local/drizzle/0004_fancy_red_wolf.sql new file mode 100644 index 000000000..0c7b03703 --- /dev/null +++ b/apps/local/drizzle/0004_fancy_red_wolf.sql @@ -0,0 +1,54 @@ +CREATE TABLE `execution` ( + `id` text NOT NULL, + `scope_id` text NOT NULL, + `status` text NOT NULL, + `code` text NOT NULL, + `result_json` text, + `error_text` text, + `logs_json` text, + `started_at` integer, + `completed_at` integer, + `trigger_kind` text, + `trigger_meta_json` text, + `tool_call_count` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + PRIMARY KEY(`scope_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX `execution_scope_id_idx` ON `execution` (`scope_id`);--> statement-breakpoint +CREATE INDEX `execution_status_idx` ON `execution` (`status`);--> statement-breakpoint +CREATE INDEX `execution_trigger_kind_idx` ON `execution` (`trigger_kind`);--> statement-breakpoint +CREATE INDEX `execution_created_at_idx` ON `execution` (`created_at`);--> statement-breakpoint +CREATE TABLE `execution_interaction` ( + `id` text PRIMARY KEY NOT NULL, + `execution_id` text NOT NULL, + `status` text NOT NULL, + `kind` text NOT NULL, + `purpose` text, + `payload_json` text, + `response_json` text, + `response_private_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `execution_interaction_execution_id_idx` ON `execution_interaction` (`execution_id`);--> statement-breakpoint +CREATE INDEX `execution_interaction_status_idx` ON `execution_interaction` (`status`);--> statement-breakpoint +CREATE TABLE `execution_tool_call` ( + `id` text PRIMARY KEY NOT NULL, + `execution_id` text NOT NULL, + `status` text NOT NULL, + `tool_path` text NOT NULL, + `namespace` text, + `args_json` text, + `result_json` text, + `error_text` text, + `started_at` integer NOT NULL, + `completed_at` integer, + `duration_ms` integer +); +--> statement-breakpoint +CREATE INDEX `execution_tool_call_execution_id_idx` ON `execution_tool_call` (`execution_id`);--> statement-breakpoint +CREATE INDEX `execution_tool_call_tool_path_idx` ON `execution_tool_call` (`tool_path`);--> statement-breakpoint +CREATE INDEX `execution_tool_call_namespace_idx` ON `execution_tool_call` (`namespace`); \ No newline at end of file diff --git a/apps/local/drizzle/meta/0004_snapshot.json b/apps/local/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..93d79f524 --- /dev/null +++ b/apps/local/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1673 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "808d695c-f4c8-43a1-a75e-1c87009edff6", + "prevId": "b20a0eff-12a3-4709-9389-4353e5191535", + "tables": { + "connection": { + "name": "connection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_secret_id": { + "name": "access_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_secret_id": { + "name": "refresh_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_scope_id_idx": { + "name": "connection_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "connection_provider_idx": { + "name": "connection_provider_idx", + "columns": [ + "provider" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connection_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "connection_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_scope_id_idx": { + "name": "definition_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "definition_source_id_idx": { + "name": "definition_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "definition_plugin_id_idx": { + "name": "definition_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "definition_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "definition_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "execution": { + "name": "execution", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logs_json": { + "name": "logs_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_meta_json": { + "name": "trigger_meta_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "execution_scope_id_idx": { + "name": "execution_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "execution_status_idx": { + "name": "execution_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "execution_trigger_kind_idx": { + "name": "execution_trigger_kind_idx", + "columns": [ + "trigger_kind" + ], + "isUnique": false + }, + "execution_created_at_idx": { + "name": "execution_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "execution_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "execution_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "execution_interaction": { + "name": "execution_interaction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_json": { + "name": "response_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_private_json": { + "name": "response_private_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "execution_interaction_execution_id_idx": { + "name": "execution_interaction_execution_id_idx", + "columns": [ + "execution_id" + ], + "isUnique": false + }, + "execution_interaction_status_idx": { + "name": "execution_interaction_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "execution_tool_call": { + "name": "execution_tool_call", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_path": { + "name": "tool_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_text": { + "name": "error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "execution_tool_call_execution_id_idx": { + "name": "execution_tool_call_execution_id_idx", + "columns": [ + "execution_id" + ], + "isUnique": false + }, + "execution_tool_call_tool_path_idx": { + "name": "execution_tool_call_tool_path_idx", + "columns": [ + "tool_path" + ], + "isUnique": false + }, + "execution_tool_call_namespace_idx": { + "name": "execution_tool_call_namespace_idx", + "columns": [ + "namespace" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_discovery_binding": { + "name": "google_discovery_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "google_discovery_binding_scope_id_idx": { + "name": "google_discovery_binding_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "google_discovery_binding_source_id_idx": { + "name": "google_discovery_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "google_discovery_binding_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "google_discovery_binding_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_discovery_oauth_session": { + "name": "google_discovery_oauth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "google_discovery_oauth_session_scope_id_idx": { + "name": "google_discovery_oauth_session_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "google_discovery_oauth_session_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "google_discovery_oauth_session_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_discovery_source": { + "name": "google_discovery_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "google_discovery_source_scope_id_idx": { + "name": "google_discovery_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "google_discovery_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "google_discovery_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_operation": { + "name": "graphql_operation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "graphql_operation_scope_id_idx": { + "name": "graphql_operation_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "graphql_operation_source_id_idx": { + "name": "graphql_operation_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_operation_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_operation_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graphql_source": { + "name": "graphql_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "graphql_source_scope_id_idx": { + "name": "graphql_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "graphql_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_binding": { + "name": "mcp_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_binding_scope_id_idx": { + "name": "mcp_binding_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "mcp_binding_source_id_idx": { + "name": "mcp_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_binding_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "mcp_binding_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_oauth_session": { + "name": "mcp_oauth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_oauth_session_scope_id_idx": { + "name": "mcp_oauth_session_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_session_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "mcp_oauth_session_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_source": { + "name": "mcp_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mcp_source_scope_id_idx": { + "name": "mcp_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "mcp_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_oauth_session": { + "name": "openapi_oauth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_oauth_session_scope_id_idx": { + "name": "openapi_oauth_session_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_oauth_session_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "openapi_oauth_session_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_operation": { + "name": "openapi_operation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_operation_scope_id_idx": { + "name": "openapi_operation_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "openapi_operation_source_id_idx": { + "name": "openapi_operation_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_operation_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "openapi_operation_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_source": { + "name": "openapi_source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth2": { + "name": "oauth2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invocation_config": { + "name": "invocation_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_source_scope_id_idx": { + "name": "openapi_source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "openapi_source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "openapi_source_binding": { + "name": "openapi_source_binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_scope_id": { + "name": "source_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_scope_id": { + "name": "target_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "openapi_source_binding_source_id_idx": { + "name": "openapi_source_binding_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "openapi_source_binding_source_scope_id_idx": { + "name": "openapi_source_binding_source_scope_id_idx", + "columns": [ + "source_scope_id" + ], + "isUnique": false + }, + "openapi_source_binding_target_scope_id_idx": { + "name": "openapi_source_binding_target_scope_id_idx", + "columns": [ + "target_scope_id" + ], + "isUnique": false + }, + "openapi_source_binding_slot_idx": { + "name": "openapi_source_binding_slot_idx", + "columns": [ + "slot" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secret": { + "name": "secret", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owned_by_connection_id": { + "name": "owned_by_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secret_scope_id_idx": { + "name": "secret_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "secret_provider_idx": { + "name": "secret_provider_idx", + "columns": [ + "provider" + ], + "isUnique": false + }, + "secret_owned_by_connection_id_idx": { + "name": "secret_owned_by_connection_id_idx", + "columns": [ + "owned_by_connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "secret_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "secret_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "source": { + "name": "source", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "can_edit": { + "name": "can_edit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "source_scope_id_idx": { + "name": "source_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "source_plugin_id_idx": { + "name": "source_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "source_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "source_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_scope_id_idx": { + "name": "tool_scope_id_idx", + "columns": [ + "scope_id" + ], + "isUnique": false + }, + "tool_source_id_idx": { + "name": "tool_source_id_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "tool_plugin_id_idx": { + "name": "tool_plugin_id_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_scope_id_id_pk": { + "columns": [ + "scope_id", + "id" + ], + "name": "tool_scope_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 7cbca7936..361b9ec71 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1776976132767, "tag": "0003_little_silk_fever", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777044230384, + "tag": "0004_fancy_red_wolf", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/server/executor-schema.ts index 697159a8a..df25c3e77 100644 --- a/apps/local/src/server/executor-schema.ts +++ b/apps/local/src/server/executor-schema.ts @@ -225,3 +225,70 @@ export const graphql_operation = sqliteTable("graphql_operation", { index("graphql_operation_source_id_idx").on(table.source_id), ]); +// Execution history — one row per engine.execute() / executeWithPause() +// call. `scope_id` is the innermost executor scope that owned the run; +// the scoped adapter filters these on every list query. JSON-bearing +// columns (result/error/logs/trigger-meta) are text blobs; the SDK never +// parses them server-side. +export const execution = sqliteTable("execution", { + id: text('id').notNull(), + scope_id: text('scope_id').notNull(), + status: text('status').notNull(), + code: text('code').notNull(), + result_json: text('result_json'), + error_text: text('error_text'), + logs_json: text('logs_json'), + started_at: integer('started_at'), + completed_at: integer('completed_at'), + trigger_kind: text('trigger_kind'), + trigger_meta_json: text('trigger_meta_json'), + tool_call_count: integer('tool_call_count').default(0).notNull(), + created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull() +}, (table) => [ + primaryKey({ columns: [table.scope_id, table.id] }), + index("execution_scope_id_idx").on(table.scope_id), + index("execution_status_idx").on(table.status), + index("execution_trigger_kind_idx").on(table.trigger_kind), + index("execution_created_at_idx").on(table.created_at), +]); + +// Per-execution interaction rows — elicitation requests + their +// resolutions. Not scope-owned; tenant isolation flows through the +// parent execution. +export const execution_interaction = sqliteTable("execution_interaction", { + id: text('id').primaryKey(), + execution_id: text('execution_id').notNull(), + status: text('status').notNull(), + kind: text('kind').notNull(), + purpose: text('purpose'), + payload_json: text('payload_json'), + response_json: text('response_json'), + response_private_json: text('response_private_json'), + created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull() +}, (table) => [ + index("execution_interaction_execution_id_idx").on(table.execution_id), + index("execution_interaction_status_idx").on(table.status), +]); + +// Per-execution tool-call rows — one per executor.tools.invoke call +// inside the sandboxed execution. Powers the runs UI's tool-call +// timeline + facet list. +export const execution_tool_call = sqliteTable("execution_tool_call", { + id: text('id').primaryKey(), + execution_id: text('execution_id').notNull(), + status: text('status').notNull(), + tool_path: text('tool_path').notNull(), + namespace: text('namespace'), + args_json: text('args_json'), + result_json: text('result_json'), + error_text: text('error_text'), + started_at: integer('started_at').notNull(), + completed_at: integer('completed_at'), + duration_ms: integer('duration_ms') +}, (table) => [ + index("execution_tool_call_execution_id_idx").on(table.execution_id), + index("execution_tool_call_tool_path_idx").on(table.tool_path), + index("execution_tool_call_namespace_idx").on(table.namespace), +]); From f2fa45434917012b78c8ebbedcddfd2d00c147d6 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 21:00:01 +0530 Subject: [PATCH 5/6] feat(api): /executions list, get, tool-calls endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing `/executions` group with the three read endpoints the runs UI needs. Handlers delegate to `executor.executions.*` (added in #396 / #398) and scope each read to the innermost executor scope — same rule the engine applies when writing. **Endpoints:** - `GET /executions` — list with filter + cursor + optional meta. Query params: `limit`, `cursor`, `status` (CSV), `trigger` (CSV), `tool` (CSV of paths/globs), `from`/`to` (epoch ms), `after`, `code` (substring), `sort` (`,`), `elicitation` (`"true"` / `"false"`). Meta bundles facets + timeline buckets; handler only asks for it when the request isn't paginated (no `cursor` / `after`), so cheap "first page, full facets" is the default call shape. - `GET /executions/:id` — single execution detail + `pendingInteraction`. 404 on unknown id via `ExecutionNotFoundError` (already declared on the group). - `GET /executions/:id/tool-calls` — tool-call timeline. 404 on unknown execution (guard rail so empty arrays don't mask typos). **Response shape:** every `Date` is serialized to epoch ms at the handler edge (`.getTime()`) so the wire format stays numeric. The schemas in `api.ts` mirror the SDK's row projections one-to-one modulo that transform. **CSV + enum handling:** `splitCsv`, `parseSortParam`, `parseElicitationParam` live in the handler file because they're edge concerns — the SDK takes typed arrays and enums. Invalid sort fields / directions drop back to defaults (no 400). No new tests — the handlers are thin wrappers over the SDK store, which already has round-trip + filter + meta coverage in `packages/core/sdk/src/executions.test.ts`. The CSV/enum parsers are small enough to validate by inspection. --- packages/core/api/src/executions/api.ts | 145 +++++++++++++++++++ packages/core/api/src/handlers/executions.ts | 143 +++++++++++++++++- 2 files changed, 287 insertions(+), 1 deletion(-) diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts index d92332435..2bc9f654f 100644 --- a/packages/core/api/src/executions/api.ts +++ b/packages/core/api/src/executions/api.ts @@ -21,6 +21,134 @@ const ExecuteHeaders = Schema.Struct({ "x-executor-trigger": Schema.optional(Schema.String), }); +const ExecutionStatusLiteral = Schema.Literal( + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +); + +const ExecutionRecord = Schema.Struct({ + id: Schema.String, + scopeId: Schema.String, + status: ExecutionStatusLiteral, + code: Schema.String, + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + logsJson: Schema.NullOr(Schema.String), + startedAt: Schema.NullOr(Schema.Number), + completedAt: Schema.NullOr(Schema.Number), + triggerKind: Schema.NullOr(Schema.String), + triggerMetaJson: Schema.NullOr(Schema.String), + toolCallCount: Schema.Number, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +const ExecutionInteractionRecord = Schema.Struct({ + id: Schema.String, + executionId: Schema.String, + status: Schema.Literal("pending", "resolved", "cancelled"), + kind: Schema.String, + purpose: Schema.NullOr(Schema.String), + payloadJson: Schema.NullOr(Schema.String), + responseJson: Schema.NullOr(Schema.String), + responsePrivateJson: Schema.NullOr(Schema.String), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +const ExecutionToolCallRecord = Schema.Struct({ + id: Schema.String, + executionId: Schema.String, + status: Schema.Literal("running", "completed", "failed"), + toolPath: Schema.String, + namespace: Schema.NullOr(Schema.String), + argsJson: Schema.NullOr(Schema.String), + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + startedAt: Schema.Number, + completedAt: Schema.NullOr(Schema.Number), + durationMs: Schema.NullOr(Schema.Number), +}); + +const ExecutionListItemResponse = Schema.Struct({ + execution: ExecutionRecord, + pendingInteraction: Schema.NullOr(ExecutionInteractionRecord), +}); + +const ExecutionStatusCount = Schema.Struct({ + status: ExecutionStatusLiteral, + count: Schema.Number, +}); +const ExecutionTriggerCount = Schema.Struct({ + triggerKind: Schema.NullOr(Schema.String), + count: Schema.Number, +}); +const ExecutionToolFacet = Schema.Struct({ + toolPath: Schema.String, + count: Schema.Number, +}); +const ExecutionChartBucket = Schema.Struct({ + bucketStart: Schema.Number, + counts: Schema.Record({ key: Schema.String, value: Schema.Number }), +}); +const ExecutionListMeta = Schema.Struct({ + totalRowCount: Schema.Number, + filterRowCount: Schema.Number, + statusCounts: Schema.Array(ExecutionStatusCount), + triggerCounts: Schema.Array(ExecutionTriggerCount), + toolFacets: Schema.Array(ExecutionToolFacet), + interactionCounts: Schema.Struct({ + withElicitation: Schema.Number, + withoutElicitation: Schema.Number, + }), + chartBucketMs: Schema.Number, + chartData: Schema.Array(ExecutionChartBucket), +}); + +const ListExecutionsResponse = Schema.Struct({ + executions: Schema.Array(ExecutionListItemResponse), + nextCursor: Schema.optional(Schema.String), + meta: Schema.optional(ExecutionListMeta), +}); + +const GetExecutionResponse = Schema.Struct({ + execution: ExecutionRecord, + pendingInteraction: Schema.NullOr(ExecutionInteractionRecord), +}); + +const ListToolCallsResponse = Schema.Struct({ + toolCalls: Schema.Array(ExecutionToolCallRecord), +}); + +/** + * Query-string filters for `GET /executions`. Every param is optional + * and arrives as a plain string so the client side doesn't need to + * know about Effect Schema. The handler normalizes CSV fields and + * validates enums. + */ +const ListExecutionsParams = Schema.Struct({ + limit: Schema.optional(Schema.NumberFromString), + cursor: Schema.optional(Schema.String), + /** CSV of ExecutionStatus. Invalid values are dropped. */ + status: Schema.optional(Schema.String), + /** CSV of trigger kinds. Use "unknown" to match rows with null. */ + trigger: Schema.optional(Schema.String), + /** CSV of tool paths / globs (`github.*`). */ + tool: Schema.optional(Schema.String), + from: Schema.optional(Schema.NumberFromString), + to: Schema.optional(Schema.NumberFromString), + after: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + /** `,` — e.g. `createdAt,desc`. */ + sort: Schema.optional(Schema.String), + /** `"true"` / `"false"` to filter to runs that did or didn't elicit. */ + elicitation: Schema.optional(Schema.String), +}); + const CompletedResult = Schema.Struct({ status: Schema.Literal("completed"), text: Schema.String, @@ -74,4 +202,21 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") .addSuccess(ResumeResponse) .addError(ExecutionNotFoundError), ) + .add( + HttpApiEndpoint.get("list")`/executions` + .setUrlParams(ListExecutionsParams) + .addSuccess(ListExecutionsResponse), + ) + .add( + HttpApiEndpoint.get("get")`/executions/${executionIdParam}` + .addSuccess(GetExecutionResponse) + .addError(ExecutionNotFoundError), + ) + .add( + HttpApiEndpoint.get( + "listToolCalls", + )`/executions/${executionIdParam}/tool-calls` + .addSuccess(ListToolCallsResponse) + .addError(ExecutionNotFoundError), + ) .addError(InternalError) {} diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index 82fc2ea39..3d9d27aa6 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -3,9 +3,51 @@ import { Effect } from "effect"; import { ExecutorApi } from "../api"; import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; -import { ExecutionEngineService } from "../services"; +import { + EXECUTION_STATUS_KEYS, + ExecutionId, + type ExecutionSort, + type ExecutionStatus, +} from "@executor/sdk"; +import { ExecutionEngineService, ExecutorService } from "../services"; import { capture, captureEngineError } from "@executor/api"; +// --------------------------------------------------------------------------- +// Query-string helpers +// --------------------------------------------------------------------------- + +const STATUS_SET = new Set(EXECUTION_STATUS_KEYS); +const SORT_FIELDS = new Set(["createdAt", "durationMs"] as const); +const SORT_DIRECTIONS = new Set(["asc", "desc"] as const); + +const splitCsv = (value: string | undefined): string[] => + value + ? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0) + : []; + +const parseSortParam = (raw: string | undefined): ExecutionSort | undefined => { + if (!raw) return undefined; + const [rawField, rawDirection] = raw.split(",").map((s) => s.trim()); + if (!rawField || !rawDirection) return undefined; + if (!SORT_FIELDS.has(rawField as (typeof SORT_FIELDS extends Set ? T : never))) + return undefined; + if (!SORT_DIRECTIONS.has(rawDirection as "asc" | "desc")) return undefined; + return { + field: rawField as ExecutionSort["field"], + direction: rawDirection as ExecutionSort["direction"], + }; +}; + +const parseElicitationParam = (raw: string | undefined): boolean | undefined => { + if (raw === "true") return true; + if (raw === "false") return false; + return undefined; +}; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => handlers .handle("execute", ({ payload, headers }) => @@ -69,5 +111,104 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" isError: false, }; })), + ) + .handle("list", ({ urlParams }) => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + // Executions are scoped to the innermost scope of the current + // executor stack — same rule the engine uses when writing the row. + const scopeId = executor.scopes[0]!.id; + + const statusFilter = splitCsv(urlParams.status).filter( + (v): v is ExecutionStatus => STATUS_SET.has(v), + ); + const triggerFilter = splitCsv(urlParams.trigger); + const toolPathFilter = splitCsv(urlParams.tool); + const includeMeta = + urlParams.cursor === undefined && urlParams.after === undefined; + + const result = yield* executor.executions.list(scopeId, { + limit: urlParams.limit, + cursor: urlParams.cursor, + statusFilter: statusFilter.length > 0 ? statusFilter : undefined, + triggerFilter: triggerFilter.length > 0 ? triggerFilter : undefined, + toolPathFilter: toolPathFilter.length > 0 ? toolPathFilter : undefined, + after: urlParams.after, + timeRange: + urlParams.from !== undefined || urlParams.to !== undefined + ? { from: urlParams.from, to: urlParams.to } + : undefined, + codeQuery: urlParams.code, + sort: parseSortParam(urlParams.sort), + hadElicitation: parseElicitationParam(urlParams.elicitation), + includeMeta, + }); + + return { + executions: result.executions.map((item) => ({ + execution: { + ...item.execution, + createdAt: item.execution.createdAt.getTime(), + updatedAt: item.execution.updatedAt.getTime(), + }, + pendingInteraction: item.pendingInteraction + ? { + ...item.pendingInteraction, + createdAt: item.pendingInteraction.createdAt.getTime(), + updatedAt: item.pendingInteraction.updatedAt.getTime(), + } + : null, + })), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result.meta ? { meta: result.meta } : {}), + }; + })), + ) + .handle("get", ({ path }) => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + const detail = yield* executor.executions.get( + ExecutionId.make(path.executionId), + ); + if (!detail) { + return yield* Effect.fail({ + _tag: "ExecutionNotFoundError" as const, + executionId: path.executionId, + }); + } + return { + execution: { + ...detail.execution, + createdAt: detail.execution.createdAt.getTime(), + updatedAt: detail.execution.updatedAt.getTime(), + }, + pendingInteraction: detail.pendingInteraction + ? { + ...detail.pendingInteraction, + createdAt: detail.pendingInteraction.createdAt.getTime(), + updatedAt: detail.pendingInteraction.updatedAt.getTime(), + } + : null, + }; + })), + ) + .handle("listToolCalls", ({ path }) => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + // Guard so missing executions 404 instead of returning `[]`. + const detail = yield* executor.executions.get( + ExecutionId.make(path.executionId), + ); + if (!detail) { + return yield* Effect.fail({ + _tag: "ExecutionNotFoundError" as const, + executionId: path.executionId, + }); + } + const toolCalls = yield* executor.executions.listToolCalls( + ExecutionId.make(path.executionId), + ); + return { toolCalls }; + })), ), ); From 0a0495005e05cc80cd29559f326f722abd0ca9a6 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 24 Apr 2026 22:54:36 +0530 Subject: [PATCH 6/6] =?UTF-8?q?feat(react):=20runs=20page=20=E2=80=94=20li?= =?UTF-8?q?st,=20filters,=20timeline,=20detail=20drawer,=20live=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the full \`/runs\` observability UI on top of the \`/executions\` HTTP API shipped in #401. Route wired in both \`apps/cloud\` and \`apps/local\`; sidebar gets a "Runs" entry. React package: - \`api/executions.tsx\` — client hitting \`GET /executions\`, \`GET /executions/:id\`, \`GET /executions/:id/tool-calls\`. Flattens the server's nested \`{ execution, pendingInteraction }\` into a single row type so components can read \`row.id\` / \`row.createdAt\` directly. Wire types are kept as epoch-ms numbers (server serializes \`Date\` at the edge) so the UI never has to know about Schema-decoded Dates. - \`pages/runs.tsx\` — infinite-scrolling list, filter rail, timeline chart, detail drawer, keyboard-driven navigation (\`j\` live, \`r\` refresh, \`/\` filter command, \`?\` help, \`↑/↓\` row nav, \`b\` collapse rail). URL params carry the filter state so runs links are shareable. - \`components/runs/*\` — 16 components covering the shell, rows, drawer, facet rail, CLI-style filter command + parser, timeline chart with drag-to-zoom, live-mode controls, and the keyboard-help overlay. - \`hooks/use-live-mode.ts\` — captures the cutoff timestamp at activation so new rows are rendered above a divider without the list jumping. \`useEffectEvent\` isolates live-toggle from stale closures. - \`hooks/use-local-storage.ts\` — SSR-safe persistence helper, used for field visibility preferences. - \`api/provider.tsx\` — wraps \`RegistryProvider\` in a shared \`QueryClientProvider\` (React Query for the list + detail fetches). - Deps added: \`@tanstack/react-query\`, \`date-fns\`, \`@date-fns/utc\`, \`react-hotkeys-hook\`. - \`styles/globals.css\` — \`--color-success|warning|error|info\` tokens used by the status dots. Apps: - \`routes/runs.tsx\` (cloud + local) — file route bound to \`\` with Effect-Schema validation on each query param. - \`routeTree.gen.ts\` (cloud + local) — regenerated to register \`/runs\`. The tanstack-router vite plugin will overwrite on next dev start; kept manual for now so \`bun x tsc --noEmit\` passes in CI. - \`web/shell.tsx\` (cloud + local) — sidebar NavItem for "Runs". ## Test plan - [x] \`bun x tsc --noEmit\` in \`@executor/react\`, \`apps/local\`, \`apps/cloud\` — all clean. - [x] No new runtime tests (the list/detail flow goes through the API layer which the SDK's executions.test.ts exercises end-to-end via the in-memory adapter). - [ ] Dev-server smoke: start the daemon, visit \`/runs\`, exercise filters + live mode — to be verified by reviewer or in CI once the full stack is merged. --- apps/cloud/src/routeTree.gen.ts | 21 + apps/cloud/src/routes/runs.tsx | 24 + apps/cloud/src/web/shell.tsx | 2 + apps/local/src/routeTree.gen.ts | 21 + apps/local/src/routes/runs.tsx | 24 + apps/local/src/web/shell.tsx | 2 + bun.lock | 12 +- packages/react/package.json | 4 + packages/react/src/api/executions.tsx | 220 +++++++ packages/react/src/api/provider.tsx | 18 +- .../src/components/runs/column-header.tsx | 100 +++ .../src/components/runs/detail-drawer.tsx | 608 ++++++++++++++++++ .../components/runs/filter-command-parser.ts | 172 +++++ .../src/components/runs/filter-command.tsx | 259 ++++++++ .../react/src/components/runs/filter-rail.tsx | 382 +++++++++++ .../components/runs/hover-card-timestamp.tsx | 97 +++ .../src/components/runs/keyboard-help.tsx | 63 ++ .../react/src/components/runs/live-button.tsx | 28 + .../react/src/components/runs/live-row.tsx | 14 + .../src/components/runs/refresh-button.tsx | 29 + packages/react/src/components/runs/row.tsx | 172 +++++ packages/react/src/components/runs/shell.tsx | 162 +++++ packages/react/src/components/runs/status.ts | 108 ++++ .../src/components/runs/timeline-chart.tsx | 200 ++++++ .../components/runs/view-options-button.tsx | 96 +++ packages/react/src/hooks/use-live-mode.ts | 42 ++ packages/react/src/hooks/use-local-storage.ts | 53 ++ packages/react/src/pages/runs.tsx | 497 ++++++++++++++ packages/react/src/styles/globals.css | 12 + 29 files changed, 3438 insertions(+), 4 deletions(-) create mode 100644 apps/cloud/src/routes/runs.tsx create mode 100644 apps/local/src/routes/runs.tsx create mode 100644 packages/react/src/api/executions.tsx create mode 100644 packages/react/src/components/runs/column-header.tsx create mode 100644 packages/react/src/components/runs/detail-drawer.tsx create mode 100644 packages/react/src/components/runs/filter-command-parser.ts create mode 100644 packages/react/src/components/runs/filter-command.tsx create mode 100644 packages/react/src/components/runs/filter-rail.tsx create mode 100644 packages/react/src/components/runs/hover-card-timestamp.tsx create mode 100644 packages/react/src/components/runs/keyboard-help.tsx create mode 100644 packages/react/src/components/runs/live-button.tsx create mode 100644 packages/react/src/components/runs/live-row.tsx create mode 100644 packages/react/src/components/runs/refresh-button.tsx create mode 100644 packages/react/src/components/runs/row.tsx create mode 100644 packages/react/src/components/runs/shell.tsx create mode 100644 packages/react/src/components/runs/status.ts create mode 100644 packages/react/src/components/runs/timeline-chart.tsx create mode 100644 packages/react/src/components/runs/view-options-button.tsx create mode 100644 packages/react/src/hooks/use-live-mode.ts create mode 100644 packages/react/src/hooks/use-local-storage.ts create mode 100644 packages/react/src/pages/runs.tsx diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 46104e23b..60dd41900 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as RunsRouteImport } from './routes/runs' import { Route as OrgRouteImport } from './routes/org' import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as BillingRouteImport } from './routes/billing' @@ -29,6 +30,11 @@ const SecretsRoute = SecretsRouteImport.update({ path: '/secrets', getParentRoute: () => rootRouteImport, } as any) +const RunsRoute = RunsRouteImport.update({ + id: '/runs', + path: '/runs', + getParentRoute: () => rootRouteImport, +} as any) const OrgRoute = OrgRouteImport.update({ id: '/org', path: '/org', @@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { '/billing': typeof BillingRoute '/connections': typeof ConnectionsRoute '/org': typeof OrgRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/billing/plans': typeof BillingPlansRoute @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/billing': typeof BillingRoute '/connections': typeof ConnectionsRoute '/org': typeof OrgRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/billing/plans': typeof BillingPlansRoute @@ -93,6 +101,7 @@ export interface FileRoutesById { '/billing': typeof BillingRoute '/connections': typeof ConnectionsRoute '/org': typeof OrgRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/billing_/plans': typeof BillingPlansRoute @@ -106,6 +115,7 @@ export interface FileRouteTypes { | '/billing' | '/connections' | '/org' + | '/runs' | '/secrets' | '/tools' | '/billing/plans' @@ -117,6 +127,7 @@ export interface FileRouteTypes { | '/billing' | '/connections' | '/org' + | '/runs' | '/secrets' | '/tools' | '/billing/plans' @@ -128,6 +139,7 @@ export interface FileRouteTypes { | '/billing' | '/connections' | '/org' + | '/runs' | '/secrets' | '/tools' | '/billing_/plans' @@ -140,6 +152,7 @@ export interface RootRouteChildren { BillingRoute: typeof BillingRoute ConnectionsRoute: typeof ConnectionsRoute OrgRoute: typeof OrgRoute + RunsRoute: typeof RunsRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute BillingPlansRoute: typeof BillingPlansRoute @@ -163,6 +176,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecretsRouteImport parentRoute: typeof rootRouteImport } + '/runs': { + id: '/runs' + path: '/runs' + fullPath: '/runs' + preLoaderRoute: typeof RunsRouteImport + parentRoute: typeof rootRouteImport + } '/org': { id: '/org' path: '/org' @@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { BillingRoute: BillingRoute, ConnectionsRoute: ConnectionsRoute, OrgRoute: OrgRoute, + RunsRoute: RunsRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, BillingPlansRoute: BillingPlansRoute, diff --git a/apps/cloud/src/routes/runs.tsx b/apps/cloud/src/routes/runs.tsx new file mode 100644 index 000000000..175234c0e --- /dev/null +++ b/apps/cloud/src/routes/runs.tsx @@ -0,0 +1,24 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { RunsPage, type RunsSearch } from "@executor/react/pages/runs"; + +const RunsSearchSchema = Schema.standardSchemaV1( + Schema.Struct({ + executionId: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + trigger: Schema.optional(Schema.String), + tool: Schema.optional(Schema.String), + range: Schema.optional(Schema.String), + from: Schema.optional(Schema.String), + to: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + live: Schema.optional(Schema.String), + sort: Schema.optional(Schema.String), + elicitation: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/runs")({ + validateSearch: RunsSearchSchema, + component: () => , +}); diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 4aa725027..9d685ede3 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -360,6 +360,7 @@ function UserFooter() { function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; + const isRuns = props.pathname === "/runs"; const isConnections = props.pathname === "/connections"; const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); const isOrg = props.pathname === "/org"; @@ -378,6 +379,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show + diff --git a/apps/local/src/routeTree.gen.ts b/apps/local/src/routeTree.gen.ts index b068e7fb3..e98f21d74 100644 --- a/apps/local/src/routeTree.gen.ts +++ b/apps/local/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as RunsRouteImport } from './routes/runs' import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as IndexRouteImport } from './routes/index' import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' @@ -21,6 +22,11 @@ const ToolsRoute = ToolsRouteImport.update({ path: '/tools', getParentRoute: () => rootRouteImport, } as any) +const RunsRoute = RunsRouteImport.update({ + id: '/runs', + path: '/runs', + getParentRoute: () => rootRouteImport, +} as any) const SecretsRoute = SecretsRouteImport.update({ id: '/secrets', path: '/secrets', @@ -50,6 +56,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/connections': typeof ConnectionsRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/sources/$namespace': typeof SourcesNamespaceRoute @@ -58,6 +65,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/connections': typeof ConnectionsRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/sources/$namespace': typeof SourcesNamespaceRoute @@ -67,6 +75,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/connections': typeof ConnectionsRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute '/sources/$namespace': typeof SourcesNamespaceRoute @@ -77,6 +86,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/connections' + | '/runs' | '/secrets' | '/tools' | '/sources/$namespace' @@ -85,6 +95,7 @@ export interface FileRouteTypes { to: | '/' | '/connections' + | '/runs' | '/secrets' | '/tools' | '/sources/$namespace' @@ -93,6 +104,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/connections' + | '/runs' | '/secrets' | '/tools' | '/sources/$namespace' @@ -102,6 +114,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConnectionsRoute: typeof ConnectionsRoute + RunsRoute: typeof RunsRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute SourcesNamespaceRoute: typeof SourcesNamespaceRoute @@ -124,6 +137,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SecretsRouteImport parentRoute: typeof rootRouteImport } + '/runs': { + id: '/runs' + path: '/runs' + fullPath: '/runs' + preLoaderRoute: typeof RunsRouteImport + parentRoute: typeof rootRouteImport + } '/connections': { id: '/connections' path: '/connections' @@ -158,6 +178,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConnectionsRoute: ConnectionsRoute, + RunsRoute: RunsRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, diff --git a/apps/local/src/routes/runs.tsx b/apps/local/src/routes/runs.tsx new file mode 100644 index 000000000..175234c0e --- /dev/null +++ b/apps/local/src/routes/runs.tsx @@ -0,0 +1,24 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { RunsPage, type RunsSearch } from "@executor/react/pages/runs"; + +const RunsSearchSchema = Schema.standardSchemaV1( + Schema.Struct({ + executionId: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + trigger: Schema.optional(Schema.String), + tool: Schema.optional(Schema.String), + range: Schema.optional(Schema.String), + from: Schema.optional(Schema.String), + to: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + live: Schema.optional(Schema.String), + sort: Schema.optional(Schema.String), + elicitation: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/runs")({ + validateSearch: RunsSearchSchema, + component: () => , +}); diff --git a/apps/local/src/web/shell.tsx b/apps/local/src/web/shell.tsx index 879210adb..b1baab69c 100644 --- a/apps/local/src/web/shell.tsx +++ b/apps/local/src/web/shell.tsx @@ -305,6 +305,7 @@ function SidebarContent(props: { }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; + const isRuns = props.pathname === "/runs"; const isConnections = props.pathname === "/connections"; return ( @@ -322,6 +323,7 @@ function SidebarContent(props: { + {/* Sources list */}
diff --git a/bun.lock b/bun.lock index 89db25bd4..35237c272 100644 --- a/bun.lock +++ b/bun.lock @@ -766,6 +766,7 @@ "version": "1.4.3", "dependencies": { "@base-ui/react": "^1.3.0", + "@date-fns/utc": "^2.1.0", "@effect-atom/atom": "^0.5.0", "@effect-atom/atom-react": "^0.5.0", "@effect/platform": "catalog:", @@ -775,10 +776,12 @@ "@lobehub/icons": "^5.4.0", "@shikijs/langs": "^4.0.2", "@shikijs/themes": "^4.0.2", + "@tanstack/react-query": "^5.62.12", "@tanstack/react-router": "catalog:", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^3.6.0", "effect": "catalog:", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", @@ -787,6 +790,7 @@ "react": "catalog:", "react-day-picker": "^9.14.0", "react-hook-form": "^7.72.0", + "react-hotkeys-hook": "^5.2.4", "react-resizable-panels": "^4", "recharts": "3.8.0", "shiki": "^4.0.2", @@ -1061,6 +1065,8 @@ "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -2819,7 +2825,7 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -4753,6 +4759,8 @@ "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@base-ui/react/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -5245,6 +5253,8 @@ "rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="], + "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], diff --git a/packages/react/package.json b/packages/react/package.json index 557aa93cd..5dc591e76 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@date-fns/utc": "^2.1.0", "@effect-atom/atom": "^0.5.0", "@effect-atom/atom-react": "^0.5.0", "@effect/platform": "catalog:", @@ -27,10 +28,12 @@ "@lobehub/icons": "^5.4.0", "@shikijs/langs": "^4.0.2", "@shikijs/themes": "^4.0.2", + "@tanstack/react-query": "^5.62.12", "@tanstack/react-router": "catalog:", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^3.6.0", "effect": "catalog:", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", @@ -39,6 +42,7 @@ "react": "catalog:", "react-day-picker": "^9.14.0", "react-hook-form": "^7.72.0", + "react-hotkeys-hook": "^5.2.4", "react-resizable-panels": "^4", "recharts": "3.8.0", "shiki": "^4.0.2", diff --git a/packages/react/src/api/executions.tsx b/packages/react/src/api/executions.tsx new file mode 100644 index 000000000..008dc0910 --- /dev/null +++ b/packages/react/src/api/executions.tsx @@ -0,0 +1,220 @@ +import { endOfDay, parseISO, startOfDay } from "date-fns"; + +import { getBaseUrl } from "./base-url"; + +// --------------------------------------------------------------------------- +// Wire-format row projections. The server returns epoch-ms numbers for +// every timestamp (handlers stringify/unwrap Effect Schema `Date`s at +// the edge), so the UI works with plain numbers throughout instead of +// reusing the SDK's Schema classes that decode to `Date`. +// --------------------------------------------------------------------------- + +export type ExecutionStatus = + | "pending" + | "running" + | "waiting_for_interaction" + | "completed" + | "failed" + | "cancelled"; + +export type Execution = { + readonly id: string; + readonly scopeId: string; + readonly status: ExecutionStatus; + readonly code: string; + readonly resultJson: string | null; + readonly errorText: string | null; + readonly logsJson: string | null; + readonly startedAt: number | null; + readonly completedAt: number | null; + readonly triggerKind: string | null; + readonly triggerMetaJson: string | null; + readonly toolCallCount: number; + readonly createdAt: number; + readonly updatedAt: number; +}; + +export type ExecutionInteraction = { + readonly id: string; + readonly executionId: string; + readonly status: "pending" | "resolved" | "cancelled"; + readonly kind: string; + readonly purpose: string | null; + readonly payloadJson: string | null; + readonly responseJson: string | null; + readonly responsePrivateJson: string | null; + readonly createdAt: number; + readonly updatedAt: number; +}; + +export type ExecutionToolCall = { + readonly id: string; + readonly executionId: string; + readonly status: "running" | "completed" | "failed"; + readonly toolPath: string; + readonly namespace: string | null; + readonly argsJson: string | null; + readonly resultJson: string | null; + readonly errorText: string | null; + readonly startedAt: number; + readonly completedAt: number | null; + readonly durationMs: number | null; +}; + +export type ExecutionChartBucket = { + readonly bucketStart: number; + readonly counts: Readonly>; +}; + +export type ExecutionListMeta = { + readonly totalRowCount: number; + readonly filterRowCount: number; + readonly statusCounts: ReadonlyArray<{ + readonly status: ExecutionStatus; + readonly count: number; + }>; + readonly triggerCounts: ReadonlyArray<{ + readonly triggerKind: string | null; + readonly count: number; + }>; + readonly toolFacets: ReadonlyArray<{ + readonly toolPath: string; + readonly count: number; + }>; + readonly interactionCounts: { + readonly withElicitation: number; + readonly withoutElicitation: number; + }; + readonly chartBucketMs: number; + readonly chartData: ReadonlyArray; +}; + +/** + * Flat list item shape consumed by the runs UI. The server returns + * `{ execution, pendingInteraction }` nested; we flatten here so every + * component can read `row.id` / `row.createdAt` / `row.pendingInteraction` + * without going through `.execution`. + */ +export type ExecutionListItem = Execution & { + readonly pendingInteraction: ExecutionInteraction | null; +}; + +export type ListExecutionsResponse = { + readonly executions: readonly ExecutionListItem[]; + readonly nextCursor?: string; + readonly meta?: ExecutionListMeta; +}; + +export type GetExecutionResponse = { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +}; + +export type ListToolCallsResponse = { + readonly toolCalls: readonly ExecutionToolCall[]; +}; + +type ServerListItem = { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +}; + +type ServerListResponse = { + readonly executions: readonly ServerListItem[]; + readonly nextCursor?: string; + readonly meta?: ExecutionListMeta; +}; + +export type RunsQueryInput = { + readonly limit: number; + readonly cursor?: string; + readonly status?: string; + readonly trigger?: string; + readonly tool?: string; + readonly from?: string; + readonly to?: string; + /** Live-mode floor: epoch-ms. Rows strictly newer than this. */ + readonly after?: string; + readonly code?: string; + /** Sort expression `","` e.g. `"createdAt,desc"`. */ + readonly sort?: string; + /** + * Interactions filter: `"true"` → only runs that recorded an + * elicitation, `"false"` → only runs that didn't, omitted → no + * filter. Maps to `hadElicitation` on the server side. + */ + readonly elicitation?: string; +}; + +const toEpochRange = (date: string | undefined, mode: "start" | "end"): number | undefined => { + if (!date) return undefined; + + try { + const parsed = parseISO(date); + return mode === "start" ? startOfDay(parsed).getTime() : endOfDay(parsed).getTime(); + } catch { + return undefined; + } +}; + +const readJson = async (response: Response): Promise => { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(body || `Request failed with status ${response.status}`); + } + + return (await response.json()) as T; +}; + +export const listExecutions = async (input: RunsQueryInput): Promise => { + const params = new URLSearchParams(); + params.set("limit", String(input.limit)); + + if (input.cursor) params.set("cursor", input.cursor); + if (input.status) params.set("status", input.status); + if (input.trigger) params.set("trigger", input.trigger); + if (input.tool) params.set("tool", input.tool); + if (input.after) params.set("after", input.after); + if (input.sort) params.set("sort", input.sort); + if (input.elicitation) params.set("elicitation", input.elicitation); + + const from = toEpochRange(input.from, "start"); + const to = toEpochRange(input.to, "end"); + if (from !== undefined) params.set("from", String(from)); + if (to !== undefined) params.set("to", String(to)); + if (input.code?.trim()) params.set("code", input.code.trim()); + + const response = await fetch(`${getBaseUrl()}/executions?${params.toString()}`, { + credentials: "include", + }); + + const payload = await readJson(response); + return { + executions: payload.executions.map( + (item): ExecutionListItem => ({ + ...item.execution, + pendingInteraction: item.pendingInteraction, + }), + ), + ...(payload.nextCursor ? { nextCursor: payload.nextCursor } : {}), + ...(payload.meta ? { meta: payload.meta } : {}), + }; +}; + +export const getExecution = async (executionId: string): Promise => { + const response = await fetch(`${getBaseUrl()}/executions/${executionId}`, { + credentials: "include", + }); + + return readJson(response); +}; + +export const listExecutionToolCalls = async ( + executionId: string, +): Promise => { + const response = await fetch(`${getBaseUrl()}/executions/${executionId}/tool-calls`, { + credentials: "include", + }); + + return readJson(response); +}; diff --git a/packages/react/src/api/provider.tsx b/packages/react/src/api/provider.tsx index 968c2608e..52bb940ae 100644 --- a/packages/react/src/api/provider.tsx +++ b/packages/react/src/api/provider.tsx @@ -1,11 +1,23 @@ import { RegistryProvider } from "@effect-atom/atom-react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { ScopeProvider } from "./scope-context"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: true, + }, + }, +}); + export const ExecutorProvider = ( props: React.PropsWithChildren<{ fallback?: React.ReactNode }>, ) => ( - - {props.children} - + + + {props.children} + + ); diff --git a/packages/react/src/components/runs/column-header.tsx b/packages/react/src/components/runs/column-header.tsx new file mode 100644 index 000000000..4168d727b --- /dev/null +++ b/packages/react/src/components/runs/column-header.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; + +import { cn } from "../../lib/utils"; + +export type SortField = "createdAt" | "durationMs"; +export type SortDirection = "asc" | "desc"; +export type SortState = { + readonly field: SortField; + readonly direction: SortDirection; +} | null; + +export interface RunsColumnHeaderProps { + readonly sort: SortState; + readonly onSort: (field: SortField) => void; + readonly visibleFields?: { + readonly via?: boolean; + readonly tools?: boolean; + readonly log?: boolean; + readonly duration_ms?: boolean; + }; +} + +export function RunsColumnHeader({ sort, onSort, visibleFields }: RunsColumnHeaderProps) { + const showVia = visibleFields?.via !== false; + const showTools = visibleFields?.tools !== false; + const showLog = visibleFields?.log !== false; + const showDuration = visibleFields?.duration_ms !== false; + + return ( +
+ {/* dot column (spacer to match row layout) */} + + + + + status + + {showVia ? via : null} + + {showTools ? tools : null} + + {showLog ? log : null} + + {showDuration ? ( + + ) : null} + + code +
+ ); +} + +function SortHeader({ + label, + field, + currentSort, + onSort, + className, +}: { + readonly label: string; + readonly field: SortField; + readonly currentSort: SortState; + readonly onSort: (field: SortField) => void; + readonly className?: string; +}) { + const isActive = currentSort?.field === field; + const direction = isActive ? currentSort.direction : null; + const Icon = direction === "desc" ? ArrowDown : direction === "asc" ? ArrowUp : ArrowUpDown; + + return ( + // oxlint-disable-next-line react/forbid-elements -- column headers are dense table-level affordances; + ); +} diff --git a/packages/react/src/components/runs/detail-drawer.tsx b/packages/react/src/components/runs/detail-drawer.tsx new file mode 100644 index 000000000..cb875d078 --- /dev/null +++ b/packages/react/src/components/runs/detail-drawer.tsx @@ -0,0 +1,608 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHotkeys } from "react-hotkeys-hook"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import type { Execution, ExecutionInteraction, ExecutionToolCall } from "../../api/executions"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; +import { CodeBlock } from "../code-block"; +import { HoverCardTimestamp } from "./hover-card-timestamp"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet"; +import { + getExecution, + listExecutionToolCalls, + type GetExecutionResponse, +} from "../../api/executions"; +import { STATUS_LABELS, statusTone, triggerTone } from "./status"; + +type DetailTab = "properties" | "logs" | "toolCalls"; + +const formatDuration = (execution: Execution): string => { + if (execution.startedAt === null || execution.completedAt === null) return "—"; + const ms = Math.max(0, execution.completedAt - execution.startedAt); + if (ms < 1_000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`; + return `${(ms / 60_000).toFixed(1)}m`; +}; + +/** Recursively parse up to 4 layers of JSON-in-JSON — QuickJS double-serializes tool results. */ +const unwrapJson = ( + raw: string | null, +): { readonly formatted: string | null; readonly lang: "json" | "text" } => { + if (raw === null) return { formatted: null, lang: "json" }; + + let value: unknown = raw; + for (let i = 0; i < 4; i += 1) { + if (typeof value !== "string") break; + try { + value = JSON.parse(value); + } catch { + break; + } + } + + if (typeof value === "string") { + return { formatted: value, lang: "text" }; + } + try { + return { formatted: JSON.stringify(value, null, 2), lang: "json" }; + } catch { + return { formatted: String(value), lang: "text" }; + } +}; + +export interface RunsDetailDrawerProps { + readonly executionId?: string; + readonly onOpenChange: (open: boolean) => void; + /** Id of the previous row in the current filter set, or undefined if none. */ + readonly prevRowId?: string; + /** Id of the next row in the current filter set, or undefined if none. */ + readonly nextRowId?: string; + readonly onPrev?: () => void; + readonly onNext?: () => void; +} + +export function RunsDetailDrawer({ + executionId, + onOpenChange, + prevRowId, + nextRowId, + onPrev, + onNext, +}: RunsDetailDrawerProps) { + const open = Boolean(executionId); + const query = useQuery({ + queryKey: ["execution", executionId], + queryFn: () => getExecution(executionId!), + enabled: open, + staleTime: 10_000, + }); + + useHotkeys("ArrowUp", () => onPrev?.(), { enabled: open && !!prevRowId, preventDefault: true }, [ + open, + prevRowId, + onPrev, + ]); + useHotkeys( + "ArrowDown", + () => onNext?.(), + { enabled: open && !!nextRowId, preventDefault: true }, + [open, nextRowId, onNext], + ); + + return ( + + + onOpenChange(false)} + prevRowId={prevRowId} + nextRowId={nextRowId} + onPrev={onPrev} + onNext={onNext} + /> + + + ); +} + +function DrawerBody({ + executionId, + query, + onClose, + prevRowId, + nextRowId, + onPrev, + onNext, +}: { + readonly executionId?: string; + readonly query: ReturnType>; + readonly onClose: () => void; + readonly prevRowId?: string; + readonly nextRowId?: string; + readonly onPrev?: () => void; + readonly onNext?: () => void; +}) { + const [tab, setTab] = React.useState("properties"); + const [copied, setCopied] = React.useState(false); + + const envelope = query.data; + + const handleCopyJson = React.useCallback(() => { + if (!envelope) return; + const tryParse = (value: string | null): unknown => { + if (value === null) return null; + try { + return JSON.parse(value); + } catch { + return value; + } + }; + + const cleaned = { + ...envelope, + execution: { + ...envelope.execution, + resultJson: tryParse(envelope.execution.resultJson), + logsJson: tryParse(envelope.execution.logsJson), + }, + }; + void navigator.clipboard.writeText(JSON.stringify(cleaned, null, 2)).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + }, [envelope]); + + return ( +
+ + Execution details + {executionId ?? "No execution selected"} + + +
+
+
{executionId ?? "—"}
+
+
+ + +
+ + +
+
+ +
+ setTab("properties")} + /> + 0 + ? `Tool calls · ${envelope.execution.toolCallCount}` + : "Tool calls" + } + active={tab === "toolCalls"} + onClick={() => setTab("toolCalls")} + /> + setTab("logs")} /> +
+ +
+ {query.isLoading ? ( +

Loading execution…

+ ) : query.isError ? ( +

Failed to load execution details.

+ ) : envelope ? ( + tab === "properties" ? ( + + ) : tab === "toolCalls" ? ( + + ) : ( + + ) + ) : ( +

Execution not found.

+ )} +
+
+ ); +} + +function TabButton(props: { label: string; active: boolean; onClick: () => void }) { + return ( + // oxlint-disable-next-line react/forbid-elements -- tab buttons carry a tab-shaped bottom border that + ); +} + +function PropertiesTab({ envelope }: { envelope: GetExecutionResponse }) { + const { execution, pendingInteraction } = envelope; + const tone = statusTone(execution.status); + const trigger = triggerTone(execution.triggerKind); + const result = unwrapJson(execution.resultJson); + + return ( +
+
+ + + + {STATUS_LABELS[execution.status]} + + + + {formatDuration(execution)} + +
+ +
+
+ Created + {execution.createdAt ? ( + + ) : ( + + )} +
+
+ Started + {execution.startedAt ? ( + + ) : ( + + )} +
+
+ +
+ + + via {trigger.label} + + + tools {execution.toolCallCount} +
+ + + + {result.formatted ? ( + + ) : ( + + )} + + {execution.errorText ? ( +
+
+ Error +
+
+            {execution.errorText}
+          
+
+ ) : null} + + {pendingInteraction ? : null} +
+ ); +} + +function MetaCard(props: { label: string; children: React.ReactNode }) { + return ( +
+
+ {props.label} +
+
{props.children}
+
+ ); +} + +function EmptyPanel(props: { title: string; message: string }) { + return ( +
+
+ {props.title} +
+
+ {props.message} +
+
+ ); +} + +function PendingInteractionBlock({ interaction }: { interaction: ExecutionInteraction }) { + const request = unwrapJson(interaction.payloadJson); + const response = unwrapJson(interaction.responseJson); + + return ( +
+
+
+
Pending interaction
+
+ {interaction.kind} — {interaction.purpose} +
+
+ + {interaction.status} + +
+ +
+ {request.formatted ? ( + + ) : ( + + )} + {response.formatted ? ( + + ) : ( + + )} +
+
+ ); +} + +function LogsTab({ logsJson }: { logsJson: string | null }) { + const lines = React.useMemo(() => { + if (logsJson === null) return null; + try { + const parsed = JSON.parse(logsJson); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + return null; + } + return null; + }, [logsJson]); + + if (!lines) { + const fallback = unwrapJson(logsJson); + if (!fallback.formatted) { + return ( +
+ No logs recorded. +
+ ); + } + return ; + } + + if (lines.length === 0) { + return ( +
+ No logs recorded. +
+ ); + } + + return ( +
+
+ Logs +
+
+ {lines.map((line, index) => { + const isError = /\[error\]/i.test(line); + const isWarn = /\[warn\]/i.test(line); + return ( +
+ {line} +
+ ); + })} +
+
+ ); +} + +function ToolCallsTab({ execution }: { execution: Execution }) { + const query = useQuery({ + queryKey: ["execution", execution.id, "tool-calls"], + queryFn: () => listExecutionToolCalls(execution.id), + staleTime: 10_000, + }); + + if (query.isLoading) { + return

Loading tool calls…

; + } + if (query.isError) { + return

Failed to load tool calls.

; + } + + const calls = query.data?.toolCalls ?? []; + if (calls.length === 0) { + return ( +
+ No tool calls recorded. +
+ ); + } + + // Derive a time scale for the flame-graph bars. + const windowStart = execution.startedAt ?? calls[0]!.startedAt; + const windowEnd = + execution.completedAt ?? + Math.max(...calls.map((call) => call.completedAt ?? call.startedAt + (call.durationMs ?? 0))); + const windowWidth = Math.max(1, windowEnd - windowStart); + + return ( +
+ {calls.map((call) => ( + + ))} +
+ ); +} + +function ToolCallRow({ + call, + windowStart, + windowWidth, +}: { + readonly call: ExecutionToolCall; + readonly windowStart: number; + readonly windowWidth: number; +}) { + const [expanded, setExpanded] = React.useState(false); + + const offsetMs = Math.max(0, call.startedAt - windowStart); + const durationMs = call.durationMs ?? Math.max(0, Date.now() - call.startedAt); + const offsetPct = (offsetMs / windowWidth) * 100; + const widthPct = Math.max(0.75, (durationMs / windowWidth) * 100); + + const args = unwrapJson(call.argsJson); + const result = unwrapJson(call.resultJson); + + const statusColor = + call.status === "failed" + ? "bg-destructive" + : call.status === "running" + ? "bg-blue-400 animate-pulse" + : "bg-primary"; + + return ( +
+ {/* oxlint-disable-next-line react/forbid-elements -- full-width expandable card header; + + {expanded ? ( +
+ {args.formatted ? ( + + ) : ( + + )} + {call.status === "failed" && call.errorText ? ( +
+
+ Error +
+
+                {call.errorText}
+              
+
+ ) : result.formatted ? ( + + ) : ( + + )} +
+ ) : null} +
+ ); +} diff --git a/packages/react/src/components/runs/filter-command-parser.ts b/packages/react/src/components/runs/filter-command-parser.ts new file mode 100644 index 000000000..0366cf9bb --- /dev/null +++ b/packages/react/src/components/runs/filter-command-parser.ts @@ -0,0 +1,172 @@ +export interface RunsFilterTokens { + readonly status: readonly string[]; + readonly trigger: readonly string[]; + readonly tool: readonly string[]; + readonly code: string | null; + readonly durationMsMin: number | null; + readonly durationMsMax: number | null; + readonly from: number | null; + readonly to: number | null; +} + +export const emptyFilterTokens = (): RunsFilterTokens => ({ + status: [], + trigger: [], + tool: [], + code: null, + durationMsMin: null, + durationMsMax: null, + from: null, + to: null, +}); + +const RELATIVE_DURATIONS: Record = { + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +}; + +const parseRelativeMs = (literal: string): number | null => { + const match = /^(\d+)([mhdw])$/.exec(literal); + if (!match) return null; + const [, amount, unit] = match; + const base = RELATIVE_DURATIONS[unit!]; + if (!base) return null; + return Number(amount) * base; +}; + +const parseTimestamp = (value: string): number | null => { + const relative = parseRelativeMs(value); + if (relative !== null) return Date.now() - relative; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +export const parseFilterCommand = (input: string): RunsFilterTokens => { + const result: { + -readonly [K in keyof RunsFilterTokens]: RunsFilterTokens[K] extends readonly (infer E)[] + ? E[] + : RunsFilterTokens[K]; + } = { + status: [], + trigger: [], + tool: [], + code: null, + durationMsMin: null, + durationMsMax: null, + from: null, + to: null, + }; + + const parts = input.trim().split(/\s+/).filter(Boolean); + + for (const part of parts) { + const colon = part.indexOf(":"); + if (colon === -1) continue; + const key = part.slice(0, colon); + const value = part.slice(colon + 1); + if (value.length === 0) continue; + + switch (key) { + case "status": { + result.status.push(...value.split(",").filter(Boolean)); + break; + } + case "trigger": { + result.trigger.push(...value.split(",").filter(Boolean)); + break; + } + case "tool": { + result.tool.push(...value.split(",").filter(Boolean)); + break; + } + case "code": { + result.code = value; + break; + } + case "duration_ms": { + if (value.startsWith(">=")) { + result.durationMsMin = Number(value.slice(2)) || null; + } else if (value.startsWith("<=")) { + result.durationMsMax = Number(value.slice(2)) || null; + } else if (value.startsWith(">")) { + result.durationMsMin = (Number(value.slice(1)) || 0) + 1; + } else if (value.startsWith("<")) { + result.durationMsMax = (Number(value.slice(1)) || 0) - 1; + } else { + const exact = Number(value); + if (!Number.isNaN(exact)) { + result.durationMsMin = exact; + result.durationMsMax = exact; + } + } + break; + } + case "after": { + const ts = parseTimestamp(value); + if (ts !== null) result.from = ts; + break; + } + case "before": { + const ts = parseTimestamp(value); + if (ts !== null) result.to = ts; + break; + } + default: + // Unknown keys are silently dropped so the input stays forgiving. + } + } + + return result; +}; + +export type FilterCommandKey = { + readonly key: "status" | "trigger" | "tool" | "code" | "duration_ms" | "after" | "before"; + readonly description: string; + readonly example: string; + readonly hints?: readonly string[]; +}; + +export const FILTER_COMMAND_KEYS: readonly FilterCommandKey[] = [ + { + key: "status", + description: "Execution status", + example: "status:failed,completed", + hints: ["failed", "completed", "running", "waiting"], + }, + { + key: "trigger", + description: "Entry point that started the run", + example: "trigger:mcp", + hints: ["mcp", "http", "cli"], + }, + { + key: "tool", + description: "Tool path (supports * glob)", + example: "tool:github.*", + hints: ["namespace.*", "exact.path"], + }, + { + key: "code", + description: "Substring of the run's source code", + example: "code:axiom", + }, + { + key: "duration_ms", + description: "Duration comparator in ms", + example: "duration_ms:>5000", + hints: [">5000", "<1000"], + }, + { + key: "after", + description: "Newer than a relative or absolute date", + example: "after:1h", + hints: ["15m", "1h", "24h", "7d"], + }, + { + key: "before", + description: "Older than a relative or absolute date", + example: "before:2026-04-11", + }, +]; diff --git a/packages/react/src/components/runs/filter-command.tsx b/packages/react/src/components/runs/filter-command.tsx new file mode 100644 index 000000000..bf51800e6 --- /dev/null +++ b/packages/react/src/components/runs/filter-command.tsx @@ -0,0 +1,259 @@ +"use client"; + +import * as React from "react"; +import type { ExecutionListMeta } from "../../api/executions"; + +import { cn } from "../../lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "../command"; +import { + FILTER_COMMAND_KEYS, + parseFilterCommand, + type RunsFilterTokens, +} from "./filter-command-parser"; + +export interface RunsFilterCommandProps { + readonly meta?: ExecutionListMeta; + readonly onApply: (tokens: RunsFilterTokens) => void; + readonly value: string; + readonly onValueChange: (value: string) => void; + /** + * Called whenever the dropdown's open state changes. Parent uses this + * to guard conflicting hotkeys (e.g. `b` → rail collapse) while the + * palette is active. + */ + readonly onOpenChange?: (open: boolean) => void; +} + +export const RunsFilterCommand = React.forwardRef( + function RunsFilterCommand({ meta, onApply, value, onValueChange, onOpenChange }, forwardedRef) { + const [open, setOpen] = React.useState(false); + const containerRef = React.useRef(null); + const inputRef = React.useRef(null); + + React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement, []); + + React.useEffect(() => { + onOpenChange?.(open); + }, [open, onOpenChange]); + + React.useEffect(() => { + if (!open) return; + const handlePointer = (event: PointerEvent) => { + const target = event.target as Node | null; + if (!containerRef.current || !target) return; + if (!containerRef.current.contains(target)) { + setOpen(false); + } + }; + window.addEventListener("pointerdown", handlePointer); + return () => window.removeEventListener("pointerdown", handlePointer); + }, [open]); + + const currentKey = React.useMemo(() => { + const match = KEY_PATTERN.exec(value); + return match ? match[1]! : null; + }, [value]); + + const suggestions = React.useMemo(() => { + if (!currentKey) return []; + switch (currentKey) { + case "status": + return Object.entries(meta?.statusCounts ?? {}).map(([status, count]) => ({ + label: status, + hint: `${count}`, + })); + case "trigger": + return Object.entries(meta?.triggerCounts ?? {}).map(([trigger, count]) => ({ + label: trigger, + hint: `${count}`, + })); + case "tool": + return (meta?.toolFacets ?? []).map((facet) => ({ + label: facet.toolPath, + hint: `${facet.count}`, + })); + default: + return []; + } + }, [currentKey, meta]); + + const handleApply = () => { + const tokens = parseFilterCommand(value); + onApply(tokens); + setOpen(false); + inputRef.current?.blur(); + }; + + const handleSuggestionSelect = (suggestion: string) => { + const match = KEY_PATTERN.exec(value); + const updated = match + ? `${value.slice(0, match.index)}${match[1]!}:${suggestion}` + : value + ? `${value} ${suggestion}` + : suggestion; + onValueChange(updated); + inputRef.current?.focus(); + }; + + const handleKeyInsert = (key: string) => { + const trimmed = value.trimEnd(); + onValueChange(trimmed.length === 0 ? `${key}:` : `${trimmed} ${key}:`); + inputRef.current?.focus(); + }; + + return ( +
+ { + if (event.key === "Enter") { + event.preventDefault(); + handleApply(); + } else if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + inputRef.current?.blur(); + } + }} + > + setOpen(true)} + placeholder="Filter runs — status:… trigger:… tool:… code:…" + className="font-mono text-xs" + /> + + {open ? ( +
+ + {suggestions.length > 0 ? ( + + {suggestions.map((suggestion) => ( + handleSuggestionSelect(suggestion.label)} + > + {suggestion.label} + {suggestion.hint} + + ))} + + ) : ( + + + No live suggestions for this token. + + + )} + + + + + {FILTER_COMMAND_KEYS.map((entry) => ( + handleKeyInsert(entry.key)} + className="group" + > + {entry.key}: + {entry.description} + {entry.hints && entry.hints.length > 0 ? ( + + {entry.hints.map((hint) => ( + + {hint} + + ))} + + ) : null} + + ))} + + + + + + + Apply filters + Enter + + + + +
+ + Use + + ↑↓ + + to navigate + + · + + + Enter + + to query + + · + + + Esc + + to close + + · + + Union:{" "} + + status:failed,completed + + + · + + Range:{" "} + + duration_ms:>5000 + + + · + + Time:{" "} + + after:1h + + +
+
+ ) : null} +
+
+ ); + }, +); + +const KEY_PATTERN = /(status|trigger|tool|code|duration_ms|after|before):([^\s]*)$/; + diff --git a/packages/react/src/components/runs/filter-rail.tsx b/packages/react/src/components/runs/filter-rail.tsx new file mode 100644 index 000000000..9feee2523 --- /dev/null +++ b/packages/react/src/components/runs/filter-rail.tsx @@ -0,0 +1,382 @@ +import * as React from "react"; +import type { ExecutionListMeta, ExecutionStatus } from "../../api/executions"; + +import { cn } from "../../lib/utils"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../accordion"; +import { Input } from "../input"; +import { STATUS_ORDER, STATUS_LABELS, TRIGGER_ORDER, statusTone, triggerTone } from "./status"; + +export interface RunsFilterRailProps { + readonly selectedStatuses: readonly ExecutionStatus[]; + readonly onToggleStatus: (status: ExecutionStatus) => void; + readonly onOnlyStatus: (status: ExecutionStatus) => void; + readonly selectedTriggers: readonly string[]; + readonly onToggleTrigger: (trigger: string) => void; + readonly onOnlyTrigger: (trigger: string) => void; + /** + * Tri-state interactions filter: `"true"` → runs that elicited, + * `"false"` → runs that didn't, `null` → no filter. Only one of + * the two rows can be checked at a time. + */ + readonly selectedElicitation: "true" | "false" | null; + readonly onToggleElicitation: (value: "true" | "false") => void; + readonly selectedTools: readonly string[]; + readonly onToggleTool: (toolPath: string) => void; + readonly onOnlyTool: (toolPath: string) => void; + readonly range: TimeRangePreset; + readonly onRangeChange: (range: TimeRangePreset) => void; + readonly codeQuery: string; + readonly onCodeQueryChange: (value: string) => void; + readonly onReset: () => void; + readonly meta?: ExecutionListMeta; + readonly totalsLine?: string; +} + +export type TimeRangePreset = "15m" | "1h" | "24h" | "7d" | "30d" | "all"; + +export const TIME_RANGE_PRESETS: readonly { + readonly value: TimeRangePreset; + readonly label: string; +}[] = [ + { value: "15m", label: "Last 15m" }, + { value: "1h", label: "Last 1h" }, + { value: "24h", label: "Last 24h" }, + { value: "7d", label: "Last 7d" }, + { value: "30d", label: "Last 30d" }, + { value: "all", label: "All time" }, +]; + +/** Resolve a preset to an epoch-ms [from, to] pair. `to` is always "now". */ +export const resolveTimeRange = ( + preset: TimeRangePreset, +): { readonly from?: number; readonly to?: number } => { + if (preset === "all") return {}; + const now = Date.now(); + const deltaMs: Record, number> = { + "15m": 15 * 60 * 1000, + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + }; + return { from: now - deltaMs[preset], to: now }; +}; + +export function RunsFilterRail({ + selectedStatuses, + onToggleStatus, + onOnlyStatus, + selectedTriggers, + onToggleTrigger, + onOnlyTrigger, + selectedElicitation, + onToggleElicitation, + selectedTools, + onToggleTool, + onOnlyTool, + range, + onRangeChange, + codeQuery, + onCodeQueryChange, + onReset, + meta, + totalsLine, +}: RunsFilterRailProps) { + const filtersActive = + selectedStatuses.length > 0 || + selectedTriggers.length > 0 || + selectedTools.length > 0 || + selectedElicitation !== null || + codeQuery.trim().length > 0 || + range !== "24h"; + + const triggerKeys = React.useMemo(() => { + const set = new Set(TRIGGER_ORDER); + if (meta?.triggerCounts) { + for (const key of Object.keys(meta.triggerCounts)) set.add(key); + } + return [...set].sort(); + }, [meta?.triggerCounts]); + + const toolFacets = meta?.toolFacets ?? []; + + return ( +
+ {/* Title block */} +
+

Execution history

+

+ Every execution recorded for this scope, newest first. +

+ {totalsLine ? ( +

+ {totalsLine} +

+ ) : null} +
+ + {/* Filters header + reset */} +
+

+ Filters +

+ {filtersActive ? ( + // oxlint-disable-next-line react/forbid-elements -- inline text button matches the header's uppercase-tracked-wider styling; + ) : null} +
+ +
+ + +
    + {STATUS_ORDER.map((status) => { + const tone = statusTone(status); + const checked = selectedStatuses.includes(status); + const count = meta?.statusCounts.find( + (c) => c.status === status, + )?.count; + return ( +
  • + onToggleStatus(status)} + onOnly={() => onOnlyStatus(status)} + dotClass={cn(tone.dot, tone.pulse && "animate-pulse")} + label={STATUS_LABELS[status]} + count={count} + /> +
  • + ); + })} +
+
+ + +
    + {triggerKeys.map((key) => { + const tone = triggerTone(key); + const checked = selectedTriggers.includes(key); + const count = meta?.triggerCounts.find( + (c) => (c.triggerKind ?? "unknown") === key, + )?.count; + return ( +
  • + onToggleTrigger(key)} + onOnly={() => onOnlyTrigger(key)} + dotClass={tone.dot} + label={tone.label} + count={count} + monoLabel + /> +
  • + ); + })} +
+
+ + +
    +
  • + onToggleElicitation("true")} + dotClass="bg-[color:var(--color-warning)]" + label="Used elicitation" + count={meta?.interactionCounts.withElicitation} + /> +
  • +
  • + onToggleElicitation("false")} + dotClass="bg-muted-foreground/40" + label="No elicitation" + count={meta?.interactionCounts.withoutElicitation} + /> +
  • +
+
+ + {toolFacets.length > 0 ? ( + +
    + {toolFacets.map((facet) => { + const checked = selectedTools.includes(facet.toolPath); + return ( +
  • + onToggleTool(facet.toolPath)} + onOnly={() => onOnlyTool(facet.toolPath)} + dotClass="bg-foreground/40" + label={facet.toolPath} + count={facet.count} + monoLabel + /> +
  • + ); + })} +
+
+ ) : null} + + +
    + {TIME_RANGE_PRESETS.map((preset) => { + const active = preset.value === range; + return ( +
  • + {/* oxlint-disable-next-line react/forbid-elements -- radio-like list item in a compact filter rail; +
  • + ); + })} +
+
+ + + onCodeQueryChange(event.currentTarget.value)} + placeholder="tools.github.list" + className="h-8 font-mono text-[11px]" + /> + +
+
+
+ ); +} + +function FacetSection({ + value, + label, + children, +}: { + readonly value: string; + readonly label: string; + readonly children: React.ReactNode; +}) { + return ( + + + {label} + + {children} + + ); +} + +function FacetRow({ + checked, + onToggle, + onOnly, + dotClass, + label, + count, + monoLabel, +}: { + readonly checked: boolean; + readonly onToggle: () => void; + readonly onOnly?: () => void; + readonly dotClass: string; + readonly label: string; + readonly count: number | undefined; + readonly monoLabel?: boolean; +}) { + return ( +
+ {/* oxlint-disable-next-line react/forbid-elements -- facet row acts as a checkbox + dot + label + count composite; replacing with + + {onOnly ? ( + // oxlint-disable-next-line react/forbid-elements -- tiny "only" affordance overlays the facet row on hover; + ) : null} +
+ ); +} diff --git a/packages/react/src/components/runs/hover-card-timestamp.tsx b/packages/react/src/components/runs/hover-card-timestamp.tsx new file mode 100644 index 000000000..a982518b8 --- /dev/null +++ b/packages/react/src/components/runs/hover-card-timestamp.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import { UTCDate } from "@date-fns/utc"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { Check, Copy } from "lucide-react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "../../lib/utils"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card"; + +type HoverCardContentProps = ComponentPropsWithoutRef; + +export interface HoverCardTimestampProps { + readonly date: Date; + readonly side?: HoverCardContentProps["side"]; + readonly sideOffset?: HoverCardContentProps["sideOffset"]; + readonly align?: HoverCardContentProps["align"]; + readonly alignOffset?: HoverCardContentProps["alignOffset"]; + readonly className?: string; +} + +export function HoverCardTimestamp({ + date, + side = "right", + align = "start", + alignOffset = -4, + sideOffset, + className, +}: HoverCardTimestampProps) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( + + +
+ {format(date, "LLL dd, y HH:mm:ss")} +
+
+ +
+ + + + +
+
+
+ ); +} + +function CopyRow({ value, label }: { readonly value: string; readonly label: string }) { + const [copied, setCopied] = React.useState(false); + + const handleCopy = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + }, + [value], + ); + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + } + }} + > +
{label}
+
+ + {copied ? : } + + {value} +
+
+ ); +} diff --git a/packages/react/src/components/runs/keyboard-help.tsx b/packages/react/src/components/runs/keyboard-help.tsx new file mode 100644 index 000000000..5aa62407c --- /dev/null +++ b/packages/react/src/components/runs/keyboard-help.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { Keyboard } from "lucide-react"; + +import { Button } from "../button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "../dropdown-menu"; + +const SHORTCUTS: ReadonlyArray<{ + readonly label: string; + readonly key: string; +}> = [ + { label: "Toggle live refresh", key: "J" }, + { label: "Refresh data", key: "R" }, + { label: "Open filter command", key: "/" }, + { label: "Toggle filter rail", key: "B" }, + { label: "Previous run in drawer", key: "↑" }, + { label: "Next run in drawer", key: "↓" }, + { label: "Close drawer / dialog", key: "Esc" }, + { label: "Show this menu", key: "?" }, +]; + +export interface KeyboardHelpButtonProps { + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +} + +export function KeyboardHelpButton({ open, onOpenChange }: KeyboardHelpButtonProps) { + return ( + + + + + + + Keyboard shortcuts + + + {SHORTCUTS.map((shortcut) => ( + + {shortcut.label} + {shortcut.key} + + ))} + + + ); +} diff --git a/packages/react/src/components/runs/live-button.tsx b/packages/react/src/components/runs/live-button.tsx new file mode 100644 index 000000000..6cf53154b --- /dev/null +++ b/packages/react/src/components/runs/live-button.tsx @@ -0,0 +1,28 @@ +import { CirclePause, CirclePlay } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; + +export interface LiveButtonProps { + readonly active: boolean; + readonly onClick: () => void; +} + +export function LiveButton({ active, onClick }: LiveButtonProps) { + return ( + + ); +} diff --git a/packages/react/src/components/runs/live-row.tsx b/packages/react/src/components/runs/live-row.tsx new file mode 100644 index 000000000..c5b536a77 --- /dev/null +++ b/packages/react/src/components/runs/live-row.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +export function LiveRow() { + return ( +
+ + ● Live · now + +
+ ); +} diff --git a/packages/react/src/components/runs/refresh-button.tsx b/packages/react/src/components/runs/refresh-button.tsx new file mode 100644 index 000000000..591a96f60 --- /dev/null +++ b/packages/react/src/components/runs/refresh-button.tsx @@ -0,0 +1,29 @@ +import { LoaderCircle, RefreshCcw } from "lucide-react"; + +import { Button } from "../button"; + +export interface RefreshButtonProps { + readonly onClick: () => void; + readonly isLoading?: boolean; +} + +export function RefreshButton({ onClick, isLoading }: RefreshButtonProps) { + return ( + + ); +} diff --git a/packages/react/src/components/runs/row.tsx b/packages/react/src/components/runs/row.tsx new file mode 100644 index 000000000..ff877c8f6 --- /dev/null +++ b/packages/react/src/components/runs/row.tsx @@ -0,0 +1,172 @@ +import * as React from "react"; +import type { Execution } from "../../api/executions"; + +import { cn } from "../../lib/utils"; +import { HoverCardTimestamp } from "./hover-card-timestamp"; +import { statusTone, triggerTone } from "./status"; + +const formatDurationMs = (execution: Execution): string | null => { + if (execution.startedAt === null || execution.completedAt === null) return null; + const ms = Math.max(0, execution.completedAt - execution.startedAt); + return ms.toLocaleString(); +}; + + +/** Count `[error]` and `[warn]` lines in the serialized logsJson array. */ +const parseLogCounts = (logsJson: string | null): { errors: number; warns: number } => { + if (!logsJson) return { errors: 0, warns: 0 }; + try { + const parsed = JSON.parse(logsJson); + if (!Array.isArray(parsed)) return { errors: 0, warns: 0 }; + let errors = 0; + let warns = 0; + for (const line of parsed) { + if (typeof line !== "string") continue; + if (line.startsWith("[error]")) errors += 1; + else if (line.startsWith("[warn]")) warns += 1; + } + return { errors, warns }; + } catch { + return { errors: 0, warns: 0 }; + } +}; + +export interface RunRowProps { + readonly execution: Execution; + readonly isSelected?: boolean; + /** + * True when the row is "past" the live cutoff — i.e., it already + * existed at the moment live mode was turned on. Rendered at half + * opacity so new arrivals stand out. + */ + readonly isPast?: boolean; + /** + * Per-field visibility from the ViewOptionsButton. Missing keys + * default to visible. `status` and `code` are always shown. + */ + readonly visibleFields?: { + readonly via?: boolean; + readonly tools?: boolean; + readonly log?: boolean; + readonly duration_ms?: boolean; + }; + readonly onSelect?: () => void; +} + +export function RunRow({ execution, isSelected, isPast, visibleFields, onSelect }: RunRowProps) { + const showVia = visibleFields?.via !== false; + const showTools = visibleFields?.tools !== false; + const showLog = visibleFields?.log !== false; + const showDuration = visibleFields?.duration_ms !== false; + + const durationMs = formatDurationMs(execution); + const durationNumeric = durationMs ? Number(durationMs.replace(/,/g, "")) : null; + const isSlow = durationNumeric !== null && durationNumeric > 5_000; + const tone = statusTone(execution.status); + const trigger = triggerTone(execution.triggerKind); + const logs = React.useMemo(() => parseLogCounts(execution.logsJson), [execution.logsJson]); + + return ( + // oxlint-disable-next-line react/forbid-elements -- row needs a plain + ); +} diff --git a/packages/react/src/components/runs/shell.tsx b/packages/react/src/components/runs/shell.tsx new file mode 100644 index 000000000..5c3ddb81c --- /dev/null +++ b/packages/react/src/components/runs/shell.tsx @@ -0,0 +1,162 @@ +import * as React from "react"; + +import { cn } from "../../lib/utils"; +import { LiveRow } from "./live-row"; + +export interface RunsShellProps { + readonly filterRail: React.ReactNode; + readonly topBar?: React.ReactNode; + readonly chartSlot?: React.ReactNode; + readonly columnHeader?: React.ReactNode; + readonly emptyState?: React.ReactNode; + readonly rows: readonly T[]; + readonly getRowId: (row: T) => string; + readonly renderRow: (row: T) => React.ReactNode; + /** Row id before which to render a `` divider (live mode cutoff). */ + readonly liveMarkerBeforeRowId?: string; + readonly isLoading?: boolean; + readonly isFetchingNextPage?: boolean; + readonly hasNextPage?: boolean; + readonly fetchNextPage?: () => void; + readonly totalRowsFetched?: number; + readonly filterRowCount?: number; + /** When true, hide the filter rail and let the main pane fill the width. */ + readonly collapseRail?: boolean; + readonly className?: string; +} + +export function RunsShell({ + filterRail, + topBar, + chartSlot, + columnHeader, + emptyState, + rows, + getRowId, + renderRow, + liveMarkerBeforeRowId, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + totalRowsFetched = 0, + filterRowCount, + collapseRail, + className, +}: RunsShellProps) { + const topBarRef = React.useRef(null); + const bodyRef = React.useRef(null); + const [topBarHeight, setTopBarHeight] = React.useState(0); + + React.useEffect(() => { + const topBar = topBarRef.current; + if (!topBar) return; + + const observer = new ResizeObserver(() => { + const rect = topBar.getBoundingClientRect(); + setTopBarHeight(rect.height); + }); + + observer.observe(topBar); + return () => observer.disconnect(); + }, []); + + const onScroll = React.useCallback( + (event: React.UIEvent) => { + if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return; + + const target = event.currentTarget; + const onPageBottom = + Math.ceil(target.scrollTop + target.clientHeight) >= target.scrollHeight - 64; + + if (onPageBottom) { + const hitFilterCeiling = + typeof filterRowCount === "number" && totalRowsFetched >= filterRowCount; + if (!hitFilterCeiling) { + fetchNextPage(); + } + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage, totalRowsFetched, filterRowCount], + ); + + return ( +
+ + +
+
+ {topBar} + {chartSlot} +
+ + {columnHeader ? ( +
+ {columnHeader} +
+ ) : null} + +
+ {isLoading ? ( +
+ Loading runs… +
+ ) : rows.length === 0 ? ( +
+ {emptyState ??

No runs.

} +
+ ) : ( + <> + {rows.map((row) => { + const id = getRowId(row); + return ( + + {id === liveMarkerBeforeRowId ? : null} + {renderRow(row)} + + ); + })} + {isFetchingNextPage ? ( +
+ Loading more… +
+ ) : null} + {!hasNextPage && totalRowsFetched > 0 ? ( +
+ End of history +
+ ) : null} + + )} +
+
+
+ ); +} diff --git a/packages/react/src/components/runs/status.ts b/packages/react/src/components/runs/status.ts new file mode 100644 index 000000000..d23cb2d17 --- /dev/null +++ b/packages/react/src/components/runs/status.ts @@ -0,0 +1,108 @@ +import type { ExecutionStatus } from "../../api/executions"; + +export const STATUS_ORDER = [ + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", + "pending", +] as const satisfies readonly ExecutionStatus[]; + +export const STATUS_LABELS: Record = { + pending: "Pending", + running: "Running", + waiting_for_interaction: "Waiting", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +}; + +export type StatusTone = { + /** Tailwind bg-* class for the solid dot. */ + readonly dot: string; + /** Tailwind text-* class for the inline status label. */ + readonly text: string; + /** CSS value suitable for recharts bar `fill`. */ + readonly chartFill: string; + /** Whether to apply `animate-pulse` to the dot. */ + readonly pulse: boolean; +}; + +export const STATUS_TONES: Record = { + completed: { + dot: "bg-primary", + text: "text-primary", + chartFill: "var(--primary)", + pulse: false, + }, + failed: { + dot: "bg-destructive", + text: "text-destructive", + chartFill: "var(--destructive)", + pulse: false, + }, + running: { + dot: "bg-blue-400", + text: "text-blue-400", + chartFill: "#60a5fa", + pulse: true, + }, + waiting_for_interaction: { + dot: "bg-amber-400", + text: "text-amber-400", + chartFill: "#fbbf24", + pulse: true, + }, + cancelled: { + dot: "bg-muted-foreground/60", + text: "text-muted-foreground", + chartFill: "var(--muted-foreground)", + pulse: false, + }, + pending: { + dot: "bg-muted-foreground/40", + text: "text-muted-foreground", + chartFill: "color-mix(in srgb, var(--muted-foreground) 50%, transparent)", + pulse: false, + }, +}; + +export const statusTone = (status: ExecutionStatus): StatusTone => STATUS_TONES[status]; + +export type TriggerTone = { + readonly dot: string; + readonly text: string; + readonly label: string; +}; + +const UNKNOWN_TRIGGER_TONE: TriggerTone = { + dot: "bg-muted-foreground/40", + text: "text-muted-foreground", + label: "unknown", +}; + +export const TRIGGER_TONES: Record = { + mcp: { + dot: "bg-[color:var(--color-info)]", + text: "text-[color:var(--color-info)]", + label: "mcp", + }, + http: { + dot: "bg-[color:var(--color-success)]", + text: "text-[color:var(--color-success)]", + label: "http", + }, + cli: { + dot: "bg-foreground/70", + text: "text-foreground", + label: "cli", + }, +}; + +export const triggerTone = (kind: string | null | undefined): TriggerTone => { + if (!kind) return UNKNOWN_TRIGGER_TONE; + return TRIGGER_TONES[kind] ?? { ...UNKNOWN_TRIGGER_TONE, label: kind }; +}; + +export const TRIGGER_ORDER = ["mcp", "http", "cli"] as const; diff --git a/packages/react/src/components/runs/timeline-chart.tsx b/packages/react/src/components/runs/timeline-chart.tsx new file mode 100644 index 000000000..9b8cde8d0 --- /dev/null +++ b/packages/react/src/components/runs/timeline-chart.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import { format } from "date-fns"; +import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from "recharts"; + +type RechartsMouseEvent = { readonly activeLabel?: string | number }; +import type { ExecutionChartBucket } from "../../api/executions"; + +import { cn } from "../../lib/utils"; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "../chart"; +import { STATUS_LABELS, STATUS_TONES } from "./status"; + +const TIMELINE_CONFIG: ChartConfig = { + completed: { label: STATUS_LABELS.completed, color: STATUS_TONES.completed.chartFill }, + failed: { label: STATUS_LABELS.failed, color: STATUS_TONES.failed.chartFill }, + running: { label: STATUS_LABELS.running, color: STATUS_TONES.running.chartFill }, + waiting_for_interaction: { + label: STATUS_LABELS.waiting_for_interaction, + color: STATUS_TONES.waiting_for_interaction.chartFill, + }, + cancelled: { label: STATUS_LABELS.cancelled, color: STATUS_TONES.cancelled.chartFill }, + pending: { label: STATUS_LABELS.pending, color: STATUS_TONES.pending.chartFill }, +}; + +const BAR_STACK_ORDER = [ + "failed", + "cancelled", + "waiting_for_interaction", + "running", + "pending", + "completed", +] as const; + +const pickAxisLabelFormatter = (bucketMs: number) => { + const MIN = 60_000; + const HOUR = 60 * MIN; + const DAY = 24 * HOUR; + + if (bucketMs <= MIN) { + return (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm:ss"); + }; + } + if (bucketMs < HOUR) { + return (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm"); + }; + } + if (bucketMs < DAY) { + return (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm"); + }; + } + return (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : format(date, "LLL dd"); + }; +}; + +const pickTooltipLabelFormatter = (bucketMs: number) => { + const MIN = 60_000; + const HOUR = 60 * MIN; + return (value: unknown) => { + const date = new Date(value as string); + if (Number.isNaN(date.getTime())) return "—"; + if (bucketMs <= MIN) return format(date, "LLL dd, HH:mm:ss"); + if (bucketMs < HOUR) return format(date, "LLL dd, HH:mm"); + return format(date, "LLL dd, y HH:mm"); + }; +}; + +export interface TimelineChartProps { + readonly data: readonly ExecutionChartBucket[]; + readonly bucketMs: number; + readonly className?: string; + readonly onRangeSelect?: (range: { readonly from: number; readonly to: number }) => void; +} + +export function TimelineChart({ data, bucketMs, className, onRangeSelect }: TimelineChartProps) { + const [refAreaLeft, setRefAreaLeft] = React.useState(null); + const [refAreaRight, setRefAreaRight] = React.useState(null); + const isSelectingRef = React.useRef(false); + + const chartRows = React.useMemo( + () => + data.map((bucket) => ({ + ...bucket, + date: new Date(bucket.bucketStart).toISOString(), + })), + [data], + ); + + const axisLabelFormatter = React.useMemo(() => pickAxisLabelFormatter(bucketMs), [bucketMs]); + const tooltipLabelFormatter = React.useMemo( + () => pickTooltipLabelFormatter(bucketMs), + [bucketMs], + ); + + const handleMouseDown = (event: RechartsMouseEvent | null | undefined) => { + if (event?.activeLabel != null) { + setRefAreaLeft(String(event.activeLabel)); + isSelectingRef.current = true; + } + }; + + const handleMouseMove = (event: RechartsMouseEvent | null | undefined) => { + if (isSelectingRef.current && event?.activeLabel != null) { + setRefAreaRight(String(event.activeLabel)); + } + }; + + const handleMouseUp = () => { + if (refAreaLeft && refAreaRight && onRangeSelect) { + const [lStr, rStr] = [refAreaLeft, refAreaRight].sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime(), + ); + const from = new Date(lStr).getTime(); + const to = new Date(rStr).getTime() + bucketMs; // include the bucket + if (from < to) { + onRangeSelect({ from, to }); + } + } + setRefAreaLeft(null); + setRefAreaRight(null); + isSelectingRef.current = false; + }; + + if (chartRows.length === 0) { + return ( +
+ No activity in range +
+ ); + } + + return ( + + + + + } + /> + {BAR_STACK_ORDER.map((status) => ( + + ))} + {refAreaLeft && refAreaRight ? ( + + ) : null} + + + ); +} diff --git a/packages/react/src/components/runs/view-options-button.tsx b/packages/react/src/components/runs/view-options-button.tsx new file mode 100644 index 000000000..8adbe0af2 --- /dev/null +++ b/packages/react/src/components/runs/view-options-button.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { Settings2 } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; +import { Popover, PopoverContent, PopoverTrigger } from "../popover"; + +// --------------------------------------------------------------------------- +// ViewOptionsButton — row field visibility toggle +// --------------------------------------------------------------------------- +// +// Adapted from openstatus `data-table-view-options.tsx` (which toggles column +// visibility). We're flat-row so there are no columns — instead, this toggles +// which inline key-value fields the row renders. Persisted to localStorage via +// the `useLocalStorage` hook so the user's preferences survive reloads. + +export type RunFieldKey = "via" | "tools" | "log" | "duration_ms"; + +export const RUN_FIELD_LABELS: Record = { + via: "Trigger (via:)", + tools: "Tool calls (tools:)", + log: "Log levels (log:)", + duration_ms: "Duration (duration_ms:)", +}; + +export const DEFAULT_FIELD_VISIBILITY: Record = { + via: true, + tools: true, + log: true, + duration_ms: true, +}; + +const FIELD_ORDER: readonly RunFieldKey[] = ["via", "tools", "log", "duration_ms"]; + +export interface ViewOptionsButtonProps { + readonly visible: Record; + readonly onToggle: (key: RunFieldKey) => void; +} + +export function ViewOptionsButton({ visible, onToggle }: ViewOptionsButtonProps) { + return ( + + + + + +
+ Row fields +
+ {FIELD_ORDER.map((field) => { + const checked = visible[field] !== false; + return ( + // oxlint-disable-next-line react/forbid-elements -- dropdown menu row with a leading checkbox span; + ); + })} +
+
+ ); +} diff --git a/packages/react/src/hooks/use-live-mode.ts b/packages/react/src/hooks/use-live-mode.ts new file mode 100644 index 000000000..7362b2ce8 --- /dev/null +++ b/packages/react/src/hooks/use-live-mode.ts @@ -0,0 +1,42 @@ +import * as React from "react"; + +export interface LiveModeState { + readonly cutoffTimestamp: number | null; + readonly cutoffRow: T | null; + readonly isPast: (createdAt: number) => boolean; +} + +export function useLiveMode( + rows: readonly T[], + live: boolean, +): LiveModeState { + const [cutoffTimestamp, setCutoffTimestamp] = React.useState(null); + + const onLiveChange = React.useEffectEvent((isLive: boolean) => { + if (!isLive) { + setCutoffTimestamp(null); + return; + } + const newest = rows[0]; + setCutoffTimestamp(newest ? newest.createdAt : Date.now()); + }); + + React.useEffect(() => { + onLiveChange(live); + }, [live]); + + const cutoffRow = React.useMemo(() => { + if (cutoffTimestamp === null) return null; + return rows.find((row) => row.createdAt <= cutoffTimestamp) ?? null; + }, [rows, cutoffTimestamp]); + + const isPast = React.useCallback( + (createdAt: number): boolean => { + if (cutoffTimestamp === null) return false; + return createdAt <= cutoffTimestamp; + }, + [cutoffTimestamp], + ); + + return { cutoffTimestamp, cutoffRow, isPast }; +} diff --git a/packages/react/src/hooks/use-local-storage.ts b/packages/react/src/hooks/use-local-storage.ts new file mode 100644 index 000000000..3a8fd35d6 --- /dev/null +++ b/packages/react/src/hooks/use-local-storage.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +// --------------------------------------------------------------------------- +// useLocalStorage — minimal JSON-backed persistent state hook +// --------------------------------------------------------------------------- +// +// SSR-safe (reads lazily from `window.localStorage` inside useEffect so it +// works with TanStack Start's hydration). Writes on every state change. +// Falls back to the initial value when parsing fails or localStorage is +// unavailable (private browsing, quota exceeded, etc.). + +export function useLocalStorage( + key: string, + initialValue: T, +): readonly [T, (value: T | ((prev: T) => T)) => void] { + const [value, setValue] = React.useState(initialValue); + const [isHydrated, setIsHydrated] = React.useState(false); + + // Hydrate from localStorage on mount. Done in a one-shot effect so the + // initial SSR render matches the client default, then swaps in the + // stored value on the first client tick. + React.useEffect(() => { + try { + const stored = window.localStorage.getItem(key); + if (stored !== null) { + setValue(JSON.parse(stored) as T); + } + } catch { + // Parse error or localStorage unavailable — keep initial value. + } + setIsHydrated(true); + }, [key]); + + const setPersistedValue = React.useCallback( + (next: T | ((prev: T) => T)) => { + setValue((prev) => { + const resolved = typeof next === "function" ? (next as (p: T) => T)(prev) : next; + if (isHydrated) { + try { + window.localStorage.setItem(key, JSON.stringify(resolved)); + } catch { + // Quota exceeded or localStorage unavailable — state still + // updates in memory. + } + } + return resolved; + }); + }, + [key, isHydrated], + ); + + return [value, setPersistedValue] as const; +} diff --git a/packages/react/src/pages/runs.tsx b/packages/react/src/pages/runs.tsx new file mode 100644 index 000000000..362b61d79 --- /dev/null +++ b/packages/react/src/pages/runs.tsx @@ -0,0 +1,497 @@ +import * as React from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import type { ExecutionStatus } from "../api/executions"; + +import { listExecutions, type ExecutionListItem } from "../api/executions"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLiveMode } from "../hooks/use-live-mode"; +import { useLocalStorage } from "../hooks/use-local-storage"; +import { RunsShell } from "../components/runs/shell"; +import { RunRow } from "../components/runs/row"; +import { RunsColumnHeader, type SortField, type SortState } from "../components/runs/column-header"; +import { + RunsFilterRail, + resolveTimeRange, + type TimeRangePreset, +} from "../components/runs/filter-rail"; +import { TimelineChart } from "../components/runs/timeline-chart"; +import { RunsDetailDrawer } from "../components/runs/detail-drawer"; +import { LiveButton } from "../components/runs/live-button"; +import { RefreshButton } from "../components/runs/refresh-button"; +import { KeyboardHelpButton } from "../components/runs/keyboard-help"; +import { + ViewOptionsButton, + DEFAULT_FIELD_VISIBILITY, + type RunFieldKey, +} from "../components/runs/view-options-button"; +import { RunsFilterCommand } from "../components/runs/filter-command"; +import type { RunsFilterTokens } from "../components/runs/filter-command-parser"; +import { STATUS_ORDER } from "../components/runs/status"; + +export type RunsSearch = { + readonly executionId?: string; + readonly status?: string; + readonly trigger?: string; + readonly tool?: string; + readonly range?: string; + readonly from?: string; + readonly to?: string; + readonly code?: string; + readonly live?: string; + readonly sort?: string; + readonly elicitation?: string; +}; + +const DEFAULT_RANGE: TimeRangePreset = "24h"; +const VALID_RANGES: readonly TimeRangePreset[] = ["15m", "1h", "24h", "7d", "30d", "all"]; +const PAGE_SIZE = 50; +const LIVE_REFRESH_INTERVAL_MS = 5_000; + +const splitCsv = (value: string | undefined): string[] => + value ? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : []; + +const parseStatuses = (value: string | undefined): ExecutionStatus[] => + splitCsv(value).filter((s): s is ExecutionStatus => STATUS_ORDER.includes(s as ExecutionStatus)); + +const toggleCsv = (values: readonly string[], value: string): string[] => + values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value].sort(); + +const VALID_SORT_FIELDS: readonly SortField[] = ["createdAt", "durationMs"]; + +const parseSortSearch = (value: string | undefined): SortState => { + if (!value) return null; + const [field, direction] = value.split(","); + if (!field || !direction) return null; + if (!VALID_SORT_FIELDS.includes(field as SortField)) return null; + if (direction !== "asc" && direction !== "desc") return null; + return { field: field as SortField, direction }; +}; + + +export function RunsPage({ search }: { search: RunsSearch }) { + const navigate = useNavigate(); + + const selectedStatuses = React.useMemo(() => parseStatuses(search.status), [search.status]); + const selectedTriggers = React.useMemo(() => splitCsv(search.trigger), [search.trigger]); + const selectedTools = React.useMemo(() => splitCsv(search.tool), [search.tool]); + const range = React.useMemo( + (): TimeRangePreset => + search.range && VALID_RANGES.includes(search.range as TimeRangePreset) + ? (search.range as TimeRangePreset) + : DEFAULT_RANGE, + [search.range], + ); + const sort = React.useMemo(() => parseSortSearch(search.sort), [search.sort]); + const selectedElicitation: "true" | "false" | null = + search.elicitation === "true" || search.elicitation === "false" ? search.elicitation : null; + const live = search.live === "1"; + + const [codeInput, setCodeInput] = React.useState(search.code ?? ""); + + React.useEffect(() => { + setCodeInput(search.code ?? ""); + }, [search.code]); + + const updateSearch = React.useCallback( + (patch: Partial) => { + void navigate({ + to: "/runs", + replace: true, + search: (current: RunsSearch) => { + const next = { ...current, ...patch }; + const cleaned: Record = {}; + for (const [key, value] of Object.entries(next)) { + if (value && String(value).length > 0) { + cleaned[key] = String(value); + } + } + return cleaned as RunsSearch; + }, + }); + }, + [navigate], + ); + + React.useEffect(() => { + const trimmed = codeInput.trim(); + const current = search.code ?? ""; + if (trimmed === current) return; + + const timeout = window.setTimeout(() => { + updateSearch({ code: trimmed || undefined, executionId: undefined }); + }, 250); + return () => window.clearTimeout(timeout); + }, [codeInput, search.code, updateSearch]); + + const resolvedTimeRange = React.useMemo(() => { + if (search.from || search.to) { + return { + from: search.from ? Number(search.from) : undefined, + to: search.to ? Number(search.to) : undefined, + }; + } + return resolveTimeRange(range); + }, [range, search.from, search.to]); + + const listQuery = useInfiniteQuery({ + queryKey: [ + "executions", + selectedStatuses.join(","), + selectedTriggers.join(","), + selectedTools.join(","), + resolvedTimeRange.from ?? "", + resolvedTimeRange.to ?? "", + search.code ?? "", + search.sort ?? "", + search.elicitation ?? "", + ], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + listExecutions({ + limit: PAGE_SIZE, + cursor: pageParam, + status: selectedStatuses.length > 0 ? selectedStatuses.join(",") : undefined, + trigger: selectedTriggers.length > 0 ? selectedTriggers.join(",") : undefined, + tool: selectedTools.length > 0 ? selectedTools.join(",") : undefined, + from: resolvedTimeRange.from ? String(resolvedTimeRange.from) : undefined, + to: resolvedTimeRange.to ? String(resolvedTimeRange.to) : undefined, + code: search.code, + sort: search.sort, + elicitation: search.elicitation, + }), + getNextPageParam: (page) => page.nextCursor, + staleTime: 10_000, + refetchInterval: live ? LIVE_REFRESH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + }); + + const rows = React.useMemo( + () => listQuery.data?.pages.flatMap((page) => page.executions) ?? [], + [listQuery.data], + ); + + const liveMode = useLiveMode(rows, live); + + const selectedIndex = React.useMemo( + () => (search.executionId ? rows.findIndex((r) => r.id === search.executionId) : -1), + [rows, search.executionId], + ); + const prevRowId = selectedIndex > 0 ? rows[selectedIndex - 1]?.id : undefined; + const nextRowId = + selectedIndex >= 0 && selectedIndex < rows.length - 1 ? rows[selectedIndex + 1]?.id : undefined; + + const meta = listQuery.data?.pages[0]?.meta; + + const totalsLine = meta + ? `${meta.filterRowCount.toLocaleString()} of ${meta.totalRowCount.toLocaleString()} runs` + : undefined; + + const handleToggleStatus = React.useCallback( + (status: ExecutionStatus) => { + const next = toggleCsv(selectedStatuses, status) as ExecutionStatus[]; + updateSearch({ + status: next.length > 0 ? next.join(",") : undefined, + executionId: undefined, + }); + }, + [selectedStatuses, updateSearch], + ); + + const handleToggleTrigger = React.useCallback( + (trigger: string) => { + const next = toggleCsv(selectedTriggers, trigger); + updateSearch({ + trigger: next.length > 0 ? next.join(",") : undefined, + executionId: undefined, + }); + }, + [selectedTriggers, updateSearch], + ); + + const handleToggleTool = React.useCallback( + (toolPath: string) => { + const next = toggleCsv(selectedTools, toolPath); + updateSearch({ + tool: next.length > 0 ? next.join(",") : undefined, + executionId: undefined, + }); + }, + [selectedTools, updateSearch], + ); + + const handleToggleElicitation = React.useCallback( + (value: "true" | "false") => { + updateSearch({ + elicitation: selectedElicitation === value ? undefined : value, + executionId: undefined, + }); + }, + [selectedElicitation, updateSearch], + ); + + const handleSort = React.useCallback( + (field: SortField) => { + const next: SortState = + sort?.field !== field + ? { field, direction: "desc" } + : sort.direction === "desc" + ? { field, direction: "asc" } + : null; + updateSearch({ + sort: next ? `${next.field},${next.direction}` : undefined, + executionId: undefined, + }); + }, + [sort, updateSearch], + ); + + const handleOnlyStatus = React.useCallback( + (status: ExecutionStatus) => updateSearch({ status, executionId: undefined }), + [updateSearch], + ); + const handleOnlyTrigger = React.useCallback( + (trigger: string) => updateSearch({ trigger, executionId: undefined }), + [updateSearch], + ); + const handleOnlyTool = React.useCallback( + (tool: string) => updateSearch({ tool, executionId: undefined }), + [updateSearch], + ); + + const handleRangeChange = React.useCallback( + (nextRange: TimeRangePreset) => { + updateSearch({ + range: nextRange, + from: undefined, + to: undefined, + executionId: undefined, + }); + }, + [updateSearch], + ); + + const handleCodeQueryChange = React.useCallback((value: string) => { + setCodeInput(value); + }, []); + + const handleReset = React.useCallback(() => { + setCodeInput(""); + updateSearch({ + status: undefined, + trigger: undefined, + tool: undefined, + range: DEFAULT_RANGE, + from: undefined, + to: undefined, + code: undefined, + elicitation: undefined, + executionId: undefined, + }); + }, [updateSearch]); + + const handleChartRangeSelect = React.useCallback( + ({ from, to }: { from: number; to: number }) => { + updateSearch({ + range: undefined, + from: String(from), + to: String(to), + executionId: undefined, + }); + }, + [updateSearch], + ); + + const handleRowSelect = React.useCallback( + (execution: ExecutionListItem) => { + updateSearch({ + executionId: search.executionId === execution.id ? undefined : execution.id, + }); + }, + [search.executionId, updateSearch], + ); + + const handleDrawerOpenChange = React.useCallback( + (open: boolean) => { + if (!open) { + updateSearch({ executionId: undefined }); + } + }, + [updateSearch], + ); + + const toggleLive = React.useCallback(() => { + updateSearch({ live: live ? undefined : "1" }); + }, [live, updateSearch]); + + const filterCommandInputRef = React.useRef(null); + const [filterCommandValue, setFilterCommandValue] = React.useState(""); + const [filterCommandOpen, setFilterCommandOpen] = React.useState(false); + const [keyboardHelpOpen, setKeyboardHelpOpen] = React.useState(false); + const [railCollapsed, setRailCollapsed] = React.useState(false); + + const [fieldVisibility, setFieldVisibility] = useLocalStorage>( + "runs.fieldVisibility", + DEFAULT_FIELD_VISIBILITY, + ); + + const toggleFieldVisibility = React.useCallback( + (key: RunFieldKey) => { + setFieldVisibility((prev) => ({ ...prev, [key]: !prev[key] })); + }, + [setFieldVisibility], + ); + + const currentFilterExpression = React.useMemo(() => { + const parts: string[] = []; + if (selectedStatuses.length > 0) parts.push(`status:${selectedStatuses.join(",")}`); + if (selectedTriggers.length > 0) parts.push(`trigger:${selectedTriggers.join(",")}`); + if (selectedTools.length > 0) parts.push(`tool:${selectedTools.join(",")}`); + if (search.code) parts.push(`code:${search.code}`); + return parts.join(" "); + }, [selectedStatuses, selectedTriggers, selectedTools, search.code]); + + React.useEffect(() => { + setFilterCommandValue(currentFilterExpression); + }, [currentFilterExpression]); + + const handleApplyFilterCommand = React.useCallback( + (tokens: RunsFilterTokens) => { + const statusValue = (tokens.status as ExecutionStatus[]).filter((s) => + STATUS_ORDER.includes(s), + ); + + updateSearch({ + status: statusValue.length > 0 ? statusValue.join(",") : undefined, + trigger: tokens.trigger.length > 0 ? [...tokens.trigger].join(",") : undefined, + tool: tokens.tool.length > 0 ? [...tokens.tool].join(",") : undefined, + code: tokens.code ?? undefined, + from: tokens.from ? String(tokens.from) : undefined, + to: tokens.to ? String(tokens.to) : undefined, + range: tokens.from || tokens.to ? undefined : undefined, + executionId: undefined, + }); + }, + [updateSearch], + ); + + useHotkeys("j", toggleLive, { enabled: !filterCommandOpen }); + useHotkeys("r", () => void listQuery.refetch(), { enabled: !filterCommandOpen }); + useHotkeys("/", () => filterCommandInputRef.current?.focus(), { preventDefault: true }); + useHotkeys("shift+/", () => setKeyboardHelpOpen(true), { preventDefault: true }); + useHotkeys("b", () => setRailCollapsed((prev) => !prev), { + enabled: !filterCommandOpen, + }); + + return ( + <> + + } + topBar={ +
+
+
+ + {rows.length.toLocaleString()} loaded + + {meta ? ( + + · {meta.filterRowCount.toLocaleString()} total + + ) : null} +
+
+ void listQuery.refetch()} + isLoading={listQuery.isRefetching} + /> + + + +
+
+ +
+ } + chartSlot={ + meta ? ( + + ) : null + } + columnHeader={ + + } + isLoading={listQuery.isLoading} + isFetchingNextPage={listQuery.isFetchingNextPage} + hasNextPage={listQuery.hasNextPage} + fetchNextPage={() => void listQuery.fetchNextPage()} + totalRowsFetched={rows.length} + filterRowCount={meta?.filterRowCount} + rows={rows} + getRowId={(row) => row.id} + collapseRail={railCollapsed} + renderRow={(row) => ( + handleRowSelect(row)} + /> + )} + liveMarkerBeforeRowId={liveMode.cutoffRow?.id} + emptyState={ +
+

+ No runs match the current filters. +

+

+ Try widening the time range or removing the status filter. +

+
+ } + /> + + prevRowId && updateSearch({ executionId: prevRowId })} + onNext={() => nextRowId && updateSearch({ executionId: nextRowId })} + /> + + ); +} diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index bddf02aae..5ed11e358 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -30,6 +30,10 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--error); + --color-info: var(--info); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); @@ -67,6 +71,10 @@ --accent: oklch(0.94 0.006 260); --accent-foreground: oklch(0.145 0.005 260); --destructive: oklch(0.55 0.22 25); + --success: oklch(0.62 0.15 150); + --warning: oklch(0.72 0.16 85); + --error: oklch(0.58 0.22 25); + --info: oklch(0.62 0.12 245); --border: oklch(0.88 0.006 260); --input: oklch(0.88 0.006 260); --ring: oklch(0.55 0.17 175); @@ -103,6 +111,10 @@ --accent: oklch(0.195 0.008 260); --accent-foreground: oklch(0.9 0.008 250); --destructive: oklch(0.62 0.22 25); + --success: oklch(0.78 0.16 150); + --warning: oklch(0.8 0.14 85); + --error: oklch(0.72 0.2 25); + --info: oklch(0.76 0.11 245); --border: oklch(0.28 0.007 260); --input: oklch(0.195 0.007 260); --ring: oklch(0.72 0.15 175);