From a33385721eadaf703c6b2c28f3f4f1d62f8ed798 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:02:29 +0530 Subject: [PATCH 01/35] feat(sdk): add execution history types and ExecutionStore interface Adds Execution, ExecutionStatus, ExecutionInteraction types and ExecutionId/ExecutionInteractionId branded IDs. Introduces the ExecutionStore interface with create, update, list, get, recordInteraction, resolveInteraction, and sweep methods. --- packages/core/sdk/src/executions.ts | 269 ++++++++++++++++++++++++++++ packages/core/sdk/src/ids.ts | 6 + packages/core/sdk/src/index.ts | 30 +++- packages/core/sdk/src/promise.ts | 6 + 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 packages/core/sdk/src/executions.ts diff --git a/packages/core/sdk/src/executions.ts b/packages/core/sdk/src/executions.ts new file mode 100644 index 000000000..90a8de60b --- /dev/null +++ b/packages/core/sdk/src/executions.ts @@ -0,0 +1,269 @@ +import { Context, Effect, Schema } from "effect"; + +import { ExecutionId, ExecutionInteractionId, 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), + 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 type CreateExecutionInput = Omit; +export type UpdateExecutionInput = Partial< + Pick< + Execution, + | "status" + | "code" + | "resultJson" + | "errorText" + | "logsJson" + | "startedAt" + | "completedAt" + | "updatedAt" + > +>; + +export type CreateExecutionInteractionInput = Omit; +export type UpdateExecutionInteractionInput = Partial< + Pick< + ExecutionInteraction, + "status" | "kind" | "purpose" | "payloadJson" | "responseJson" | "responsePrivateJson" | "updatedAt" + > +>; + +export interface ExecutionListOptions { + readonly limit: number; + readonly cursor?: string; + readonly statusFilter?: readonly ExecutionStatus[]; + readonly timeRange?: { + readonly from?: number; + readonly to?: number; + }; + readonly codeQuery?: string; + /** + * When true, the store computes and returns {@link ExecutionListMeta} + * alongside the page. Typically requested only on the first page + * (cursor === undefined) since the metadata is stable across pagination. + */ + 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; +} + +/** + * Metadata describing the full filtered result set, independent of the + * current page. Used to drive status facets, counts, and the timeline + * chart above the list. + */ +export interface ExecutionListMeta { + readonly totalRowCount: number; + readonly filterRowCount: number; + readonly chartBucketMs: number; + readonly chartData: readonly ExecutionChartBucket[]; + readonly statusCounts: Readonly>; +} + +export const EXECUTION_STATUS_KEYS = [ + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +] as const satisfies readonly ExecutionStatus[]; + +/** + * Pick a bucket size in milliseconds from a time range span. Mirrors + * openstatus-data-table's calculatePeriod, just expressed as duration. + */ +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, +}); + +/** + * Build a chart + counts from a flat, already-filtered list of + * executions. Shared by every {@link ExecutionStore} implementation so + * the chart math lives in one place. + */ +export const buildExecutionListMeta = ( + filtered: readonly Execution[], + timeRange: ExecutionListOptions["timeRange"], + totalRowCount: number, +): ExecutionListMeta => { + const filterRowCount = filtered.length; + + const statusCounts: Record = { + pending: 0, + running: 0, + waiting_for_interaction: 0, + completed: 0, + failed: 0, + cancelled: 0, + }; + for (const execution of filtered) { + statusCounts[execution.status] += 1; + } + + if (filterRowCount === 0) { + return { + totalRowCount, + filterRowCount, + chartBucketMs: pickChartBucketMs(0), + chartData: [], + statusCounts, + }; + } + + 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, + }; +}; + +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; + readonly sweep: () => Effect.Effect; + } +>() {} diff --git a/packages/core/sdk/src/ids.ts b/packages/core/sdk/src/ids.ts index 4a6cacc4b..e64d79473 100644 --- a/packages/core/sdk/src/ids.ts +++ b/packages/core/sdk/src/ids.ts @@ -11,3 +11,9 @@ export type SecretId = typeof SecretId.Type; export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId")); export type PolicyId = typeof PolicyId.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; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 739e02b00..24928df8b 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -1,5 +1,12 @@ // IDs -export { ScopeId, ToolId, SecretId, PolicyId } from "./ids"; +export { + ScopeId, + ToolId, + SecretId, + PolicyId, + ExecutionId, + ExecutionInteractionId, +} from "./ids"; // Errors export { @@ -51,6 +58,26 @@ export { SecretRef, SetSecretInput, SecretStore, type SecretProvider } from "./s // Policies export { Policy, PolicyAction, PolicyCheckInput, PolicyEngine } from "./policies"; +// Executions +export { + ExecutionStatus, + Execution, + ExecutionInteractionStatus, + ExecutionInteraction, + ExecutionStore, + EXECUTION_STATUS_KEYS, + pickChartBucketMs, + buildExecutionListMeta, + type CreateExecutionInput, + type UpdateExecutionInput, + type CreateExecutionInteractionInput, + type UpdateExecutionInteractionInput, + type ExecutionListItem, + type ExecutionListOptions, + type ExecutionListMeta, + type ExecutionChartBucket, +} from "./executions"; + // Scope export { Scope } from "./scope"; @@ -98,6 +125,7 @@ export { export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store"; export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine"; +export { makeInMemoryExecutionStore } from "./in-memory/execution-store"; // Testing export { makeTestConfig } from "./testing"; diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index a4f0333d5..bebc8ab05 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -40,10 +40,16 @@ export { SecretId, ScopeId, PolicyId, + ExecutionId, + ExecutionInteractionId, Source, SourceDetectionResult, SecretRef, Policy, + Execution, + ExecutionInteraction, + ExecutionStatus, + ExecutionInteractionStatus, Scope, FormElicitation, UrlElicitation, From 03646523536346c6c08aa78d91f5ed38d32e2b4f Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:02:48 +0530 Subject: [PATCH 02/35] feat(storage): add ExecutionStore implementations for SQLite and PostgreSQL Implements ExecutionStore for file-based storage (SQLite) and PostgreSQL (Drizzle). Includes 30-day retention sweep for expired executions and their interactions. New executions and execution_interactions tables with appropriate indexes. --- .../core/storage-file/src/execution-store.ts | 353 +++++++++++++++++ packages/core/storage-file/src/index.test.ts | 119 +++++- packages/core/storage-file/src/index.ts | 7 +- .../src/migrations/0002_executions.ts | 48 +++ .../core/storage-file/src/migrations/index.ts | 2 + .../drizzle/0001_execution_history.sql | 34 ++ .../drizzle/meta/_journal.json | 7 + .../storage-postgres/src/execution-store.ts | 354 ++++++++++++++++++ .../core/storage-postgres/src/index.test.ts | 115 +++++- packages/core/storage-postgres/src/index.ts | 3 + packages/core/storage-postgres/src/schema.ts | 45 +++ 11 files changed, 1084 insertions(+), 3 deletions(-) create mode 100644 packages/core/storage-file/src/execution-store.ts create mode 100644 packages/core/storage-file/src/migrations/0002_executions.ts create mode 100644 packages/core/storage-postgres/drizzle/0001_execution_history.sql create mode 100644 packages/core/storage-postgres/src/execution-store.ts diff --git a/packages/core/storage-file/src/execution-store.ts b/packages/core/storage-file/src/execution-store.ts new file mode 100644 index 000000000..e8382e7bc --- /dev/null +++ b/packages/core/storage-file/src/execution-store.ts @@ -0,0 +1,353 @@ +import { Effect } from "effect"; +import { randomUUID } from "node:crypto"; +import type * as SqlClient from "@effect/sql/SqlClient"; + +import { + Execution, + ExecutionId, + ExecutionInteraction, + ExecutionInteractionId, + buildExecutionListMeta, + type CreateExecutionInput, + type CreateExecutionInteractionInput, + type ExecutionListItem, + type ExecutionListOptions, + type UpdateExecutionInput, + type UpdateExecutionInteractionInput, + type ExecutionStatus, + ScopeId, +} from "@executor/sdk"; + +import { absorbSql } from "./sql-utils"; + +const encodeCursor = (execution: Execution): string => + encodeURIComponent(JSON.stringify({ createdAt: execution.createdAt, id: execution.id })); + +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; + } +}; + +type ExecutionRow = { + id: string; + scope_id: string; + status: string; + code: string; + result_json: string | null; + error_text: string | null; + logs_json: string | null; + started_at: number | null; + completed_at: number | null; + created_at: number; + updated_at: number; +}; + +type ExecutionInteractionRow = { + id: string; + execution_id: string; + status: string; + kind: string; + purpose: string; + payload_json: string; + response_json: string | null; + response_private_json: string | null; + created_at: number; + updated_at: number; +}; + +const toExecution = (row: ExecutionRow): Execution => + new Execution({ + id: ExecutionId.make(row.id), + scopeId: ScopeId.make(row.scope_id), + status: row.status as ExecutionStatus, + code: row.code, + resultJson: row.result_json, + errorText: row.error_text, + logsJson: row.logs_json, + startedAt: row.started_at, + completedAt: row.completed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + +const toInteraction = (row: ExecutionInteractionRow): ExecutionInteraction => + new ExecutionInteraction({ + id: ExecutionInteractionId.make(row.id), + executionId: ExecutionId.make(row.execution_id), + status: row.status as ExecutionInteraction["status"], + kind: row.kind, + purpose: row.purpose, + payloadJson: row.payload_json, + responseJson: row.response_json, + responsePrivateJson: row.response_private_json, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + +const matchesFilters = (execution: Execution, options: ExecutionListOptions): boolean => { + if (options.statusFilter && options.statusFilter.length > 0) { + const allowed = new Set(options.statusFilter); + if (!allowed.has(execution.status)) { + return false; + } + } + + if (options.timeRange?.from !== undefined && execution.createdAt < options.timeRange.from) { + return false; + } + + if (options.timeRange?.to !== undefined && execution.createdAt > options.timeRange.to) { + return false; + } + + if (options.codeQuery) { + const query = options.codeQuery.trim().toLowerCase(); + if (query.length > 0 && !execution.code.toLowerCase().includes(query)) { + return false; + } + } + + return true; +}; + +export const makeSqliteExecutionStore = (sql: SqlClient.SqlClient) => { + const getPendingInteractions = (): Effect.Effect> => + absorbSql( + Effect.gen(function* () { + const rows = yield* sql` + SELECT * + FROM execution_interactions + WHERE status = 'pending' + ORDER BY created_at DESC, id DESC + `; + + const map = new Map(); + for (const row of rows) { + const interaction = toInteraction(row); + if (!map.has(interaction.executionId)) { + map.set(interaction.executionId, interaction); + } + } + return map; + }), + ); + + return { + create: (input: CreateExecutionInput) => + absorbSql( + Effect.gen(function* () { + const id = ExecutionId.make(`exec_${randomUUID()}`); + yield* sql` + INSERT INTO executions ( + id, scope_id, status, code, result_json, error_text, logs_json, + started_at, completed_at, created_at, updated_at + ) VALUES ( + ${id}, + ${input.scopeId}, + ${input.status}, + ${input.code}, + ${input.resultJson}, + ${input.errorText}, + ${input.logsJson}, + ${input.startedAt}, + ${input.completedAt}, + ${input.createdAt}, + ${input.updatedAt} + ) + `; + + return new Execution({ id, ...input }); + }), + ), + + update: (id: ExecutionId, patch: UpdateExecutionInput) => + absorbSql( + Effect.gen(function* () { + const currentRows = yield* sql`SELECT * FROM executions WHERE id = ${id}`; + const current = currentRows[0]; + if (!current) { + return yield* Effect.die(new Error(`Execution not found: ${id}`)); + } + + const next = new Execution({ + ...toExecution(current), + ...patch, + id, + scopeId: ScopeId.make(current.scope_id), + }); + + yield* sql` + UPDATE executions SET + status = ${next.status}, + code = ${next.code}, + result_json = ${next.resultJson}, + error_text = ${next.errorText}, + logs_json = ${next.logsJson}, + started_at = ${next.startedAt}, + completed_at = ${next.completedAt}, + updated_at = ${next.updatedAt} + WHERE id = ${id} + `; + + return next; + }), + ), + + list: (scopeId: ScopeId, options: ExecutionListOptions) => + absorbSql( + Effect.gen(function* () { + const limit = Math.max(1, options.limit); + const allRows = yield* sql` + SELECT * + FROM executions + WHERE scope_id = ${scopeId} + ORDER BY created_at DESC, id DESC + `; + + const allInScope = allRows.map(toExecution); + const allFiltered = allInScope.filter((execution) => + matchesFilters(execution, options), + ); + + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + const afterCursor = allFiltered.filter((execution) => + cursor + ? execution.createdAt < cursor.createdAt || + (execution.createdAt === cursor.createdAt && execution.id < cursor.id) + : true, + ); + + const executions = afterCursor.slice(0, limit); + const pendingInteractions = yield* getPendingInteractions(); + + const items: ExecutionListItem[] = executions.map((execution) => ({ + ...execution, + pendingInteraction: pendingInteractions.get(execution.id) ?? null, + })); + + const hasMore = afterCursor.length > limit; + const last = executions.at(-1); + const meta = options.includeMeta + ? buildExecutionListMeta(allFiltered, options.timeRange, allInScope.length) + : undefined; + + return { + executions: items, + nextCursor: hasMore && last ? encodeCursor(last) : undefined, + meta, + }; + }), + ), + + get: (id: ExecutionId) => + absorbSql( + Effect.gen(function* () { + const rows = yield* sql`SELECT * FROM executions WHERE id = ${id}`; + const row = rows[0]; + if (!row) { + return null; + } + + const execution = toExecution(row); + const pendingRows = yield* sql` + SELECT * + FROM execution_interactions + WHERE execution_id = ${id} AND status = 'pending' + ORDER BY created_at DESC, id DESC + LIMIT 1 + `; + + return { + execution, + pendingInteraction: pendingRows[0] ? toInteraction(pendingRows[0]) : null, + }; + }), + ), + + recordInteraction: (_executionId: ExecutionId, interaction: CreateExecutionInteractionInput) => + absorbSql( + Effect.gen(function* () { + const id = ExecutionInteractionId.make(`interaction_${randomUUID()}`); + yield* sql` + INSERT INTO execution_interactions ( + id, execution_id, status, kind, purpose, payload_json, + response_json, response_private_json, created_at, updated_at + ) VALUES ( + ${id}, + ${interaction.executionId}, + ${interaction.status}, + ${interaction.kind}, + ${interaction.purpose}, + ${interaction.payloadJson}, + ${interaction.responseJson}, + ${interaction.responsePrivateJson}, + ${interaction.createdAt}, + ${interaction.updatedAt} + ) + `; + + return new ExecutionInteraction({ id, ...interaction }); + }), + ), + + resolveInteraction: (interactionId: ExecutionInteractionId, patch: UpdateExecutionInteractionInput) => + absorbSql( + Effect.gen(function* () { + const currentRows = yield* sql` + SELECT * FROM execution_interactions WHERE id = ${interactionId} + `; + const current = currentRows[0]; + if (!current) { + return yield* Effect.die(new Error(`Execution interaction not found: ${interactionId}`)); + } + + const next = new ExecutionInteraction({ + ...toInteraction(current), + ...patch, + id: interactionId, + executionId: ExecutionId.make(current.execution_id), + }); + + yield* sql` + UPDATE execution_interactions SET + status = ${next.status}, + kind = ${next.kind}, + purpose = ${next.purpose}, + payload_json = ${next.payloadJson}, + response_json = ${next.responseJson}, + response_private_json = ${next.responsePrivateJson}, + updated_at = ${next.updatedAt} + WHERE id = ${interactionId} + `; + + return next; + }), + ), + + sweep: () => + absorbSql( + Effect.gen(function* () { + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + yield* sql`DELETE FROM execution_interactions WHERE execution_id IN ( + SELECT id FROM executions WHERE created_at < ${cutoff} + )`; + yield* sql`DELETE FROM executions WHERE created_at < ${cutoff}`; + }), + ), + }; +}; diff --git a/packages/core/storage-file/src/index.test.ts b/packages/core/storage-file/src/index.test.ts index 6c5a879cd..fc5408142 100644 --- a/packages/core/storage-file/src/index.test.ts +++ b/packages/core/storage-file/src/index.test.ts @@ -4,13 +4,20 @@ import { Effect } from "effect"; import { SqliteClient } from "@effect/sql-sqlite-node"; import * as SqlClient from "@effect/sql/SqlClient"; -import { ScopeId, ToolId, SecretId, makeInMemorySecretProvider, scopeKv } from "@executor/sdk"; +import { + ScopeId, + ToolId, + SecretId, + makeInMemorySecretProvider, + scopeKv, +} from "@executor/sdk"; import type { Kv } from "@executor/sdk"; import { migrate } from "./schema"; import { makeSqliteKv } from "./plugin-kv"; import { makeKvToolRegistry } from "./tool-registry"; import { makeKvSecretStore } from "./secret-store"; import { makeKvPolicyEngine } from "./policy-engine"; +import { makeSqliteExecutionStore } from "./execution-store"; // --------------------------------------------------------------------------- // Test layer: in-memory SQLite + migrated KV @@ -261,3 +268,113 @@ describe("KvPolicyEngine", () => { ), ); }); + +describe("SqliteExecutionStore", () => { + it.effect("lists scoped executions with filters and pending interactions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* migrate.pipe(Effect.catchAll((e) => Effect.die(e))); + const store = makeSqliteExecutionStore(sql); + const scopeId = ScopeId.make(`scope-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const now = Date.now(); + + const first = yield* store.create({ + scopeId, + status: "completed", + code: "return 1", + resultJson: "1", + errorText: null, + logsJson: null, + startedAt: now - 20, + completedAt: now - 10, + createdAt: now - 20, + updatedAt: now - 10, + }); + const second = yield* store.create({ + scopeId, + status: "waiting_for_interaction", + code: "return await tools.api.singleApproval({})", + resultJson: null, + errorText: null, + logsJson: null, + startedAt: now, + completedAt: null, + createdAt: now, + updatedAt: now, + }); + + yield* store.recordInteraction(second.id, { + executionId: second.id, + status: "pending", + kind: "form", + purpose: "Approval required", + payloadJson: "{}", + responseJson: null, + responsePrivateJson: null, + createdAt: now, + updatedAt: now, + }); + + const filtered = yield* store.list(scopeId, { + limit: 1, + statusFilter: ["waiting_for_interaction"], + codeQuery: "singleApproval", + }); + expect(filtered.executions).toHaveLength(1); + expect(filtered.executions[0]?.id).toBe(second.id); + expect(filtered.executions[0]?.pendingInteraction?.purpose).toBe("Approval required"); + + const firstPage = yield* store.list(scopeId, { + limit: 1, + }); + expect(firstPage.executions[0]?.id).toBe(second.id); + + const pageTwo = yield* store.list(scopeId, { + limit: 1, + cursor: firstPage.nextCursor, + }); + expect(pageTwo.executions).toHaveLength(1); + expect(pageTwo.executions[0]?.id).toBe(first.id); + }).pipe(Effect.provide(TestSqlLayer)), + ); + + it.effect("sweeps expired executions and their interactions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* migrate.pipe(Effect.catchAll((e) => Effect.die(e))); + const store = makeSqliteExecutionStore(sql); + const scopeId = ScopeId.make(`scope-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const expiredAt = Date.now() - 31 * 24 * 60 * 60 * 1000; + + const expired = yield* store.create({ + scopeId, + status: "failed", + code: "throw new Error('boom')", + resultJson: null, + errorText: "boom", + logsJson: null, + startedAt: expiredAt, + completedAt: expiredAt, + createdAt: expiredAt, + updatedAt: expiredAt, + }); + + yield* store.recordInteraction(expired.id, { + executionId: expired.id, + status: "pending", + kind: "form", + purpose: "Expired interaction", + payloadJson: "{}", + responseJson: null, + responsePrivateJson: null, + createdAt: expiredAt, + updatedAt: expiredAt, + }); + + yield* store.sweep(); + + const result = yield* store.get(expired.id); + expect(result).toBeNull(); + }).pipe(Effect.provide(TestSqlLayer)), + ); +}); diff --git a/packages/core/storage-file/src/index.ts b/packages/core/storage-file/src/index.ts index 2f1b52303..4281fcb43 100644 --- a/packages/core/storage-file/src/index.ts +++ b/packages/core/storage-file/src/index.ts @@ -23,13 +23,15 @@ import { createHash } from "node:crypto"; import { basename } from "node:path"; +import type * as SqlClient from "@effect/sql/SqlClient"; -import { scopeKv, ScopeId, makeInMemorySourceRegistry } from "@executor/sdk"; +import { scopeKv, ScopeId, makeInMemoryExecutionStore, makeInMemorySourceRegistry } from "@executor/sdk"; import type { Kv, Scope, ExecutorConfig, ExecutorPlugin } from "@executor/sdk"; import { makeKvToolRegistry } from "./tool-registry"; import { makeKvSecretStore } from "./secret-store"; import { makeKvPolicyEngine } from "./policy-engine"; +import { makeSqliteExecutionStore } from "./execution-store"; /** * Derive a URL-safe scope ID from a folder path. @@ -45,6 +47,7 @@ export { makeSqliteKv, makeInMemoryKv } from "./plugin-kv"; export { makeKvToolRegistry } from "./tool-registry"; export { makeKvSecretStore } from "./secret-store"; export { makeKvPolicyEngine } from "./policy-engine"; +export { makeSqliteExecutionStore } from "./execution-store"; export { migrate } from "./schema"; // --------------------------------------------------------------------------- @@ -56,6 +59,7 @@ export const makeKvConfig = => { const cwd = options.cwd; @@ -75,6 +79,7 @@ export const makeKvConfig = > = Effect.succeed([ [1, "initial", Effect.succeed(migration_0001)], + [2, "executions", Effect.succeed(migration_0002)], ]); diff --git a/packages/core/storage-postgres/drizzle/0001_execution_history.sql b/packages/core/storage-postgres/drizzle/0001_execution_history.sql new file mode 100644 index 000000000..4b84291af --- /dev/null +++ b/packages/core/storage-postgres/drizzle/0001_execution_history.sql @@ -0,0 +1,34 @@ +CREATE TABLE "executions" ( + "id" text NOT NULL, + "organization_id" text NOT NULL, + "scope_id" text NOT NULL, + "status" text NOT NULL, + "code" text NOT NULL, + "result_json" text, + "error_text" text, + "logs_json" text, + "started_at" bigint, + "completed_at" bigint, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + CONSTRAINT "executions_id_organization_id_pk" PRIMARY KEY("id","organization_id") +); +--> statement-breakpoint +CREATE TABLE "execution_interactions" ( + "id" text NOT NULL, + "organization_id" text NOT NULL, + "execution_id" text NOT NULL, + "status" text NOT NULL, + "kind" text NOT NULL, + "purpose" text NOT NULL, + "payload_json" text NOT NULL, + "response_json" text, + "response_private_json" text, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + CONSTRAINT "execution_interactions_id_organization_id_pk" PRIMARY KEY("id","organization_id") +); +--> statement-breakpoint +CREATE INDEX "executions_scope_created_at_idx" ON "executions" USING btree ("scope_id","created_at","id"); +--> statement-breakpoint +CREATE INDEX "execution_interactions_execution_status_idx" ON "execution_interactions" USING btree ("execution_id","status"); diff --git a/packages/core/storage-postgres/drizzle/meta/_journal.json b/packages/core/storage-postgres/drizzle/meta/_journal.json index a73e8dc22..8c4a21db8 100644 --- a/packages/core/storage-postgres/drizzle/meta/_journal.json +++ b/packages/core/storage-postgres/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775718391348, "tag": "0000_magical_dreaming_celestial", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775923200000, + "tag": "0001_execution_history", + "breakpoints": true } ] } diff --git a/packages/core/storage-postgres/src/execution-store.ts b/packages/core/storage-postgres/src/execution-store.ts new file mode 100644 index 000000000..5e75363fd --- /dev/null +++ b/packages/core/storage-postgres/src/execution-store.ts @@ -0,0 +1,354 @@ +import { Effect } from "effect"; +import { randomUUID } from "node:crypto"; +import { and, desc, eq, gte, ilike, inArray, lt, lte, or } from "drizzle-orm"; + +import { + Execution, + ExecutionId, + ExecutionInteraction, + ExecutionInteractionId, + buildExecutionListMeta, + type CreateExecutionInput, + type CreateExecutionInteractionInput, + type ExecutionListItem, + type ExecutionListOptions, + type UpdateExecutionInput, + type UpdateExecutionInteractionInput, + ScopeId, +} from "@executor/sdk"; +import type { DrizzleDb } from "./types"; +import { executionInteractions, executions } from "./schema"; + +const encodeCursor = (execution: Execution): string => + encodeURIComponent(JSON.stringify({ createdAt: execution.createdAt, id: execution.id })); + +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; + } +}; + +const toExecution = (row: typeof executions.$inferSelect): Execution => + new Execution({ + id: ExecutionId.make(row.id), + scopeId: ScopeId.make(row.scopeId), + status: row.status as Execution["status"], + code: row.code, + resultJson: row.resultJson, + errorText: row.errorText, + logsJson: row.logsJson, + startedAt: row.startedAt, + completedAt: row.completedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + +const toInteraction = (row: typeof executionInteractions.$inferSelect): ExecutionInteraction => + new ExecutionInteraction({ + id: ExecutionInteractionId.make(row.id), + executionId: ExecutionId.make(row.executionId), + status: row.status as ExecutionInteraction["status"], + kind: row.kind, + purpose: row.purpose, + payloadJson: row.payloadJson, + responseJson: row.responseJson, + responsePrivateJson: row.responsePrivateJson, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + +export const makePgExecutionStore = (db: DrizzleDb, organizationId: string) => { + return { + create: (input: CreateExecutionInput) => + Effect.tryPromise(async () => { + const id = ExecutionId.make(`exec_${randomUUID()}`); + await db.insert(executions).values({ + id, + scopeId: input.scopeId, + organizationId, + status: input.status, + code: input.code, + resultJson: input.resultJson, + errorText: input.errorText, + logsJson: input.logsJson, + startedAt: input.startedAt, + completedAt: input.completedAt, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return new Execution({ id, ...input }); + }).pipe(Effect.orDie), + + update: (id: ExecutionId, patch: UpdateExecutionInput) => + Effect.tryPromise(async () => { + const currentRows = await db + .select() + .from(executions) + .where(and(eq(executions.id, id), eq(executions.organizationId, organizationId))); + const current = currentRows[0]; + if (!current) { + throw new Error(`Execution not found: ${id}`); + } + + const next = new Execution({ + ...toExecution(current), + ...patch, + id, + scopeId: ScopeId.make(current.scopeId), + }); + + await db + .update(executions) + .set({ + status: next.status, + code: next.code, + resultJson: next.resultJson, + errorText: next.errorText, + logsJson: next.logsJson, + startedAt: next.startedAt, + completedAt: next.completedAt, + updatedAt: next.updatedAt, + }) + .where(and(eq(executions.id, id), eq(executions.organizationId, organizationId))); + + return next; + }).pipe(Effect.orDie), + + list: (scopeId: ScopeId, options: ExecutionListOptions) => + Effect.tryPromise(async () => { + const limit = Math.max(1, options.limit); + const scopeConditions = [ + eq(executions.organizationId, organizationId), + eq(executions.scopeId, scopeId), + ]; + + const filterConditions = [...scopeConditions]; + if (options.statusFilter && options.statusFilter.length > 0) { + filterConditions.push(inArray(executions.status, [...options.statusFilter])); + } + if (options.timeRange?.from !== undefined) { + filterConditions.push(gte(executions.createdAt, options.timeRange.from)); + } + if (options.timeRange?.to !== undefined) { + filterConditions.push(lte(executions.createdAt, options.timeRange.to)); + } + if (options.codeQuery && options.codeQuery.trim().length > 0) { + filterConditions.push(ilike(executions.code, `%${options.codeQuery.trim()}%`)); + } + + const conditions = [...filterConditions]; + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + if (cursor) { + conditions.push( + or( + lt(executions.createdAt, cursor.createdAt), + and(eq(executions.createdAt, cursor.createdAt), lt(executions.id, cursor.id)), + )!, + ); + } + + const rows = await db + .select() + .from(executions) + .where(and(...conditions)) + .orderBy(desc(executions.createdAt), desc(executions.id)) + .limit(limit + 1); + + const pageRows = rows.slice(0, limit); + const executionRows = pageRows.map(toExecution); + const executionIds = executionRows.map((execution) => execution.id); + const pendingRows = + executionIds.length === 0 + ? [] + : await db + .select() + .from(executionInteractions) + .where( + and( + eq(executionInteractions.organizationId, organizationId), + eq(executionInteractions.status, "pending"), + inArray(executionInteractions.executionId, executionIds), + ), + ) + .orderBy(desc(executionInteractions.createdAt), desc(executionInteractions.id)); + + const pendingByExecution = new Map(); + for (const row of pendingRows) { + const interaction = toInteraction(row); + if (!pendingByExecution.has(interaction.executionId)) { + pendingByExecution.set(interaction.executionId, interaction); + } + } + + const items: ExecutionListItem[] = executionRows.map((execution) => ({ + ...execution, + pendingInteraction: pendingByExecution.get(execution.id) ?? null, + })); + + const hasMore = rows.length > limit; + const last = executionRows.at(-1); + + let meta; + if (options.includeMeta) { + // Meta summarizes the full filtered set, independent of pagination. + // Two queries: filtered rows for bucketing, and an unfiltered + // scope count for totalRowCount. + const [filteredForMeta, scopeTotals] = await Promise.all([ + db + .select() + .from(executions) + .where(and(...filterConditions)) + .orderBy(desc(executions.createdAt), desc(executions.id)), + db + .select({ id: executions.id }) + .from(executions) + .where(and(...scopeConditions)), + ]); + + meta = buildExecutionListMeta( + filteredForMeta.map(toExecution), + options.timeRange, + scopeTotals.length, + ); + } + + return { + executions: items, + nextCursor: hasMore && last ? encodeCursor(last) : undefined, + meta, + }; + }).pipe(Effect.orDie), + + get: (id: ExecutionId) => + Effect.tryPromise(async () => { + const executionRows = await db + .select() + .from(executions) + .where(and(eq(executions.id, id), eq(executions.organizationId, organizationId))); + const row = executionRows[0]; + if (!row) { + return null; + } + + const pendingRows = await db + .select() + .from(executionInteractions) + .where( + and( + eq(executionInteractions.executionId, id), + eq(executionInteractions.organizationId, organizationId), + eq(executionInteractions.status, "pending"), + ), + ) + .orderBy(desc(executionInteractions.createdAt), desc(executionInteractions.id)) + .limit(1); + + return { + execution: toExecution(row), + pendingInteraction: pendingRows[0] ? toInteraction(pendingRows[0]) : null, + }; + }).pipe(Effect.orDie), + + recordInteraction: (_executionId: ExecutionId, interaction: CreateExecutionInteractionInput) => + Effect.tryPromise(async () => { + const id = ExecutionInteractionId.make(`interaction_${randomUUID()}`); + await db.insert(executionInteractions).values({ + id, + executionId: interaction.executionId, + organizationId, + status: interaction.status, + kind: interaction.kind, + purpose: interaction.purpose, + payloadJson: interaction.payloadJson, + responseJson: interaction.responseJson, + responsePrivateJson: interaction.responsePrivateJson, + createdAt: interaction.createdAt, + updatedAt: interaction.updatedAt, + }); + return new ExecutionInteraction({ id, ...interaction }); + }).pipe(Effect.orDie), + + resolveInteraction: (interactionId: ExecutionInteractionId, patch: UpdateExecutionInteractionInput) => + Effect.tryPromise(async () => { + const currentRows = await db + .select() + .from(executionInteractions) + .where( + and( + eq(executionInteractions.id, interactionId), + eq(executionInteractions.organizationId, organizationId), + ), + ); + const current = currentRows[0]; + if (!current) { + throw new Error(`Execution interaction not found: ${interactionId}`); + } + + const next = new ExecutionInteraction({ + ...toInteraction(current), + ...patch, + id: interactionId, + executionId: ExecutionId.make(current.executionId), + }); + + await db + .update(executionInteractions) + .set({ + status: next.status, + kind: next.kind, + purpose: next.purpose, + payloadJson: next.payloadJson, + responseJson: next.responseJson, + responsePrivateJson: next.responsePrivateJson, + updatedAt: next.updatedAt, + }) + .where( + and( + eq(executionInteractions.id, interactionId), + eq(executionInteractions.organizationId, organizationId), + ), + ); + + return next; + }).pipe(Effect.orDie), + + sweep: () => + Effect.tryPromise(async () => { + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + const expiredExecutions = await db + .select({ id: executions.id }) + .from(executions) + .where(and(eq(executions.organizationId, organizationId), lt(executions.createdAt, cutoff))); + const expiredIds = expiredExecutions.map((row) => row.id); + + if (expiredIds.length > 0) { + await db + .delete(executionInteractions) + .where( + and( + eq(executionInteractions.organizationId, organizationId), + inArray(executionInteractions.executionId, expiredIds), + ), + ); + } + + await db + .delete(executions) + .where(and(eq(executions.organizationId, organizationId), lt(executions.createdAt, cutoff))); + }).pipe(Effect.orDie), + }; +}; diff --git a/packages/core/storage-postgres/src/index.test.ts b/packages/core/storage-postgres/src/index.test.ts index a9029df8f..add01e2d6 100644 --- a/packages/core/storage-postgres/src/index.test.ts +++ b/packages/core/storage-postgres/src/index.test.ts @@ -19,6 +19,7 @@ import { } from "@executor/sdk"; import { makePgConfig } from "./index"; +import { makePgExecutionStore } from "./execution-store"; import { makePgKv } from "./pg-kv"; import * as schema from "./schema"; @@ -43,7 +44,9 @@ beforeAll(async () => { }); beforeEach(async () => { - await db.execute(sql`TRUNCATE plugin_kv, policies, secrets, tool_definitions, tools, sources`); + await db.execute( + sql`TRUNCATE execution_interactions, executions, plugin_kv, policies, secrets, tool_definitions, tools, sources`, + ); }); afterAll(async () => { @@ -368,3 +371,113 @@ describe("Executor with Postgres storage", () => { }), ); }); + +describe("PostgresExecutionStore", () => { + it.effect("lists scoped executions with filters and pending interactions", () => + Effect.gen(function* () { + const store = makePgExecutionStore(db, TEST_ORG_ID); + const scopeId = ScopeId.make( + `${TEST_ORG_ID}-runs-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const now = Date.now(); + + const first = yield* store.create({ + scopeId, + status: "completed", + code: "return 1", + resultJson: "1", + errorText: null, + logsJson: null, + startedAt: now - 20, + completedAt: now - 10, + createdAt: now - 20, + updatedAt: now - 10, + }); + const second = yield* store.create({ + scopeId, + status: "waiting_for_interaction", + code: "return await tools.api.singleApproval({})", + resultJson: null, + errorText: null, + logsJson: null, + startedAt: now, + completedAt: null, + createdAt: now, + updatedAt: now, + }); + + yield* store.recordInteraction(second.id, { + executionId: second.id, + status: "pending", + kind: "form", + purpose: "Approval required", + payloadJson: "{}", + responseJson: null, + responsePrivateJson: null, + createdAt: now, + updatedAt: now, + }); + + const filtered = yield* store.list(scopeId, { + limit: 1, + statusFilter: ["waiting_for_interaction"], + codeQuery: "singleApproval", + }); + expect(filtered.executions).toHaveLength(1); + expect(filtered.executions[0]?.id).toBe(second.id); + expect(filtered.executions[0]?.pendingInteraction?.purpose).toBe("Approval required"); + + const firstPage = yield* store.list(scopeId, { + limit: 1, + }); + expect(firstPage.executions[0]?.id).toBe(second.id); + + const pageTwo = yield* store.list(scopeId, { + limit: 1, + cursor: firstPage.nextCursor, + }); + expect(pageTwo.executions).toHaveLength(1); + expect(pageTwo.executions[0]?.id).toBe(first.id); + }), + ); + + it.effect("sweeps expired executions and their interactions", () => + Effect.gen(function* () { + const store = makePgExecutionStore(db, TEST_ORG_ID); + const scopeId = ScopeId.make( + `${TEST_ORG_ID}-runs-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const expiredAt = Date.now() - 31 * 24 * 60 * 60 * 1000; + + const expired = yield* store.create({ + scopeId, + status: "failed", + code: "throw new Error('boom')", + resultJson: null, + errorText: "boom", + logsJson: null, + startedAt: expiredAt, + completedAt: expiredAt, + createdAt: expiredAt, + updatedAt: expiredAt, + }); + + yield* store.recordInteraction(expired.id, { + executionId: expired.id, + status: "pending", + kind: "form", + purpose: "Expired interaction", + payloadJson: "{}", + responseJson: null, + responsePrivateJson: null, + createdAt: expiredAt, + updatedAt: expiredAt, + }); + + yield* store.sweep(); + + const result = yield* store.get(expired.id); + expect(result).toBeNull(); + }), + ); +}); diff --git a/packages/core/storage-postgres/src/index.ts b/packages/core/storage-postgres/src/index.ts index 40ce62f8c..e17f467d8 100644 --- a/packages/core/storage-postgres/src/index.ts +++ b/packages/core/storage-postgres/src/index.ts @@ -24,11 +24,13 @@ import type { DrizzleDb } from "./types"; import { makePgToolRegistry } from "./tool-registry"; import { makePgSecretStore } from "./secret-store"; import { makePgPolicyEngine } from "./policy-engine"; +import { makePgExecutionStore } from "./execution-store"; export { makePgKv } from "./pg-kv"; export { makePgToolRegistry } from "./tool-registry"; export { makePgSecretStore } from "./secret-store"; export { makePgPolicyEngine } from "./policy-engine"; +export { makePgExecutionStore } from "./execution-store"; export { encrypt, decrypt } from "./crypto"; export type { DrizzleDb } from "./types"; @@ -57,6 +59,7 @@ export const makePgConfig = [primaryKey({ columns: [table.organizationId, table.namespace, table.key] })], ); + +export const executions = pgTable( + "executions", + { + id: text("id").notNull(), + organizationId: text("organization_id").notNull(), + scopeId: text("scope_id").notNull(), + status: text("status").notNull(), + code: text("code").notNull(), + resultJson: text("result_json"), + errorText: text("error_text"), + logsJson: text("logs_json"), + startedAt: bigint("started_at", { mode: "number" }), + completedAt: bigint("completed_at", { mode: "number" }), + createdAt: bigint("created_at", { mode: "number" }).notNull(), + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.id, table.organizationId] }), + index("executions_scope_created_at_idx").on(table.scopeId, table.createdAt, table.id), + ], +); + +export const executionInteractions = pgTable( + "execution_interactions", + { + id: text("id").notNull(), + organizationId: text("organization_id").notNull(), + executionId: text("execution_id").notNull(), + status: text("status").notNull(), + kind: text("kind").notNull(), + purpose: text("purpose").notNull(), + payloadJson: text("payload_json").notNull(), + responseJson: text("response_json"), + responsePrivateJson: text("response_private_json"), + createdAt: bigint("created_at", { mode: "number" }).notNull(), + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.id, table.organizationId] }), + index("execution_interactions_execution_status_idx").on(table.executionId, table.status), + ], +); From 49dacda606fe1532945ce1f297cf9fd05211b685 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:02:54 +0530 Subject: [PATCH 03/35] feat(execution): add execution history persistence to engine Create execution record at start of execute/executeWithPause. Record interactions when elicitation occurs, update status to waiting_for_interaction. Resolve interactions on resume, propagating response to store. Persist terminal state (result/logs/error/status) on completion or cancellation. --- packages/core/execution/src/engine.ts | 188 ++++++++++++++++-- .../core/execution/src/tool-invoker.test.ts | 127 ++++++++++++ 2 files changed, 301 insertions(+), 14 deletions(-) diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 67e418cfb..070dd9905 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -1,12 +1,14 @@ import { Deferred, Effect, Fiber, Ref } from "effect"; import type { + ExecutionInteractionId, Executor, InvokeOptions, ElicitationResponse, ElicitationHandler, ElicitationContext, } from "@executor/sdk"; +import { ExecutionId } from "@executor/sdk"; import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core"; import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; @@ -26,6 +28,7 @@ import { buildExecuteDescription } from "./description"; export type ExecutionEngineConfig = { readonly executor: Executor; readonly codeExecutor?: CodeExecutor; + readonly executionStore?: Executor["executions"]; }; export type ExecutionResult = @@ -39,6 +42,7 @@ export type PausedExecution = { /** Internal representation with Effect runtime state for pause/resume. */ type InternalPausedExecution = PausedExecution & { + readonly interactionId: ExecutionInteractionId; readonly response: Deferred.Deferred; readonly fiber: Fiber.Fiber; readonly pauseSignalRef: Ref.Ref>; @@ -140,6 +144,35 @@ export const formatPausedExecution = ( const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const serializeJson = (value: unknown): string | null => { + if (value === null || typeof value === "undefined") { + return null; + } + + try { + return JSON.stringify(value); + } catch { + return JSON.stringify(String(value)); + } +}; + +const serializeLogs = (logs: readonly string[] | undefined): string | null => + logs && logs.length > 0 ? JSON.stringify(logs) : null; + +const buildInteractionPayload = (ctx: ElicitationContext) => { + const req = ctx.request; + return { + kind: req._tag === "UrlElicitation" ? "url" : "form", + purpose: req.message, + payloadJson: JSON.stringify({ + message: req.message, + kind: req._tag === "UrlElicitation" ? "url" : "form", + ...(req._tag === "UrlElicitation" ? { url: req.url } : {}), + ...(req._tag === "FormElicitation" ? { requestedSchema: req.requestedSchema } : {}), + }), + }; +}; + const readOptionalLimit = (value: unknown, toolName: string): number | ExecutionToolError => { if (value === undefined) { return 12; @@ -294,8 +327,39 @@ const runEffect = (effect: Effect.Effect): Promise => export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => { const { executor } = config; const codeExecutor = config.codeExecutor ?? makeQuickJsExecutor(); + const executionStore = config.executionStore ?? executor.executions; const pausedExecutions = new Map(); - let nextId = 0; + + const persistTerminalState = ( + executionId: ExecutionId, + result: ExecuteResult, + ): Effect.Effect => + Effect.gen(function* () { + const now = Date.now(); + yield* executionStore.update(executionId, { + status: result.error ? "failed" : "completed", + resultJson: serializeJson(result.result), + errorText: result.error ?? null, + logsJson: serializeLogs(result.logs), + completedAt: now, + updatedAt: now, + }); + return result; + }); + + const createExecutionRecord = (code: string) => + executionStore.create({ + scopeId: executor.scope.id, + status: "running", + code, + resultJson: null, + errorText: null, + logsJson: null, + startedAt: Date.now(), + completedAt: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); /** * Race a running fiber against a pause signal. Returns when either @@ -303,12 +367,14 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE * comes first). Re-used by both executeWithPause and resume. */ const awaitCompletionOrPause = ( + executionId: ExecutionId, fiber: Fiber.Fiber, pauseSignal: Deferred.Deferred, ): Effect.Effect => Effect.race( Fiber.join(fiber).pipe( Effect.orDie, + Effect.flatMap((result) => persistTerminalState(executionId, result)), Effect.map((result): ExecutionResult => ({ status: "completed", result })), ), Deferred.await(pauseSignal).pipe( @@ -324,6 +390,8 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE */ const startPausableExecution = (code: string): Effect.Effect => Effect.gen(function* () { + const execution = yield* createExecutionRecord(code); + // Ref holds the current pause signal. The elicitation handler reads // it each time it fires, so resume() can swap in a fresh Deferred // before unblocking the fiber. @@ -334,17 +402,34 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE const elicitationHandler: ElicitationHandler = (ctx) => Effect.gen(function* () { + const now = Date.now(); const responseDeferred = yield* Deferred.make(); - const id = `exec_${++nextId}`; + const interactionPayload = buildInteractionPayload(ctx); + const interaction = yield* executionStore.recordInteraction(execution.id, { + executionId: execution.id, + status: "pending", + kind: interactionPayload.kind, + purpose: interactionPayload.purpose, + payloadJson: interactionPayload.payloadJson, + responseJson: null, + responsePrivateJson: null, + createdAt: now, + updatedAt: now, + }); + yield* executionStore.update(execution.id, { + status: "waiting_for_interaction", + updatedAt: now, + }); const paused: InternalPausedExecution = { - id, + id: execution.id, elicitationContext: ctx, + interactionId: interaction.id, response: responseDeferred, fiber: fiber!, pauseSignalRef, }; - pausedExecutions.set(id, paused); + pausedExecutions.set(execution.id, paused); const currentSignal = yield* Ref.get(pauseSignalRef); yield* Deferred.succeed(currentSignal, paused); @@ -357,7 +442,55 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE fiber = yield* Effect.forkDaemon(codeExecutor.execute(code, invoker)); const initialSignal = yield* Ref.get(pauseSignalRef); - return yield* awaitCompletionOrPause(fiber, initialSignal); + return yield* awaitCompletionOrPause(execution.id, fiber, initialSignal); + }); + + const executeWithManagedRecording = ( + code: string, + onElicitation: ElicitationHandler, + ): Effect.Effect => + Effect.gen(function* () { + const execution = yield* createExecutionRecord(code); + + const recordingHandler: ElicitationHandler = (ctx) => + Effect.gen(function* () { + const now = Date.now(); + const interactionPayload = buildInteractionPayload(ctx); + const interaction = yield* executionStore.recordInteraction(execution.id, { + executionId: execution.id, + status: "pending", + kind: interactionPayload.kind, + purpose: interactionPayload.purpose, + payloadJson: interactionPayload.payloadJson, + responseJson: null, + responsePrivateJson: null, + createdAt: now, + updatedAt: now, + }); + yield* executionStore.update(execution.id, { + status: "waiting_for_interaction", + updatedAt: now, + }); + + const response = yield* onElicitation(ctx); + yield* executionStore.resolveInteraction(interaction.id, { + status: response.action === "accept" ? "resolved" : "cancelled", + responseJson: serializeJson({ + action: response.action, + content: response.content ?? null, + }), + updatedAt: Date.now(), + }); + yield* executionStore.update(execution.id, { + status: "running", + updatedAt: Date.now(), + }); + return response; + }); + + const invoker = makeFullInvoker(executor, { onElicitation: recordingHandler }); + const result = yield* codeExecutor.execute(code, invoker).pipe(Effect.orDie); + return yield* persistTerminalState(execution.id, result); }); /** @@ -366,7 +499,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE * against the next pause. */ const resumeExecution = ( - executionId: string, + executionId: ExecutionId, response: ResumeResponse, ): Effect.Effect => Effect.gen(function* () { @@ -374,30 +507,57 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE if (!paused) return null; pausedExecutions.delete(executionId); + const now = Date.now(); + yield* executionStore.resolveInteraction(paused.interactionId, { + status: response.action === "accept" ? "resolved" : "cancelled", + responseJson: serializeJson({ + action: response.action, + content: response.content ?? null, + }), + updatedAt: now, + }); + + if (response.action !== "accept") { + yield* executionStore.update(executionId, { + status: "cancelled", + completedAt: now, + updatedAt: now, + }); + yield* Fiber.interrupt(paused.fiber); + return { + status: "completed", + result: { + result: null, + error: "Execution cancelled by user", + logs: [], + }, + }; + } + // Swap in a fresh pause signal BEFORE unblocking the fiber, so the // next elicitation handler call signals this new Deferred. const nextSignal = yield* Deferred.make(); yield* Ref.set(paused.pauseSignalRef, nextSignal); + yield* executionStore.update(executionId, { + status: "running", + updatedAt: now, + }); yield* Deferred.succeed(paused.response, { action: response.action, content: response.content, }); - return yield* awaitCompletionOrPause(paused.fiber, nextSignal); + return yield* awaitCompletionOrPause(executionId, paused.fiber, nextSignal); }); return { - execute: async (code, options) => { - const invoker = makeFullInvoker(executor, { - onElicitation: options.onElicitation, - }); - return runEffect(codeExecutor.execute(code, invoker)); - }, + execute: (code, options) => runEffect(executeWithManagedRecording(code, options.onElicitation)), executeWithPause: (code) => runEffect(startPausableExecution(code)), - resume: (executionId, response) => runEffect(resumeExecution(executionId, response)), + resume: (executionId, response) => + runEffect(resumeExecution(ExecutionId.make(executionId), response)), getDescription: () => runEffect(buildExecuteDescription(executor)), }; diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 38543c8ea..e8543f7f0 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -3,6 +3,7 @@ import { Effect, Fiber, Schema } from "effect"; import { ElicitationResponse, + ExecutionId, Source, createExecutor, inMemoryToolsPlugin, @@ -408,3 +409,129 @@ describe("pause/resume with multiple elicitations", () => { } }, 10000); }); + +describe("execution history persistence", () => { + const makeHistoryExecutor = () => + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [ + inMemoryToolsPlugin({ + namespace: "api", + tools: [ + tool({ + name: "singleApproval", + description: "A tool that elicits once", + inputSchema: EmptyInput, + handler: (_args, ctx) => + Effect.gen(function* () { + const r = yield* ctx.elicit( + new FormElicitation({ + message: "Only approval", + requestedSchema: {}, + }), + ); + return { ok: true, response: r }; + }), + }), + ], + }), + ] as const, + }); + + yield* config.sources.registerRuntime( + new Source({ + id: "api", + name: "API", + kind: "in-memory", + runtime: true, + canRemove: false, + canRefresh: false, + }), + ); + + return yield* createExecutor(config); + }); + + it.effect("records completed executions with result and logs", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const engine = createExecutionEngine({ executor }); + + const result = yield* Effect.promise(() => + engine.execute( + [ + 'console.log("hello from run");', + "return { ok: true, value: 42 };", + ].join("\n"), + { onElicitation: acceptAll }, + ), + ); + + expect(result.error).toBeUndefined(); + + const listed = yield* executor.executions.list(executor.scope.id, { limit: 10 }); + expect(listed.executions).toHaveLength(1); + expect(listed.executions[0]?.status).toBe("completed"); + expect(JSON.parse(listed.executions[0]!.resultJson ?? "null")).toEqual({ + ok: true, + value: 42, + }); + expect(JSON.parse(listed.executions[0]!.logsJson ?? "[]")).toContain("[log] hello from run"); + }), + ); + + it.effect("records waiting interactions and resolves them on resume", () => + Effect.gen(function* () { + const executor = yield* makeHistoryExecutor(); + const engine = createExecutionEngine({ executor }); + + const paused = yield* Effect.promise(() => engine.executeWithPause("return await tools.api.singleApproval({});")); + expect(paused.status).toBe("paused"); + + if (paused.status !== "paused") { + return; + } + + const waiting = yield* executor.executions.get(ExecutionId.make(paused.execution.id)); + expect(waiting?.execution.status).toBe("waiting_for_interaction"); + expect(waiting?.pendingInteraction?.status).toBe("pending"); + expect(waiting?.pendingInteraction?.purpose).toBe("Only approval"); + + const resumed = yield* Effect.promise(() => + engine.resume(paused.execution.id, { action: "accept" }), + ); + expect(resumed?.status).toBe("completed"); + + const completed = yield* executor.executions.get(ExecutionId.make(paused.execution.id)); + expect(completed?.execution.status).toBe("completed"); + expect(completed?.pendingInteraction).toBeNull(); + expect(JSON.parse(completed?.execution.resultJson ?? "null")).toMatchObject({ ok: true }); + }), + ); + + it.effect("marks executions cancelled when a paused interaction is declined", () => + Effect.gen(function* () { + const executor = yield* makeHistoryExecutor(); + const engine = createExecutionEngine({ executor }); + + const paused = yield* Effect.promise(() => engine.executeWithPause("return await tools.api.singleApproval({});")); + expect(paused.status).toBe("paused"); + + if (paused.status !== "paused") { + return; + } + + const resumed = yield* Effect.promise(() => + engine.resume(paused.execution.id, { action: "decline" }), + ); + expect(resumed?.status).toBe("completed"); + if (resumed?.status === "completed") { + expect(resumed.result.error).toContain("cancelled"); + } + + const cancelled = yield* executor.executions.get(ExecutionId.make(paused.execution.id)); + expect(cancelled?.execution.status).toBe("cancelled"); + expect(cancelled?.pendingInteraction).toBeNull(); + }), + ); +}); From c9016f15e8f912fc6876940656204d804794f66c Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:03:01 +0530 Subject: [PATCH 04/35] feat(sdk): integrate ExecutionStore into executor lifecycle Add ExecutionStoreService to ExecutorConfig and executor type. Wire makeInMemoryExecutionStore into promiseExecutor. Call sweep() on shutdown. Add REST API endpoints: GET /executions (list with filters, cursor pagination, chart meta) and GET /executions/:id. --- apps/local/src/server/executor.ts | 2 +- packages/core/api/src/executions/api.ts | 71 ++++++ packages/core/api/src/handlers/executions.ts | 61 +++++- packages/core/sdk/src/executor.ts | 9 +- .../core/sdk/src/in-memory/execution-store.ts | 203 ++++++++++++++++++ packages/core/sdk/src/promise-executor.ts | 2 + packages/core/sdk/src/testing.ts | 2 + .../plugins/openapi/src/sdk/plugin.test.ts | 2 + 8 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 packages/core/sdk/src/in-memory/execution-store.ts diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 1d00c57e2..ecfaa37be 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -105,7 +105,7 @@ const createLocalExecutorLayer = () => { const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); const kv = makeSqliteKv(sql); - const config = makeKvConfig(kv, { cwd }); + const config = makeKvConfig(kv, { cwd, sql }); const scopedKv = makeScopedKv(kv, cwd); const configPath = join(cwd, "executor.jsonc"); const fsLayer = NodeFileSystem.layer; diff --git a/packages/core/api/src/executions/api.ts b/packages/core/api/src/executions/api.ts index cc849827a..4928884e2 100644 --- a/packages/core/api/src/executions/api.ts +++ b/packages/core/api/src/executions/api.ts @@ -1,5 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; import { Schema } from "effect"; +import { Execution, ExecutionInteraction, ExecutionStatus } from "@executor/sdk"; // --------------------------------------------------------------------------- // Schemas @@ -35,6 +36,66 @@ const ResumeResponse = Schema.Struct({ isError: Schema.Boolean, }); +const ExecutionSummary = Schema.Struct({ + id: Schema.String, + scopeId: Schema.String, + 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), + createdAt: Schema.Number, + updatedAt: Schema.Number, + pendingInteraction: Schema.NullOr(ExecutionInteraction), +}); + +const ListExecutionsParams = Schema.Struct({ + limit: Schema.optional(Schema.NumberFromString), + cursor: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + from: Schema.optional(Schema.NumberFromString), + to: Schema.optional(Schema.NumberFromString), + code: Schema.optional(Schema.String), +}); + +const ExecutionChartBucket = Schema.Struct({ + timestamp: Schema.Number, + pending: Schema.Number, + running: Schema.Number, + waiting_for_interaction: Schema.Number, + completed: Schema.Number, + failed: Schema.Number, + cancelled: Schema.Number, +}); + +const ExecutionListMeta = Schema.Struct({ + totalRowCount: Schema.Number, + filterRowCount: Schema.Number, + chartBucketMs: Schema.Number, + chartData: Schema.Array(ExecutionChartBucket), + statusCounts: Schema.Struct({ + pending: Schema.Number, + running: Schema.Number, + waiting_for_interaction: Schema.Number, + completed: Schema.Number, + failed: Schema.Number, + cancelled: Schema.Number, + }), +}); + +const ListExecutionsResponse = Schema.Struct({ + executions: Schema.Array(ExecutionSummary), + nextCursor: Schema.optional(Schema.String), + meta: Schema.optional(ExecutionListMeta), +}); + +const GetExecutionResponse = Schema.Struct({ + execution: Execution, + pendingInteraction: Schema.NullOr(ExecutionInteraction), +}); + const ExecutionNotFoundError = Schema.TaggedStruct("ExecutionNotFoundError", { executionId: Schema.String, }).annotations(HttpApiSchema.annotations({ status: 404 })); @@ -50,6 +111,16 @@ const executionIdParam = HttpApiSchema.param("executionId", Schema.String); // --------------------------------------------------------------------------- export class ExecutionsApi extends HttpApiGroup.make("executions") + .add( + HttpApiEndpoint.get("list")`/executions` + .setUrlParams(ListExecutionsParams) + .addSuccess(ListExecutionsResponse), + ) + .add( + HttpApiEndpoint.get("get")`/executions/${executionIdParam}` + .addSuccess(GetExecutionResponse) + .addError(ExecutionNotFoundError), + ) .add( HttpApiEndpoint.post("execute")`/executions` .setPayload(ExecuteRequest) diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index 3e7ec6d1f..12faf2b79 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -3,10 +3,67 @@ import { Effect } from "effect"; import { ExecutorApi } from "../api"; import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; -import { ExecutionEngineService } from "../services"; +import { ExecutionId, type ExecutionStatus } from "@executor/sdk"; +import { ExecutionEngineService, ExecutorService } from "../services"; + +const EXECUTION_STATUSES = new Set([ + "pending", + "running", + "waiting_for_interaction", + "completed", + "failed", + "cancelled", +]); export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => handlers + .handle("list", ({ urlParams }) => + Effect.gen(function* () { + const executor = yield* ExecutorService; + const statusFilter = urlParams.status + ?.split(",") + .map((value) => value.trim()) + .filter((value): value is ExecutionStatus => EXECUTION_STATUSES.has(value as ExecutionStatus)); + // Meta (chart + totals) is only computed on the first page so the + // client can pin it without refetching on scroll. + const includeMeta = urlParams.cursor === undefined; + const result = yield* executor.executions.list(executor.scope.id, { + limit: Math.max(1, Math.min(urlParams.limit ?? 25, 100)), + cursor: urlParams.cursor, + statusFilter: statusFilter && statusFilter.length > 0 ? statusFilter : undefined, + timeRange: + urlParams.from !== undefined || urlParams.to !== undefined + ? { + from: urlParams.from, + to: urlParams.to, + } + : undefined, + codeQuery: urlParams.code, + includeMeta, + }); + + return { + executions: result.executions, + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result.meta ? { meta: result.meta } : {}), + }; + }), + ) + .handle("get", ({ path }) => + Effect.gen(function* () { + const executor = yield* ExecutorService; + const result = yield* executor.executions.get(ExecutionId.make(path.executionId)); + + if (!result) { + return yield* Effect.fail({ + _tag: "ExecutionNotFoundError" as const, + executionId: path.executionId, + }); + } + + return result; + }), + ) .handle("execute", ({ payload }) => Effect.gen(function* () { const engine = yield* ExecutionEngineService; @@ -34,7 +91,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" Effect.gen(function* () { const engine = yield* ExecutionEngineService; const result = yield* Effect.promise(() => - engine.resume(path.executionId, { + engine.resume(ExecutionId.make(path.executionId), { action: payload.action, content: payload.content as Record | undefined, }), diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 05d706eae..c55ea8780 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -12,6 +12,7 @@ import type { } from "./tools"; import type { Source, SourceDetectionResult, SourceRegistry } from "./sources"; import type { Policy, PolicyEngine } from "./policies"; +import type { ExecutionStore } from "./executions"; import type { Scope } from "./scope"; import type { ExecutorPlugin, PluginExtensions, PluginHandle } from "./plugin"; import type { @@ -88,6 +89,7 @@ export type Executor[] }; readonly close: () => Effect.Effect; + readonly executions: ExecutionStoreService; } & PluginExtensions; // --------------------------------------------------------------------------- @@ -98,6 +100,7 @@ export type ToolRegistryService = Context.Tag.Service; export type SourceRegistryService = Context.Tag.Service; export type SecretStoreService = Context.Tag.Service; export type PolicyEngineService = Context.Tag.Service; +export type ExecutionStoreService = Context.Tag.Service; export interface ExecutorConfig[] = []> { readonly scope: Scope; @@ -105,6 +108,7 @@ export interface ExecutorConfig, ): Effect.Effect, Error> => Effect.gen(function* () { - const { scope, tools, sources, secrets, policies, plugins = [] } = config; + const { scope, tools, sources, secrets, policies, executions, plugins = [] } = config; + + yield* executions.sweep(); // Initialize all plugins const handles = new Map>(); @@ -204,6 +210,7 @@ export const createExecutor = < if (handle.close) yield* handle.close(); } }), + executions, }; return Object.assign(base, extensions) as Executor; diff --git a/packages/core/sdk/src/in-memory/execution-store.ts b/packages/core/sdk/src/in-memory/execution-store.ts new file mode 100644 index 000000000..ed675a4ab --- /dev/null +++ b/packages/core/sdk/src/in-memory/execution-store.ts @@ -0,0 +1,203 @@ +import { Effect } from "effect"; + +import { + Execution, + ExecutionInteraction, + buildExecutionListMeta, + type CreateExecutionInput, + type CreateExecutionInteractionInput, + type ExecutionListItem, + type ExecutionListOptions, + type ExecutionStatus, + type UpdateExecutionInput, + type UpdateExecutionInteractionInput, +} from "../executions"; +import { ExecutionId, ExecutionInteractionId, ScopeId } from "../ids"; + +const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +const encodeCursor = (execution: Execution): string => + encodeURIComponent(JSON.stringify({ createdAt: execution.createdAt, id: execution.id })); + +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; + } +}; + +const compareExecutionOrder = (left: Execution, right: Execution): number => { + if (left.createdAt !== right.createdAt) { + return right.createdAt - left.createdAt; + } + return right.id.localeCompare(left.id); +}; + +const matchesFilters = (execution: Execution, options: ExecutionListOptions): boolean => { + if (options.statusFilter && options.statusFilter.length > 0) { + const allowed = new Set(options.statusFilter); + if (!allowed.has(execution.status)) { + return false; + } + } + + if (options.timeRange?.from !== undefined && execution.createdAt < options.timeRange.from) { + return false; + } + + if (options.timeRange?.to !== undefined && execution.createdAt > options.timeRange.to) { + return false; + } + + if (options.codeQuery) { + const query = options.codeQuery.trim().toLowerCase(); + if (query.length > 0 && !execution.code.toLowerCase().includes(query)) { + return false; + } + } + + return true; +}; + +export const makeInMemoryExecutionStore = () => { + const executions = new Map(); + const interactions = new Map(); + + const getPendingInteraction = (executionId: ExecutionId): ExecutionInteraction | null => + [...interactions.values()].find( + (interaction) => interaction.executionId === executionId && interaction.status === "pending", + ) ?? null; + + return { + create: (input: CreateExecutionInput) => + Effect.sync(() => { + const id = ExecutionId.make(`exec_${Date.now()}_${Math.random().toString(36).slice(2)}`); + const execution = new Execution({ id, ...input }); + executions.set(id, execution); + return execution; + }), + + update: (id: ExecutionId, patch: UpdateExecutionInput) => + Effect.sync(() => { + const current = executions.get(id); + if (!current) { + throw new Error(`Execution not found: ${id}`); + } + const execution = new Execution({ + ...current, + ...patch, + id: current.id, + scopeId: current.scopeId, + }); + executions.set(id, execution); + return execution; + }), + + list: (scopeId: ScopeId, options: ExecutionListOptions) => + Effect.sync(() => { + const inScope = [...executions.values()].filter( + (execution) => execution.scopeId === scopeId, + ); + const filtered = inScope + .filter((execution) => matchesFilters(execution, options)) + .sort(compareExecutionOrder); + + const cursor = options.cursor ? decodeCursor(options.cursor) : null; + const startIndex = cursor + ? filtered.findIndex( + (execution) => + execution.createdAt === cursor.createdAt && execution.id === cursor.id, + ) + 1 + : 0; + const page = filtered.slice( + Math.max(0, startIndex), + Math.max(0, startIndex) + options.limit, + ); + + const executionsPage: ExecutionListItem[] = page.map((execution) => ({ + ...execution, + pendingInteraction: getPendingInteraction(execution.id), + })); + + const last = page.at(-1); + const hasMore = startIndex + page.length < filtered.length; + const meta = options.includeMeta + ? buildExecutionListMeta(filtered, options.timeRange, inScope.length) + : undefined; + + return { + executions: executionsPage, + nextCursor: hasMore && last ? encodeCursor(last) : undefined, + meta, + }; + }), + + get: (id: ExecutionId) => + Effect.sync(() => { + const execution = executions.get(id); + if (!execution) { + return null; + } + return { + execution, + pendingInteraction: getPendingInteraction(id), + }; + }), + + recordInteraction: (_executionId: ExecutionId, interaction: CreateExecutionInteractionInput) => + Effect.sync(() => { + const id = ExecutionInteractionId.make( + `interaction_${Date.now()}_${Math.random().toString(36).slice(2)}`, + ); + const stored = new ExecutionInteraction({ id, ...interaction }); + interactions.set(id, stored); + return stored; + }), + + resolveInteraction: (interactionId: ExecutionInteractionId, patch: UpdateExecutionInteractionInput) => + Effect.sync(() => { + const current = interactions.get(interactionId); + if (!current) { + throw new Error(`Execution interaction not found: ${interactionId}`); + } + const interaction = new ExecutionInteraction({ + ...current, + ...patch, + id: current.id, + executionId: current.executionId, + }); + interactions.set(interactionId, interaction); + return interaction; + }), + + sweep: () => + Effect.sync(() => { + const cutoff = Date.now() - RETENTION_MS; + const expiredIds = [...executions.values()] + .filter((execution) => execution.createdAt < cutoff) + .map((execution) => execution.id); + + for (const executionId of expiredIds) { + executions.delete(executionId); + for (const [interactionId, interaction] of interactions) { + if (interaction.executionId === executionId) { + interactions.delete(interactionId); + } + } + } + }), + }; +}; diff --git a/packages/core/sdk/src/promise-executor.ts b/packages/core/sdk/src/promise-executor.ts index ea40c72e4..12c511427 100644 --- a/packages/core/sdk/src/promise-executor.ts +++ b/packages/core/sdk/src/promise-executor.ts @@ -6,6 +6,7 @@ import { makeInMemoryToolRegistry, makeInMemorySecretStore, makeInMemoryPolicyEngine, + makeInMemoryExecutionStore, makeInMemorySourceRegistry, ScopeId, ToolId, @@ -640,6 +641,7 @@ export const createExecutor = async { sources: makeInMemorySourceRegistry(), secrets: makeInMemorySecretStore(), policies: makeInMemoryPolicyEngine(), + executions: makeInMemoryExecutionStore(), }; const executor1 = yield* createExecutor({ From d1fc4c0373019a6e44db1177eb406e027ade6865 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:03:09 +0530 Subject: [PATCH 05/35] feat(react): add execution history page with filters, timeline, and detail drawer Full observability-style /runs page with infinite scroll, filter rail (status, time range, code search), timeline chart with clickable range, and detail drawer for execution inspection. React Query provider added to ExecutorProvider. Command palette gains Runs navigation. CSS color tokens for execution status (success/warning/error/info). --- bun.lock | 10 +- packages/react/package.json | 2 + packages/react/src/api/executions.tsx | 83 ++++ packages/react/src/api/provider.tsx | 18 +- .../react/src/components/command-palette.tsx | 20 +- .../src/components/runs/detail-drawer.tsx | 417 ++++++++++++++++++ .../react/src/components/runs/filter-rail.tsx | 228 ++++++++++ packages/react/src/components/runs/row.tsx | 117 +++++ packages/react/src/components/runs/shell.tsx | 208 +++++++++ packages/react/src/components/runs/status.ts | 82 ++++ .../src/components/runs/timeline-chart.tsx | 218 +++++++++ packages/react/src/pages/runs.tsx | 302 +++++++++++++ packages/react/src/styles/globals.css | 12 + 13 files changed, 1711 insertions(+), 6 deletions(-) create mode 100644 packages/react/src/api/executions.tsx create mode 100644 packages/react/src/components/runs/detail-drawer.tsx create mode 100644 packages/react/src/components/runs/filter-rail.tsx create mode 100644 packages/react/src/components/runs/row.tsx create mode 100644 packages/react/src/components/runs/shell.tsx create mode 100644 packages/react/src/components/runs/status.ts create mode 100644 packages/react/src/components/runs/timeline-chart.tsx create mode 100644 packages/react/src/pages/runs.tsx diff --git a/bun.lock b/bun.lock index d08180cbc..bb8038c0e 100644 --- a/bun.lock +++ b/bun.lock @@ -627,10 +627,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", @@ -1948,6 +1950,10 @@ "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.97.0", "", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.97.0", "", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="], + "@tanstack/react-router": ["@tanstack/react-router@1.168.10", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.9", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA=="], "@tanstack/react-start": ["@tanstack/react-start@1.167.16", "", { "dependencies": { "@tanstack/react-router": "1.168.10", "@tanstack/react-start-client": "1.166.25", "@tanstack/react-start-server": "1.166.25", "@tanstack/router-utils": "^1.161.6", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-plugin-core": "1.167.17", "@tanstack/start-server-core": "1.167.9", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-vHIhn+FTWfAVhRus1BZEaBZPhnYL+StDuMlShslIBPEGGTCRt11BxNUfV/iDpr7zbxw36Snj7zGfI7DwfjjlDQ=="], @@ -2566,7 +2572,7 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -4778,6 +4784,8 @@ "rc-util/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], diff --git a/packages/react/package.json b/packages/react/package.json index 1fa0c9459..42762c40b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,10 +27,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", diff --git a/packages/react/src/api/executions.tsx b/packages/react/src/api/executions.tsx new file mode 100644 index 000000000..4e13016a6 --- /dev/null +++ b/packages/react/src/api/executions.tsx @@ -0,0 +1,83 @@ +import { endOfDay, parseISO, startOfDay } from "date-fns"; +import type { + Execution, + ExecutionChartBucket, + ExecutionInteraction, + ExecutionListMeta, +} 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 }; + +export type GetExecutionResponse = { + readonly execution: Execution; + readonly pendingInteraction: ExecutionInteraction | null; +}; + +export type RunsQueryInput = { + readonly limit: number; + readonly cursor?: string; + readonly status?: string; + readonly from?: string; + readonly to?: string; + readonly code?: 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); + + 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); +}; diff --git a/packages/react/src/api/provider.tsx b/packages/react/src/api/provider.tsx index d6bb730b0..3a16962a3 100644 --- a/packages/react/src/api/provider.tsx +++ b/packages/react/src/api/provider.tsx @@ -1,9 +1,21 @@ 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) => ( - - {props.children} - + + + {props.children} + + ); diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 6ef6e9618..4f0ac7701 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,9 +157,19 @@ 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) => ( ))} + )} {presetEntries.length > 0 && } 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..27b3f953c --- /dev/null +++ b/packages/react/src/components/runs/detail-drawer.tsx @@ -0,0 +1,417 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Execution, ExecutionInteraction } from "@executor/sdk"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; +import { CodeBlock } from "../code-block"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../sheet"; +import { getExecution, type GetExecutionResponse } from "../../api/executions"; +import { statusTone, statusLabel } from "./status"; + +// --------------------------------------------------------------------------- +// Detail drawer — v1.3 aesthetic, openstatus-triggered via URL state +// --------------------------------------------------------------------------- +// +// Differences from v1.3: +// - Backed by TanStack Query's getExecution endpoint (not LoadableBlock). +// - Built on our existing Sheet primitive (Radix dialog slide-in) widened +// to sm:max-w-3xl. +// - `CodeBlock` replaces v1.3's DocumentPanel — same visual role, Shiki +// highlighting, copy-to-clipboard. +// Same elsewhere: tabbed Properties / Logs, 2-col status+duration cards, +// compact created/started row, per-line log coloring, Copy JSON button. + +type DetailTab = "properties" | "logs"; + +const formatTimestamp = (value: number | null): string => { + if (value === null) return "—"; + const d = new Date(value); + const month = d.toLocaleString(undefined, { month: "short" }); + const day = d.getDate(); + const h = d.getHours().toString().padStart(2, "0"); + const m = d.getMinutes().toString().padStart(2, "0"); + const s = d.getSeconds().toString().padStart(2, "0"); + return `${month} ${day}, ${h}:${m}:${s}`; +}; + +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`; +}; + +const prettyJson = (value: string | null): string | null => { + if (value === null) return null; + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } +}; + +const parseLogs = (logsJson: string | null): string[] | null => { + if (logsJson === null) return null; + try { + const parsed = JSON.parse(logsJson); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + return null; + } + return null; +}; + +// --------------------------------------------------------------------------- + +export interface RunsDetailDrawerProps { + readonly executionId?: string; + readonly onOpenChange: (open: boolean) => void; +} + +export function RunsDetailDrawer({ executionId, onOpenChange }: RunsDetailDrawerProps) { + const open = Boolean(executionId); + const query = useQuery({ + queryKey: ["execution", executionId], + queryFn: () => getExecution(executionId!), + enabled: open, + staleTime: 10_000, + }); + + return ( + + + onOpenChange(false)} /> + + + ); +} + +function DrawerBody({ + executionId, + query, + onClose, +}: { + readonly executionId?: string; + readonly query: ReturnType>; + readonly onClose: () => 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 ( +
+ {/* Hidden titles for radix a11y */} + + Execution details + {executionId ?? "No execution selected"} + + + {/* Visible header — mono id + actions */} +
+
+
+ {executionId ?? "—"} +
+
+
+ + +
+
+ + {/* Tabs */} +
+ setTab("properties")} /> + setTab("logs")} /> +
+ + {/* Content */} +
+ {query.isLoading ? ( +

Loading execution…

+ ) : query.isError ? ( +

+ Failed to load execution details. +

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

Execution not found.

+ )} +
+
+ ); +} + +function TabButton(props: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Properties tab +// --------------------------------------------------------------------------- + +function PropertiesTab({ envelope }: { envelope: GetExecutionResponse }) { + const { execution, pendingInteraction } = envelope; + const tone = statusTone(execution.status); + const formattedResult = prettyJson(execution.resultJson); + + return ( +
+ {/* 2-col status/duration cards */} +
+ + + + {statusLabel(execution.status)} + + + + {formatDuration(execution)} + +
+ + {/* Compact timeline row */} +
+ + + + +
+ + + + {formattedResult ? ( + + ) : ( + + )} + + {execution.errorText ? ( +
+
+ Error +
+
+            {execution.errorText}
+          
+
+ ) : null} + + {pendingInteraction ? : null} +
+ ); +} + +function MetaCard(props: { label: string; children: React.ReactNode }) { + return ( +
+
+ {props.label} +
+
{props.children}
+
+ ); +} + +function TimelineLine(props: { label: string; value: string }) { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +function EmptyPanel(props: { title: string; message: string }) { + return ( +
+
+ {props.title} +
+
+ {props.message} +
+
+ ); +} + +function PendingInteractionBlock({ interaction }: { interaction: ExecutionInteraction }) { + const request = prettyJson(interaction.payloadJson); + const response = prettyJson(interaction.responseJson); + + return ( +
+
+
+
Pending interaction
+
+ {interaction.kind} — {interaction.purpose} +
+
+ + {interaction.status} + +
+ +
+ {request ? ( + + ) : ( + + )} + {response ? ( + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Logs tab +// --------------------------------------------------------------------------- + +function LogsTab({ logsJson }: { logsJson: string | null }) { + const lines = React.useMemo(() => parseLogs(logsJson), [logsJson]); + + if (!lines) { + const formatted = prettyJson(logsJson); + if (!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} +
+ ); + })} +
+
+ ); +} 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..0039169ab --- /dev/null +++ b/packages/react/src/components/runs/filter-rail.tsx @@ -0,0 +1,228 @@ +import * as React from "react"; +import type { ExecutionListMeta, ExecutionStatus } from "@executor/sdk"; + +import { cn } from "../../lib/utils"; +import { Input } from "../input"; +import { STATUS_ORDER, STATUS_LABELS, statusTone } from "./status"; + +// --------------------------------------------------------------------------- +// FilterRail — left rail with page title, status facets, range, code input +// --------------------------------------------------------------------------- +// +// Openstatus `/infinite` puts `` in a sticky left +// rail. We do the same but with v1.3 density: header = `font-display` +// page title + muted subtitle, then a status facet list (dot + label + +// checkbox + count), then a time-range preset group, then a code contains +// input. + +export interface RunsFilterRailProps { + readonly selectedStatuses: readonly ExecutionStatus[]; + readonly onToggleStatus: (status: ExecutionStatus) => 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, + range, + onRangeChange, + codeQuery, + onCodeQueryChange, + onReset, + meta, + totalsLine, +}: RunsFilterRailProps) { + const filtersActive = + selectedStatuses.length > 0 || codeQuery.trim().length > 0 || range !== "24h"; + + return ( +
+ {/* Title block */} +
+

