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/apps/cloud/src/api/autumn.ts b/apps/cloud/src/api/autumn.ts index 5275a1961..8dac53909 100644 --- a/apps/cloud/src/api/autumn.ts +++ b/apps/cloud/src/api/autumn.ts @@ -4,9 +4,31 @@ import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; import { autumnHandler } from "autumn-js/backend"; import { WorkOSAuth } from "../auth/workos"; +import { AutumnService } from "../services/autumn"; import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; import { SharedServices } from "./layers"; +export const makeTrackExecutionUsage = (autumn: AutumnService["Type"]) => { + return (organizationId: string): void => { + autumn + .use((client) => + client.track({ + customerId: organizationId, + featureId: "executions", + value: 1, + }), + ) + .pipe( + Effect.catchAll((err) => + Effect.logError("[billing] track failed").pipe( + Effect.annotateLogs("error", String(err)), + ), + ), + Effect.runFork, + ); + }; +}; + export const AutumnApiApp = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* Effect.mapError( @@ -66,7 +88,10 @@ export const AutumnApiApp = Effect.gen(function* () { ); if (statusCode >= 400) { - console.error("[autumn] upstream error:", statusCode, response); + yield* Effect.logWarning("[autumn] upstream error").pipe( + Effect.annotateLogs("statusCode", statusCode), + Effect.annotateLogs("response", JSON.stringify(response)), + ); return yield* Effect.fail( new HttpResponseError({ status: statusCode, @@ -81,7 +106,10 @@ export const AutumnApiApp = Effect.gen(function* () { Effect.provide(SharedServices), Effect.catchAll((err) => { if (isServerError(err)) { - console.error("[autumn] request failed:", err instanceof Error ? err.stack : err); + return Effect.logError("[autumn] request failed").pipe( + Effect.annotateLogs("error", err instanceof Error ? err.stack ?? err.message : String(err)), + Effect.map(() => toErrorServerResponse(err)), + ); } return Effect.succeed(toErrorServerResponse(err)); }), diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 3cd99a342..d4125fac5 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -81,7 +81,10 @@ export const ProtectedApiApp = Effect.gen(function* () { Effect.provide(SharedServices), Effect.catchAll((err) => { if (isServerError(err)) { - console.error("[api] request failed:", err instanceof Error ? err.stack : err); + return Effect.logError("[api] request failed").pipe( + Effect.annotateLogs("error", err instanceof Error ? err.stack ?? err.message : String(err)), + Effect.map(() => toErrorServerResponse(err)), + ); } return Effect.succeed(toErrorServerResponse(err)); }), 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/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/packages/core/sdk/src/cursor.ts b/packages/core/sdk/src/cursor.ts new file mode 100644 index 000000000..64da2cad9 --- /dev/null +++ b/packages/core/sdk/src/cursor.ts @@ -0,0 +1,25 @@ +import type { Execution } from "./executions"; +import { ExecutionId } from "./ids"; + +export const encodeCursor = (execution: Execution): string => + encodeURIComponent(JSON.stringify({ createdAt: execution.createdAt, id: execution.id })); + +export const decodeCursor = ( + cursor: string, +): { + readonly createdAt: number; + readonly id: ExecutionId; +} | null => { + try { + const parsed = JSON.parse(decodeURIComponent(cursor)) as { + createdAt?: unknown; + id?: unknown; + }; + if (typeof parsed.createdAt !== "number" || typeof parsed.id !== "string") { + return null; + } + return { createdAt: parsed.createdAt, id: ExecutionId.make(parsed.id) }; + } catch { + return null; + } +}; diff --git a/packages/core/sdk/src/executions.ts b/packages/core/sdk/src/executions.ts new file mode 100644 index 000000000..2de3d65ee --- /dev/null +++ b/packages/core/sdk/src/executions.ts @@ -0,0 +1,404 @@ +import { Context, Effect, Schema } from "effect"; + +import { ExecutionId, ExecutionInteractionId, ExecutionToolCallId, ScopeId } from "./ids"; + +export const ExecutionStatus = Schema.Literal( + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +); +export type ExecutionStatus = typeof ExecutionStatus.Type; + +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.Number, + updatedAt: Schema.Number, +}) {} + +export const ExecutionInteractionStatus = Schema.Literal("pending", "resolved", "cancelled"); +export type ExecutionInteractionStatus = typeof ExecutionInteractionStatus.Type; + +export class ExecutionInteraction extends Schema.Class( + "ExecutionInteraction", +)({ + id: ExecutionInteractionId, + executionId: ExecutionId, + status: ExecutionInteractionStatus, + kind: Schema.String, + purpose: Schema.String, + payloadJson: Schema.String, + responseJson: Schema.NullOr(Schema.String), + responsePrivateJson: Schema.NullOr(Schema.String), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}) {} + +export const ExecutionToolCallStatus = Schema.Literal("running", "completed", "failed"); +export type ExecutionToolCallStatus = typeof ExecutionToolCallStatus.Type; + +export class ExecutionToolCall extends Schema.Class("ExecutionToolCall")({ + id: ExecutionToolCallId, + executionId: ExecutionId, + status: ExecutionToolCallStatus, + /** Full dotted path, e.g. `"github.listIssues"`. */ + toolPath: Schema.String, + /** First path segment, e.g. `"github"`. Useful for facet grouping. */ + namespace: 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), +}) {} + +export type CreateExecutionInput = Omit; +export type UpdateExecutionInput = Partial< + Pick< + Execution, + | "status" + | "code" + | "resultJson" + | "errorText" + | "logsJson" + | "startedAt" + | "completedAt" + | "toolCallCount" + | "updatedAt" + > +>; + +export type CreateExecutionInteractionInput = Omit; +export type UpdateExecutionInteractionInput = Partial< + Pick< + ExecutionInteraction, + | "status" + | "kind" + | "purpose" + | "payloadJson" + | "responseJson" + | "responsePrivateJson" + | "updatedAt" + > +>; + +export type CreateExecutionToolCallInput = Omit; +export type UpdateExecutionToolCallInput = Partial< + Pick +>; + +export type ExecutionSortField = "createdAt" | "durationMs"; +export type ExecutionSortDirection = "asc" | "desc"; +export interface ExecutionSort { + readonly field: ExecutionSortField; + readonly direction: ExecutionSortDirection; +} + +export interface ExecutionListOptions { + readonly limit: number; + readonly cursor?: string; + readonly statusFilter?: readonly ExecutionStatus[]; + readonly triggerFilter?: readonly string[]; + /** Glob patterns: exact match or trailing wildcard (`a.*`). */ + readonly toolPathFilter?: readonly string[]; + readonly timeRange?: { + readonly from?: number; + readonly to?: number; + }; + readonly codeQuery?: string; + /** Filter by whether the run recorded any interaction. */ + readonly hadElicitation?: boolean; + /** Return only rows created after this timestamp. */ + readonly after?: number; + /** Sort order. Defaults to createdAt desc. */ + readonly sort?: ExecutionSort; + /** When true, compute and return ExecutionListMeta. */ + readonly includeMeta?: boolean; +} + +export type ExecutionListItem = Execution & { + readonly pendingInteraction: ExecutionInteraction | null; +}; + +/** + * One bucket in the execution timeline chart. `timestamp` is the bucket + * start in epoch-ms. The remaining keys are counts per status. + */ +export interface ExecutionChartBucket { + readonly timestamp: number; + readonly pending: number; + readonly running: number; + readonly waiting_for_interaction: number; + readonly completed: number; + readonly failed: number; + readonly cancelled: number; +} + +export interface ExecutionToolFacet { + readonly toolPath: string; + readonly count: number; +} + +export interface ExecutionListMeta { + readonly totalRowCount: number; + readonly filterRowCount: number; + readonly chartBucketMs: number; + readonly chartData: readonly ExecutionChartBucket[]; + readonly statusCounts: Readonly>; + /** Count of executions per `triggerKind`. Includes `"unknown"` for nulls. */ + readonly triggerCounts: Readonly>; + /** Top-N tool paths by invocation count across the filtered set. */ + readonly toolFacets: readonly ExecutionToolFacet[]; + readonly interactionCounts: { + readonly withElicitation: number; + readonly withoutElicitation: number; + }; +} + +export const EXECUTION_STATUS_KEYS = [ + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +] as const satisfies readonly ExecutionStatus[]; + +export const pickChartBucketMs = (spanMs: number): number => { + const MIN = 60_000; + const HOUR = 60 * MIN; + const DAY = 24 * HOUR; + + if (spanMs <= 10 * MIN) return MIN; // 1 minute + if (spanMs <= DAY) return 5 * MIN; // 5 minutes + if (spanMs <= 7 * DAY) return HOUR; // 1 hour + if (spanMs <= 30 * DAY) return 6 * HOUR; // 6 hours + return DAY; // 1 day +}; + +type MutableBucket = { + -readonly [K in keyof ExecutionChartBucket]: ExecutionChartBucket[K]; +}; + +const emptyBucket = (timestamp: number): MutableBucket => ({ + timestamp, + pending: 0, + running: 0, + waiting_for_interaction: 0, + completed: 0, + failed: 0, + cancelled: 0, +}); + +export interface BuildExecutionListMetaInput { + readonly filtered: readonly Execution[]; + readonly timeRange: ExecutionListOptions["timeRange"]; + readonly totalRowCount: number; + /** Tool path invocation counts for populating toolFacets. */ + readonly toolPathCounts?: ReadonlyMap; + /** Execution IDs with at least one interaction, for computing interactionCounts. */ + readonly executionIdsWithInteractions?: ReadonlySet; +} + +const TRIGGER_KIND_UNKNOWN = "unknown"; + +export const buildExecutionListMeta = (input: BuildExecutionListMetaInput): ExecutionListMeta => { + const { filtered, timeRange, totalRowCount, toolPathCounts, executionIdsWithInteractions } = + input; + const filterRowCount = filtered.length; + + const statusCounts: Record = { + pending: 0, + running: 0, + waiting_for_interaction: 0, + completed: 0, + failed: 0, + cancelled: 0, + }; + const triggerCounts: Record = {}; + let withElicitation = 0; + for (const execution of filtered) { + statusCounts[execution.status] += 1; + const key = execution.triggerKind ?? TRIGGER_KIND_UNKNOWN; + triggerCounts[key] = (triggerCounts[key] ?? 0) + 1; + if (executionIdsWithInteractions?.has(execution.id)) { + withElicitation += 1; + } + } + const interactionCounts = { + withElicitation, + withoutElicitation: filterRowCount - withElicitation, + }; + + const toolFacets: ExecutionToolFacet[] = toolPathCounts + ? [...toolPathCounts.entries()] + .map(([toolPath, count]) => ({ toolPath, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 20) + : []; + + if (filterRowCount === 0) { + return { + totalRowCount, + filterRowCount, + chartBucketMs: pickChartBucketMs(0), + chartData: [], + statusCounts, + triggerCounts, + toolFacets, + interactionCounts, + }; + } + + let minTs = Number.POSITIVE_INFINITY; + let maxTs = Number.NEGATIVE_INFINITY; + for (const execution of filtered) { + if (execution.createdAt < minTs) minTs = execution.createdAt; + if (execution.createdAt > maxTs) maxTs = execution.createdAt; + } + + const rangeStart = timeRange?.from ?? minTs; + const rangeEnd = timeRange?.to ?? maxTs; + const span = Math.max(rangeEnd - rangeStart, 0); + const bucketMs = pickChartBucketMs(span); + + const firstBucket = Math.floor(rangeStart / bucketMs) * bucketMs; + const lastBucket = Math.floor(rangeEnd / bucketMs) * bucketMs; + + const bucketCount = Math.max(1, Math.floor((lastBucket - firstBucket) / bucketMs) + 1); + // Cap buckets so a misconfigured time range doesn't blow up the response. + const safeBucketCount = Math.min(bucketCount, 500); + const bucketMap = new Map(); + for (let i = 0; i < safeBucketCount; i += 1) { + const ts = firstBucket + i * bucketMs; + bucketMap.set(ts, emptyBucket(ts)); + } + + for (const execution of filtered) { + const bucketStart = Math.floor(execution.createdAt / bucketMs) * bucketMs; + let bucket = bucketMap.get(bucketStart); + if (!bucket) { + bucket = emptyBucket(bucketStart); + bucketMap.set(bucketStart, bucket); + } + bucket[execution.status] += 1; + } + + const chartData = [...bucketMap.values()].sort((a, b) => a.timestamp - b.timestamp); + + return { + totalRowCount, + filterRowCount, + chartBucketMs: bucketMs, + chartData, + statusCounts, + triggerCounts, + toolFacets, + interactionCounts, + }; +}; + +/** + * Match a tool path against a filter pattern. Supports exact match + * and trailing glob (`github.*` matches any `github.`). + */ +export const matchToolPathPattern = (toolPath: string, pattern: string): boolean => { + if (pattern === toolPath) return true; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -1); // keep trailing `.` + return toolPath.startsWith(prefix); + } + return false; +}; + +const computeDuration = (execution: Execution): number | null => { + if (execution.startedAt === null || execution.completedAt === null) return null; + return Math.max(0, execution.completedAt - execution.startedAt); +}; + +/** + * Comparator suitable for `Array.prototype.sort`. Returns `< 0` when + * `a` should come before `b`. Tie-breaks on `id` DESC for stable + * ordering. Rows with `null` duration sort to the end regardless of + * direction. + */ +export const pickExecutionSorter = ( + sort: ExecutionSort | undefined, +): ((a: Execution, b: Execution) => number) => { + if (!sort || sort.field === "createdAt") { + const ascending = sort?.direction === "asc"; + return (a, b) => { + const delta = a.createdAt - b.createdAt; + if (delta !== 0) return ascending ? delta : -delta; + return b.id.localeCompare(a.id); + }; + } + + // durationMs + const ascending = sort.direction === "asc"; + return (a, b) => { + const da = computeDuration(a); + const db = computeDuration(b); + if (da === null && db === null) return b.id.localeCompare(a.id); + if (da === null) return 1; + if (db === null) return -1; + const delta = da - db; + if (delta !== 0) return ascending ? delta : -delta; + return b.id.localeCompare(a.id); + }; +}; + +export class ExecutionStore extends Context.Tag("@executor/sdk/ExecutionStore")< + ExecutionStore, + { + readonly create: (input: CreateExecutionInput) => Effect.Effect; + readonly update: (id: ExecutionId, patch: UpdateExecutionInput) => Effect.Effect; + readonly list: ( + scopeId: ScopeId, + options: ExecutionListOptions, + ) => Effect.Effect<{ + readonly executions: readonly ExecutionListItem[]; + readonly nextCursor?: string; + readonly meta?: ExecutionListMeta; + }>; + readonly get: (id: ExecutionId) => Effect.Effect<{ + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; + } | null>; + readonly recordInteraction: ( + executionId: ExecutionId, + interaction: CreateExecutionInteractionInput, + ) => Effect.Effect; + readonly resolveInteraction: ( + interactionId: ExecutionInteractionId, + patch: UpdateExecutionInteractionInput, + ) => Effect.Effect; + /** Record the start of a tool call; returns the created row. */ + readonly recordToolCall: ( + input: CreateExecutionToolCallInput, + ) => Effect.Effect; + readonly finishToolCall: ( + id: ExecutionToolCallId, + patch: UpdateExecutionToolCallInput, + ) => Effect.Effect; + /** List tool calls for an execution, ordered by start time. */ + readonly listToolCalls: ( + executionId: ExecutionId, + ) => Effect.Effect; + readonly sweep: () => Effect.Effect; + } +>() {} 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..011d6aa65 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"; 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, diff --git a/packages/kernel/core/src/effect-errors.ts b/packages/kernel/core/src/effect-errors.ts index 26b5d7f8d..5e902761e 100644 --- a/packages/kernel/core/src/effect-errors.ts +++ b/packages/kernel/core/src/effect-errors.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectError")<{ @@ -21,3 +22,45 @@ export class CodeExecutionError extends Data.TaggedError("CodeExecutionError")<{ readonly message: string; readonly cause?: unknown; }> {} + +/** + * Extract a human-readable message from an unknown error value. + * Handles Error instances, strings, objects with `.message`, and + * arbitrary values via JSON.stringify / String fallback. + */ +export const formatUnknownMessage = (cause: unknown): string => { + if (cause instanceof Error) { + const message = cause.message.trim(); + return message.length > 0 ? message : cause.name; + } + + if (typeof cause === "string") { + return cause; + } + + if ( + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" + ) { + const message = cause.message.trim(); + if (message.length > 0) return message; + } + + if (typeof cause === "object" && cause !== null) { + try { + return JSON.stringify(cause); + } catch { + return String(cause); + } + } + + return String(cause); +}; + +/** + * Squash an Effect `Cause` and extract a readable message. + */ +export const formatCauseMessage = (cause: Cause.Cause): string => + formatUnknownMessage(Cause.squash(cause)); diff --git a/packages/kernel/runtime-deno-subprocess/src/index.ts b/packages/kernel/runtime-deno-subprocess/src/index.ts index c646772ba..1da7a1611 100644 --- a/packages/kernel/runtime-deno-subprocess/src/index.ts +++ b/packages/kernel/runtime-deno-subprocess/src/index.ts @@ -313,6 +313,7 @@ const executeInDeno = ( Effect.catchTag("DenoSpawnError", (e) => Effect.succeed({ result: null, error: e.message }), ), + Effect.withSpan("executor.runtime.deno"), ); }; diff --git a/packages/kernel/runtime-secure-exec/src/index.ts b/packages/kernel/runtime-secure-exec/src/index.ts index e690c09a7..b40529fba 100644 --- a/packages/kernel/runtime-secure-exec/src/index.ts +++ b/packages/kernel/runtime-secure-exec/src/index.ts @@ -3,8 +3,9 @@ import { type CodeExecutor, type ExecuteResult, type SandboxToolInvoker, + formatUnknownMessage, + formatCauseMessage, } from "@executor/codemode-core"; -import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -16,40 +17,6 @@ export type SecureExecExecutorOptions = { memoryLimitMb?: number; }; -const formatUnknownMessage = (cause: unknown): string => { - if (cause instanceof Error) { - const message = cause.message.trim(); - return message.length > 0 ? message : cause.name; - } - - if (typeof cause === "string") { - return cause; - } - - if ( - typeof cause === "object" && - cause !== null && - "message" in cause && - typeof cause.message === "string" - ) { - const message = cause.message.trim(); - if (message.length > 0) return message; - } - - if (typeof cause === "object" && cause !== null) { - try { - return JSON.stringify(cause); - } catch { - return String(cause); - } - } - - return String(cause); -}; - -const formatCauseMessage = (cause: Cause.Cause): string => - formatUnknownMessage(Cause.squash(cause)); - class SecureExecExecutionError extends Data.TaggedError("SecureExecExecutionError")<{ readonly operation: string; readonly cause: unknown; @@ -438,6 +405,7 @@ const evaluateInSecureExec = ( logs, }), ), + Effect.withSpan("executor.runtime.secure_exec"), ); }; 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..53b6f4c31 --- /dev/null +++ b/packages/react/src/api/executions.tsx @@ -0,0 +1,115 @@ +import { endOfDay, parseISO, startOfDay } from "date-fns"; +import type { + Execution, + ExecutionChartBucket, + ExecutionInteraction, + ExecutionListMeta, + ExecutionToolCall, +} from "@executor/sdk"; + +import { getBaseUrl } from "./base-url"; + +export type ExecutionListItem = Execution & { + readonly pendingInteraction: ExecutionInteraction | null; +}; + +export type ListExecutionsResponse = { + readonly executions: readonly ExecutionListItem[]; + readonly nextCursor?: string; + readonly meta?: ExecutionListMeta; +}; + +export type { ExecutionChartBucket, ExecutionListMeta, ExecutionToolCall }; + +export type GetExecutionResponse = { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +}; + +export type ListToolCallsResponse = { + readonly toolCalls: readonly ExecutionToolCall[]; +}; + +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", + }); + + return readJson(response); +}; + +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/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 6ef6e9618..5a5fecb64 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, Result } from "@effect-atom/atom-react"; -import { PlusIcon } from "lucide-react"; +import { HistoryIcon, PlusIcon } from "lucide-react"; import { SourceFavicon } from "./source-favicon"; import { sourcesAtom } from "../api/atoms"; import { useScope } from "../hooks/use-scope"; @@ -116,6 +116,11 @@ export function CommandPalette(props: { sourcePlugins: readonly SourcePlugin[] } [close, navigate], ); + const goToRuns = useCallback(() => { + close(); + void navigate({ to: "/runs" }); + }, [close, navigate]); + const goToPreset = useCallback( (pluginKey: string, presetId: string, presetUrl?: string) => { close(); @@ -152,21 +157,32 @@ export function CommandPalette(props: { sourcePlugins: readonly SourcePlugin[] } )} - {connectedSources.length > 0 && sourcePlugins.length > 0 && } + {(connectedSources.length > 0 || sourcePlugins.length > 0) && } + + + + + Runs + history + + {sourcePlugins.length > 0 && ( - - {sourcePlugins.map((plugin) => ( - goToAdd(plugin.key)} - > - - Add {plugin.label} - - ))} - + <> + + + {sourcePlugins.map((plugin) => ( + goToAdd(plugin.key)} + > + + Add {plugin.label} + + ))} + + )} {presetEntries.length > 0 && } 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..de12f3f77 --- /dev/null +++ b/packages/react/src/components/runs/column-header.tsx @@ -0,0 +1,99 @@ +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 ( + + ); +} 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..a6250c293 --- /dev/null +++ b/packages/react/src/components/runs/detail-drawer.tsx @@ -0,0 +1,606 @@ +"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 "@executor/sdk"; + +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 ( + + ); +} + +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 ( +
+ + + {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..d93d55e34 --- /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 "@executor/sdk"; + +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..5059ab71a --- /dev/null +++ b/packages/react/src/components/runs/filter-rail.tsx @@ -0,0 +1,374 @@ +import * as React from "react"; +import type { ExecutionListMeta, ExecutionStatus } from "@executor/sdk"; + +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 ? ( + + ) : null} +
+ +
+ + +
    + {STATUS_ORDER.map((status) => { + const tone = statusTone(status); + const checked = selectedStatuses.includes(status); + const count = meta?.statusCounts[status]; + 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[key]; + 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 ( +
  • + +
  • + ); + })} +
+
+ + + 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 ( +
+ + + {onOnly ? ( + + ) : 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..4a194bb12 --- /dev/null +++ b/packages/react/src/components/runs/row.tsx @@ -0,0 +1,171 @@ +import * as React from "react"; +import type { Execution } from "@executor/sdk"; + +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 ( + + ); +} 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..b9951ff5e --- /dev/null +++ b/packages/react/src/components/runs/status.ts @@ -0,0 +1,108 @@ +import type { ExecutionStatus } from "@executor/sdk"; + +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..ac908c95a --- /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 "@executor/sdk"; + +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.timestamp).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..e62b6257e --- /dev/null +++ b/packages/react/src/components/runs/view-options-button.tsx @@ -0,0 +1,95 @@ +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 ( + + ); + })} +
+
+ ); +} 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/lib/shiki.ts b/packages/react/src/lib/shiki.ts index 021673207..b8184044f 100644 --- a/packages/react/src/lib/shiki.ts +++ b/packages/react/src/lib/shiki.ts @@ -12,8 +12,8 @@ import langTsx from "@shikijs/langs/tsx"; import langJsx from "@shikijs/langs/jsx"; import langJson from "@shikijs/langs/json"; import langShellscript from "@shikijs/langs/shellscript"; -import githubDark from "@shikijs/themes/github-dark"; -import githubLight from "@shikijs/themes/github-light"; +import vitesseDark from "@shikijs/themes/vitesse-dark"; +import vitesseLight from "@shikijs/themes/vitesse-light"; // --------------------------------------------------------------------------- // Lazily loaded languages — imported on first use @@ -104,11 +104,11 @@ const LAZY_LANG_LOADERS: Partial Promise>> const supportedSet = new Set([...SUPPORTED_LANGS, ...Object.keys(LANG_ALIASES)]); -export const SUPPORTED_THEMES = ["github-dark", "github-light"] as const; +export const SUPPORTED_THEMES = ["vitesse-dark", "vitesse-light"] as const; export type SupportedTheme = (typeof SUPPORTED_THEMES)[number]; -export const DEFAULT_LIGHT_THEME: SupportedTheme = "github-light"; -export const DEFAULT_DARK_THEME: SupportedTheme = "github-dark"; +export const DEFAULT_LIGHT_THEME: SupportedTheme = "vitesse-light"; +export const DEFAULT_DARK_THEME: SupportedTheme = "vitesse-dark"; export type ShikiThemeProp = SupportedTheme | { light: SupportedTheme; dark: SupportedTheme }; @@ -144,7 +144,7 @@ export function isSupportedLang(lang: string): boolean { // --------------------------------------------------------------------------- const highlighter: HighlighterCore = createHighlighterCoreSync({ - themes: [githubDark, githubLight], + themes: [vitesseDark, vitesseLight], langs: [langTypescript, langJavascript, langTsx, langJsx, langJson, langShellscript], engine: createJavaScriptRegexEngine({ forgiving: true }), }); diff --git a/packages/react/src/pages/runs.tsx b/packages/react/src/pages/runs.tsx new file mode 100644 index 000000000..6d56f0383 --- /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 "@executor/sdk"; + +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);