+ Execution history +

+

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

+ {totalsLine ? ( +

+ {totalsLine} +

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

+ Filters +

+ {filtersActive ? ( + + ) : null} +
+ +
+ {/* Status facets */} + + {STATUS_ORDER.map((status) => { + const tone = statusTone(status); + const checked = selectedStatuses.includes(status); + const count = meta?.statusCounts[status]; + return ( +
  • + +
  • + ); + })} +
    + + {/* Time range */} + + {TIME_RANGE_PRESETS.map((preset) => { + const active = preset.value === range; + return ( +
  • + +
  • + ); + })} +
    + + {/* Code contains */} +
    +

    + Code contains +

    + onCodeQueryChange(event.currentTarget.value)} + placeholder="tools.github.list" + className="h-8 font-mono text-[11px]" + /> +
    +
    +
    + ); +} + +function FacetGroup({ + label, + children, +}: { + readonly label: string; + readonly children: React.ReactNode; +}) { + return ( +
    +

    + {label} +

    +
      {children}
    +
    + ); +} diff --git a/packages/react/src/components/runs/row.tsx b/packages/react/src/components/runs/row.tsx new file mode 100644 index 000000000..ca56ca373 --- /dev/null +++ b/packages/react/src/components/runs/row.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import type { Execution, ExecutionStatus } from "@executor/sdk"; + +import { cn } from "../../lib/utils"; +import { statusTone } from "./status"; + +// --------------------------------------------------------------------------- +// RunRow — v1.3 log-line aesthetic +// --------------------------------------------------------------------------- +// +// Single row of the /runs list. Every run renders as a dense, monospace +// key-value log line — muted labels, color-coded values, a single status +// dot, no cells in a grid. v1.3 used a plain + ); +} + +// --------------------------------------------------------------------------- +// Header — `_time | Raw Data` (v1.3) +// --------------------------------------------------------------------------- + +export function RunRowHeader() { + return ( +
    + + _time + Raw Data +
    + ); +} diff --git a/packages/react/src/components/runs/shell.tsx b/packages/react/src/components/runs/shell.tsx new file mode 100644 index 000000000..705690646 --- /dev/null +++ b/packages/react/src/components/runs/shell.tsx @@ -0,0 +1,208 @@ +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +// --------------------------------------------------------------------------- +// RunsShell — split-screen observability layout +// --------------------------------------------------------------------------- +// +// Shape borrowed from openstatus-data-table's `DataTableInfinite` and trimmed +// to what /runs actually needs: +// ┌────────────┬────────────────────────────────────────────┐ +// │ filterRail │ topBar (toolbar + chartSlot) │ +// │ ├────────────────────────────────────────────┤ +// │ │ columnHeader (_time | Raw Data) │ +// │ ├────────────────────────────────────────────┤ +// │ │ body (scrollable, fetchNextPage on bottom) │ +// │ │ │ +// │ │ │ +// │ │ │ +// │ └────────────────────────────────────────────┘ +// +// No TanStack Table, no BYOS store, no column model — the body is just a +// vertical list of whatever rows the caller renders. Pagination is driven by +// an onScroll hook on the body, matching openstatus's approach. +// +// v1.3 aesthetic: no outer card/border radius around the list, no alternating +// row background, dense mono typography. + +export interface RunsShellProps { + readonly filterRail: React.ReactNode; + readonly topBar?: React.ReactNode; + readonly chartSlot?: React.ReactNode; + readonly columnHeader?: React.ReactNode; + readonly emptyState?: React.ReactNode; + readonly isLoading?: boolean; + readonly isFetchingNextPage?: boolean; + readonly hasNextPage?: boolean; + readonly fetchNextPage?: () => void; + readonly totalRowsFetched?: number; + readonly filterRowCount?: number; + readonly children: React.ReactNode; + readonly className?: string; +} + +export function RunsShell({ + filterRail, + topBar, + chartSlot, + columnHeader, + emptyState, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + totalRowsFetched = 0, + filterRowCount, + children, + 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 ( +
    + {/* Left rail */} + + + {/* Main pane */} +
    + {/* Sticky top bar */} +
    + {topBar} + {chartSlot} +
    + + {/* Column header */} + {columnHeader ? ( +
    + {columnHeader} +
    + ) : null} + + {/* Scrollable body */} +
    + {isLoading ? ( +
    + Loading runs… +
    + ) : !hasRows(children) ? ( +
    + {emptyState ?? ( +

    No runs.

    + )} +
    + ) : ( + <> + {children} + {isFetchingNextPage ? ( +
    + Loading more… +
    + ) : null} + {!hasNextPage && totalRowsFetched > 0 ? ( +
    + End of history +
    + ) : null} + + )} +
    +
    +
    + ); +} + +/** + * React children can be a single element, an array, a fragment, or null. + * We only want to show the empty state when there are *no* row children, + * but React.Children.count() returns 1 for a fragment with 0 rows. So we + * walk a level deeper when the child is a fragment or array. + */ +function hasRows(children: React.ReactNode): boolean { + const count = React.Children.count(children); + if (count === 0) return false; + + let found = false; + React.Children.forEach(children, (child) => { + if (found) return; + if (child == null || typeof child === "boolean") return; + if ( + typeof child === "object" && + "type" in child && + child.type === React.Fragment && + "props" in child && + child.props && + typeof child.props === "object" && + "children" in child.props + ) { + found = hasRows(child.props.children as React.ReactNode); + return; + } + found = true; + }); + return found; +} diff --git a/packages/react/src/components/runs/status.ts b/packages/react/src/components/runs/status.ts new file mode 100644 index 000000000..fb0da2f2e --- /dev/null +++ b/packages/react/src/components/runs/status.ts @@ -0,0 +1,82 @@ +import type { ExecutionStatus } from "@executor/sdk"; + +// --------------------------------------------------------------------------- +// Shared status vocabulary +// --------------------------------------------------------------------------- +// +// Single source of truth for how every surface in the /runs page names and +// colors execution statuses: log-line rows, filter rail checkboxes, chart +// bars, and the detail drawer. v1.3 used inline `bg-*` Tailwind classes +// with a small amount of animate-pulse for live-ish states; we keep that +// but drive colors through our semantic CSS vars so light/dark inherit. + +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-[color:var(--color-success)]", + text: "text-[color:var(--color-success)]", + chartFill: "var(--color-success)", + pulse: false, + }, + failed: { + dot: "bg-[color:var(--color-error)]", + text: "text-[color:var(--color-error)]", + chartFill: "var(--color-error)", + pulse: false, + }, + running: { + dot: "bg-[color:var(--color-info)]", + text: "text-[color:var(--color-info)]", + chartFill: "var(--color-info)", + pulse: true, + }, + waiting_for_interaction: { + dot: "bg-[color:var(--color-warning)]", + text: "text-[color:var(--color-warning)]", + chartFill: "var(--color-warning)", + 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 statusLabel = (status: ExecutionStatus): string => STATUS_LABELS[status]; +export const statusTone = (status: ExecutionStatus): StatusTone => STATUS_TONES[status]; 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..4d301a8e2 --- /dev/null +++ b/packages/react/src/components/runs/timeline-chart.tsx @@ -0,0 +1,218 @@ +"use client"; + +import * as React from "react"; +import { format } from "date-fns"; +import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from "recharts"; + +// Recharts doesn't re-export its mouse-event shape from the entry point in +// our version, so type the handler argument narrowly: we only read +// `activeLabel`, which is a string when a bar is under the cursor. +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"; + +// --------------------------------------------------------------------------- +// TimelineChart — stacked bars per execution status +// --------------------------------------------------------------------------- +// +// Port of openstatus-data-table's TimelineChart. Changes from the upstream: +// 1. No ChartContext/BYOS dependency — bucket data is passed in as a prop. +// 2. Stack order reflects our status vocabulary (failed → running → …). +// 3. Drag-to-zoom emits a { from, to } callback; caller decides whether +// to write that into URL state, a parent filter, etc. +// 4. Bucket size is server-decided (`bucketMs`), we just label the axis. + +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 }, +}; + +// Stack order matters visually — put the most "noisy" statuses at the +// bottom, terminal success on top. Matches a Datadog/Axiom log viewer. +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); + + // Recharts wants a string dataKey on the X axis (date instances confuse + // the tooltip/label plumbing). We attach the timestamp as an ISO string. + 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/pages/runs.tsx b/packages/react/src/pages/runs.tsx new file mode 100644 index 000000000..8d296a4d2 --- /dev/null +++ b/packages/react/src/pages/runs.tsx @@ -0,0 +1,302 @@ +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 { RunsShell } from "../components/runs/shell"; +import { RunRow, RunRowHeader } from "../components/runs/row"; +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 { STATUS_ORDER } from "../components/runs/status"; + +// --------------------------------------------------------------------------- +// /runs — observability-style execution history +// --------------------------------------------------------------------------- +// +// Layout from openstatus-data-table's /infinite example. Row aesthetic, +// drawer, and status vocabulary from v1.3's execution-history plugin. +// URL state is the single source of truth — TanStack Router search params +// drive every filter, and the drawer open state is just `?executionId=`. + +export type RunsSearch = { + readonly executionId?: string; + readonly status?: string; + readonly range?: string; + readonly from?: string; + readonly to?: string; + readonly code?: string; +}; + +const DEFAULT_RANGE: TimeRangePreset = "24h"; +const VALID_RANGES: readonly TimeRangePreset[] = ["15m", "1h", "24h", "7d", "30d", "all"]; +const PAGE_SIZE = 50; + +const parseStatuses = (value: string | undefined): ExecutionStatus[] => + value + ? value + .split(",") + .map((entry) => entry.trim()) + .filter((entry): entry is ExecutionStatus => + STATUS_ORDER.includes(entry as ExecutionStatus), + ) + : []; + +const parseRange = (value: string | undefined): TimeRangePreset => { + if (!value) return DEFAULT_RANGE; + return VALID_RANGES.includes(value as TimeRangePreset) + ? (value as TimeRangePreset) + : DEFAULT_RANGE; +}; + +const toggleStatus = ( + statuses: readonly ExecutionStatus[], + status: ExecutionStatus, +): ExecutionStatus[] => + statuses.includes(status) + ? statuses.filter((entry) => entry !== status) + : [...statuses, status].sort(); + +export function RunsPage({ search }: { search: RunsSearch }) { + const navigate = useNavigate(); + + const selectedStatuses = React.useMemo(() => parseStatuses(search.status), [search.status]); + const range = React.useMemo(() => parseRange(search.range), [search.range]); + + 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], + ); + + // Debounce code input → URL state + 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]); + + // Resolve time range — custom from/to takes precedence over preset + 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(","), + resolvedTimeRange.from ?? "", + resolvedTimeRange.to ?? "", + search.code ?? "", + ], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + listExecutions({ + limit: PAGE_SIZE, + cursor: pageParam, + status: selectedStatuses.length > 0 ? selectedStatuses.join(",") : undefined, + from: resolvedTimeRange.from ? String(resolvedTimeRange.from) : undefined, + to: resolvedTimeRange.to ? String(resolvedTimeRange.to) : undefined, + code: search.code, + }), + getNextPageParam: (page) => page.nextCursor, + staleTime: 10_000, + }); + + const rows = React.useMemo( + () => listQuery.data?.pages.flatMap((page) => page.executions) ?? [], + [listQuery.data], + ); + + // Meta is only returned on the first page request — pin it + 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 = toggleStatus(selectedStatuses, status); + updateSearch({ + status: next.length > 0 ? next.join(",") : undefined, + executionId: undefined, + }); + }, + [selectedStatuses, 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, + range: DEFAULT_RANGE, + from: undefined, + to: undefined, + code: 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], + ); + + return ( + <> + + } + topBar={ +
    +
    + + {rows.length.toLocaleString()} loaded + + {meta ? ( + + · {meta.filterRowCount.toLocaleString()} total + + ) : null} +
    + +
    + } + chartSlot={ + meta ? ( + + ) : null + } + columnHeader={} + isLoading={listQuery.isLoading} + isFetchingNextPage={listQuery.isFetchingNextPage} + hasNextPage={listQuery.hasNextPage} + fetchNextPage={() => void listQuery.fetchNextPage()} + totalRowsFetched={rows.length} + filterRowCount={meta?.filterRowCount} + emptyState={ +
    +

    No runs match the current filters.

    +

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

    +
    + } + > + {rows.map((row) => ( + handleRowSelect(row)} + /> + ))} +
    + + + + ); +} diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index 91bca9371..87a62de83 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.235 0.007 260); --input: oklch(0.195 0.007 260); --ring: oklch(0.72 0.15 175); From 6255415da00f37f35d6096516b3b8b9f83287336 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 11 Apr 2026 18:03:16 +0530 Subject: [PATCH 06/35] feat(app): add /runs routes and sidebar navigation Add /runs route to both cloud and local apps with sidebar navigation entries. Cloud app includes execution history migration. --- apps/cloud/drizzle/0001_execution_history.sql | 34 +++++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 ++++ apps/cloud/src/routeTree.gen.ts | 21 ++++++++++++ apps/cloud/src/routes/runs.tsx | 19 +++++++++++ apps/cloud/src/web/shell.tsx | 2 ++ apps/local/src/routeTree.gen.ts | 21 ++++++++++++ apps/local/src/routes/runs.tsx | 19 +++++++++++ apps/local/src/web/shell.tsx | 2 ++ 8 files changed, 125 insertions(+) create mode 100644 apps/cloud/drizzle/0001_execution_history.sql create mode 100644 apps/cloud/src/routes/runs.tsx create mode 100644 apps/local/src/routes/runs.tsx diff --git a/apps/cloud/drizzle/0001_execution_history.sql b/apps/cloud/drizzle/0001_execution_history.sql new file mode 100644 index 000000000..4b84291af --- /dev/null +++ b/apps/cloud/drizzle/0001_execution_history.sql @@ -0,0 +1,34 @@ +CREATE TABLE "executions" ( + "id" text NOT NULL, + "organization_id" text NOT NULL, + "scope_id" text NOT NULL, + "status" text NOT NULL, + "code" text NOT NULL, + "result_json" text, + "error_text" text, + "logs_json" text, + "started_at" bigint, + "completed_at" bigint, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + CONSTRAINT "executions_id_organization_id_pk" PRIMARY KEY("id","organization_id") +); +--> statement-breakpoint +CREATE TABLE "execution_interactions" ( + "id" text NOT NULL, + "organization_id" text NOT NULL, + "execution_id" text NOT NULL, + "status" text NOT NULL, + "kind" text NOT NULL, + "purpose" text NOT NULL, + "payload_json" text NOT NULL, + "response_json" text, + "response_private_json" text, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + CONSTRAINT "execution_interactions_id_organization_id_pk" PRIMARY KEY("id","organization_id") +); +--> statement-breakpoint +CREATE INDEX "executions_scope_created_at_idx" ON "executions" USING btree ("scope_id","created_at","id"); +--> statement-breakpoint +CREATE INDEX "execution_interactions_execution_status_idx" ON "execution_interactions" USING btree ("execution_id","status"); diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 87cf42cc2..df40bf438 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775764846378, "tag": "0000_redundant_night_nurse", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775923200000, + "tag": "0001_execution_history", + "breakpoints": true } ] } diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index c38058318..0016e6869 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as TeamRouteImport } from './routes/team' import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as RunsRouteImport } from './routes/runs' import { Route as BillingRouteImport } from './routes/billing' import { Route as IndexRouteImport } from './routes/index' import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' @@ -33,6 +34,11 @@ const SecretsRoute = SecretsRouteImport.update({ path: '/secrets', getParentRoute: () => rootRouteImport, } as any) +const RunsRoute = RunsRouteImport.update({ + id: '/runs', + path: '/runs', + getParentRoute: () => rootRouteImport, +} as any) const BillingRoute = BillingRouteImport.update({ id: '/billing', path: '/billing', @@ -62,6 +68,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/billing': typeof BillingRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/team': typeof TeamRoute '/tools': typeof ToolsRoute @@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/billing': typeof BillingRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/team': typeof TeamRoute '/tools': typeof ToolsRoute @@ -83,6 +91,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/billing': typeof BillingRoute + '/runs': typeof RunsRoute '/secrets': typeof SecretsRoute '/team': typeof TeamRoute '/tools': typeof ToolsRoute @@ -95,6 +104,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/billing' + | '/runs' | '/secrets' | '/team' | '/tools' @@ -105,6 +115,7 @@ export interface FileRouteTypes { to: | '/' | '/billing' + | '/runs' | '/secrets' | '/team' | '/tools' @@ -115,6 +126,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/billing' + | '/runs' | '/secrets' | '/team' | '/tools' @@ -126,6 +138,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute BillingRoute: typeof BillingRoute + RunsRoute: typeof RunsRoute SecretsRoute: typeof SecretsRoute TeamRoute: typeof TeamRoute ToolsRoute: typeof ToolsRoute @@ -157,6 +170,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 + } '/billing': { id: '/billing' path: '/billing' @@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BillingRoute: BillingRoute, + RunsRoute: RunsRoute, SecretsRoute: SecretsRoute, TeamRoute: TeamRoute, ToolsRoute: ToolsRoute, diff --git a/apps/cloud/src/routes/runs.tsx b/apps/cloud/src/routes/runs.tsx new file mode 100644 index 000000000..3d15eb67e --- /dev/null +++ b/apps/cloud/src/routes/runs.tsx @@ -0,0 +1,19 @@ +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), + range: Schema.optional(Schema.String), + from: Schema.optional(Schema.String), + to: Schema.optional(Schema.String), + code: 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 ce607033d..a325c4a11 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -151,6 +151,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 isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); const isTeam = props.pathname === "/team"; @@ -167,6 +168,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show