Unified · Split
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | "start": "NODE_ENV=production bun src/server.ts" |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | + "@executor/plugin-execution-history-react": "workspace:*", | |
| 12 | 13 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 13 | 14 | "@executor/plugin-graphql-react": "workspace:*", |
| 14 | 15 | "@executor/plugin-mcp-react": "workspace:*", |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | Source, |
| 3 | 3 | } from "@executor/react"; |
| 4 | +import { | |
| 5 | + ExecutionHistoryReactPlugin, | |
| 6 | +} from "@executor/plugin-execution-history-react"; | |
| 4 | 7 | import { |
| 5 | 8 | GoogleDiscoveryReactPlugin, |
| 6 | 9 | getGoogleDiscoveryIconUrl, |
| @@ -22,6 +25,7 @@ import { · 15 unmodified lines | ||
| 22 | 25 | } from "@executor/react/plugins"; |
| 23 | 26 | |
| 24 | 27 | const frontendPlugins = [ |
| 28 | + ExecutionHistoryReactPlugin, | |
| 25 | 29 | McpReactPlugin, |
| 26 | 30 | GraphqlReactPlugin, |
| 27 | 31 | GoogleDiscoveryReactPlugin, |
| @@ -129,7 +129,7 @@ export function ExecutorPluginRoutePage(input: { · 128 unmodified lines | ||
| 129 | 129 | navigation: input.navigation, |
| 130 | 130 | }} |
| 131 | 131 | > |
| 132 | − <div className="h-full min-h-0"> | |
| 132 | + <div className="flex h-full min-h-0 flex-col overflow-hidden"> | |
| 133 | 133 | <PluginPage /> |
| 134 | 134 | </div> |
| 135 | 135 | </ExecutorPluginRouteProvider> |
| @@ -54,6 +54,7 @@ · 53 unmodified lines | ||
| 54 | 54 | "name": "@executor/web", |
| 55 | 55 | "version": "0.0.0", |
| 56 | 56 | "dependencies": { |
| 57 | + "@executor/plugin-execution-history-react": "workspace:*", | |
| 57 | 58 | "@executor/plugin-google-discovery-react": "workspace:*", |
| 58 | 59 | "@executor/plugin-graphql-react": "workspace:*", |
| 59 | 60 | "@executor/plugin-mcp-react": "workspace:*", |
| @@ -405,6 +406,19 @@ · 345 unmodified lines | ||
| 405 | 406 | "bun-types": "catalog:", |
| 406 | 407 | }, |
| 407 | 408 | }, |
| 409 | + "plugins/execution-history/react": { | |
| 410 | + "name": "@executor/plugin-execution-history-react", | |
| 411 | + "version": "0.0.0", | |
| 412 | + "dependencies": { | |
| 413 | + "@executor/react": "workspace:*", | |
| 414 | + "react": "^19.2.0", | |
| 415 | + }, | |
| 416 | + "devDependencies": { | |
| 417 | + "@types/node": "catalog:", | |
| 418 | + "@types/react": "^19.2.10", | |
| 419 | + "bun-types": "catalog:", | |
| 420 | + }, | |
| 421 | + }, | |
| 408 | 422 | "plugins/google-discovery/http": { |
| 409 | 423 | "name": "@executor/plugin-google-discovery-http", |
| 410 | 424 | "version": "0.0.0", |
| @@ -874,6 +888,8 @@ · 463 unmodified lines | ||
| 874 | 888 | |
| 875 | 889 | "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"], |
| 876 | 890 | |
| 891 | + "@executor/plugin-execution-history-react": ["@executor/plugin-execution-history-react@workspace:plugins/execution-history/react"], | |
| 892 | + | |
| 877 | 893 | "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"], |
| 878 | 894 | |
| 879 | 895 | "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"], |
| @@ -326,6 +326,12 @@ const createStorageDomains = (store: SqliteStore) => ({ · 325 unmodified lines | ||
| 326 | 326 | }, |
| 327 | 327 | executions: { |
| 328 | 328 | runs: { |
| 329 | + listByScope: (scopeId: Execution["scopeId"]) => | |
| 330 | + store.db.select().from(executions).where( | |
| 331 | + eq(executions.scopeId, scopeId), | |
| 332 | + ).all() | |
| 333 | + .map((row) => parseJson<Execution>(row.json)) | |
| 334 | + .sort((left, right) => right.createdAt - left.createdAt), | |
| 329 | 335 | getById: (executionId: Execution["id"]) => { |
| 330 | 336 | const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get(); |
| 331 | 337 | return row ? parseJson<Execution>(row.json) : null; |
| @@ -7,6 +7,8 @@ import type { · 6 unmodified lines | ||
| 7 | 7 | SecretListItem, |
| 8 | 8 | } from "@executor/platform-api"; |
| 9 | 9 | import type { |
| 10 | + Execution, | |
| 11 | + ExecutionEnvelope, | |
| 10 | 12 | Source, |
| 11 | 13 | SourceInspection, |
| 12 | 14 | SourceInspectionDiscoverResult, |
| @@ -17,6 +19,8 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines | ||
| 17 | 19 | import { getExecutorApiBaseUrl } from "./base-url"; |
| 18 | 20 | import { getExecutorApiHttpClient } from "./http-client"; |
| 19 | 21 | import { |
| 22 | + executionReactivityKey, | |
| 23 | + executionsReactivityKey, | |
| 20 | 24 | instanceConfigReactivityKey, |
| 21 | 25 | localInstallationReactivityKey, |
| 22 | 26 | secretsReactivityKey, |
| @@ -46,6 +50,28 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 23 unmodified lines | ||
| 46 | 50 | timeToLive: "1 minute", |
| 47 | 51 | }); |
| 48 | 52 | |
| 53 | +export const executionsAtom = (workspaceId: Execution["scopeId"]) => | |
| 54 | + getExecutorApiHttpClient().query("executions", "list", { | |
| 55 | + path: { | |
| 56 | + workspaceId, | |
| 57 | + }, | |
| 58 | + reactivityKeys: executionsReactivityKey(workspaceId), | |
| 59 | + timeToLive: "15 seconds", | |
| 60 | + }); | |
| 61 | + | |
| 62 | +export const executionAtom = ( | |
| 63 | + workspaceId: Execution["scopeId"], | |
| 64 | + executionId: Execution["id"], | |
| 65 | +) => | |
| 66 | + getExecutorApiHttpClient().query("executions", "get", { | |
| 67 | + path: { | |
| 68 | + workspaceId, | |
| 69 | + executionId, | |
| 70 | + }, | |
| 71 | + reactivityKeys: executionReactivityKey(workspaceId, executionId), | |
| 72 | + timeToLive: "15 seconds", | |
| 73 | + }); | |
| 74 | + | |
| 49 | 75 | export const sourcesAtom = (workspaceId: Source["scopeId"]) => |
| 50 | 76 | getExecutorApiHttpClient().query("sources", "list", { |
| 51 | 77 | path: { |
| @@ -142,6 +168,8 @@ export type ExecutorApiAtoms = { · 90 unmodified lines | ||
| 142 | 168 | localInstallationAtom: typeof localInstallationAtom; |
| 143 | 169 | instanceConfigAtom: typeof instanceConfigAtom; |
| 144 | 170 | secretsAtom: typeof secretsAtom; |
| 171 | + executionsAtom: typeof executionsAtom; | |
| 172 | + executionAtom: typeof executionAtom; | |
| 145 | 173 | sourcesAtom: typeof sourcesAtom; |
| 146 | 174 | sourceAtom: typeof sourceAtom; |
| 147 | 175 | sourceInspectionAtom: typeof sourceInspectionAtom; |
| @@ -153,6 +181,8 @@ export type ExecutorApiAtomValues = { · 5 unmodified lines | ||
| 153 | 181 | localInstallation: LocalInstallation; |
| 154 | 182 | instanceConfig: InstanceConfig; |
| 155 | 183 | secrets: ReadonlyArray<SecretListItem>; |
| 184 | + executions: ReadonlyArray<Execution>; | |
| 185 | + execution: ExecutionEnvelope; | |
| 156 | 186 | sources: ReadonlyArray<Source>; |
| 157 | 187 | source: Source; |
| 158 | 188 | sourceInspection: SourceInspection; |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { Source } from "@executor/platform-sdk/schema"; | |
| 1 | +import type { Execution, Source } from "@executor/platform-sdk/schema"; | |
| 2 | 2 | |
| 3 | 3 | import type { ReactivityKeys } from "./types"; |
| 4 | 4 | |
| @@ -20,6 +20,19 @@ export const sourcesReactivityKey = ( · 15 unmodified lines | ||
| 20 | 20 | sources: [workspaceId], |
| 21 | 21 | }); |
| 22 | 22 | |
| 23 | +export const executionsReactivityKey = ( | |
| 24 | + workspaceId: Execution["scopeId"], | |
| 25 | +): ReactivityKeys => ({ | |
| 26 | + executions: [workspaceId], | |
| 27 | +}); | |
| 28 | + | |
| 29 | +export const executionReactivityKey = ( | |
| 30 | + workspaceId: Execution["scopeId"], | |
| 31 | + executionId: Execution["id"], | |
| 32 | +): ReactivityKeys => ({ | |
| 33 | + execution: [workspaceId, executionId], | |
| 34 | +}); | |
| 35 | + | |
| 23 | 36 | export const sourceReactivityKey = ( |
| 24 | 37 | workspaceId: Source["scopeId"], |
| 25 | 38 | sourceId: Source["id"], |
| @@ -0,0 +1,46 @@ | ||
| 1 | +import type { | |
| 2 | + Execution, | |
| 3 | + ExecutionEnvelope, | |
| 4 | +} from "@executor/platform-sdk/schema"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + executionAtom, | |
| 8 | + executionsAtom, | |
| 9 | +} from "../core/api-atoms"; | |
| 10 | +import { | |
| 11 | + disabledAtom, | |
| 12 | + useLoadableAtom, | |
| 13 | +} from "../core/loadable"; | |
| 14 | +import type { | |
| 15 | + Loadable, | |
| 16 | +} from "../core/types"; | |
| 17 | +import { | |
| 18 | + pendingLoadable, | |
| 19 | + useWorkspaceRequestContext, | |
| 20 | +} from "../core/workspace"; | |
| 21 | + | |
| 22 | +export const useExecutions = (): Loadable<ReadonlyArray<Execution>> => { | |
| 23 | + const workspace = useWorkspaceRequestContext(); | |
| 24 | + const atom = workspace.enabled | |
| 25 | + ? executionsAtom(workspace.workspaceId) | |
| 26 | + : disabledAtom<ReadonlyArray<Execution>>(); | |
| 27 | + const executions = useLoadableAtom(atom); | |
| 28 | + | |
| 29 | + return workspace.enabled | |
| 30 | + ? executions | |
| 31 | + : pendingLoadable(workspace.workspace); | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export const useExecution = ( | |
| 35 | + executionId: string, | |
| 36 | +): Loadable<ExecutionEnvelope> => { | |
| 37 | + const workspace = useWorkspaceRequestContext(); | |
| 38 | + const atom = workspace.enabled | |
| 39 | + ? executionAtom(workspace.workspaceId, executionId as Execution["id"]) | |
| 40 | + : disabledAtom<ExecutionEnvelope>(); | |
| 41 | + const execution = useLoadableAtom(atom); | |
| 42 | + | |
| 43 | + return workspace.enabled | |
| 44 | + ? execution | |
| 45 | + : pendingLoadable(workspace.workspace); | |
| 46 | +}; | |
| @@ -21,6 +21,9 @@ export type { · 20 unmodified lines | ||
| 21 | 21 | } from "@executor/platform-api"; |
| 22 | 22 | |
| 23 | 23 | export type { |
| 24 | + Execution, | |
| 25 | + ExecutionEnvelope, | |
| 26 | + ExecutionStatus, | |
| 24 | 27 | Source, |
| 25 | 28 | SourceInspection, |
| 26 | 29 | SourceInspectionDiscoverResult, |
| @@ -63,6 +66,10 @@ export { · 36 unmodified lines | ||
| 63 | 66 | useSecrets, |
| 64 | 67 | useUpdateSecret, |
| 65 | 68 | } from "./hooks/secrets"; |
| 69 | +export { | |
| 70 | + useExecution, | |
| 71 | + useExecutions, | |
| 72 | +} from "./hooks/executions"; | |
| 66 | 73 | export { |
| 67 | 74 | usePrefetchToolDetail, |
| 68 | 75 | useRemoveSource, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | +import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { |
| 3 | 4 | CreateExecutionPayloadSchema, |
| 4 | 5 | ResumeExecutionPayloadSchema, |
| @@ -8,6 +9,7 @@ export type { · 3 unmodified lines | ||
| 8 | 9 | ResumeExecutionPayload, |
| 9 | 10 | } from "@executor/platform-sdk/contracts"; |
| 10 | 11 | import { |
| 12 | + ExecutionSchema, | |
| 11 | 13 | ExecutionIdSchema, |
| 12 | 14 | ExecutionEnvelopeSchema, |
| 13 | 15 | ScopeIdSchema as WorkspaceIdSchema, |
| @@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines | ||
| 30 | 32 | const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema); |
| 31 | 33 | |
| 32 | 34 | export class ExecutionsApi extends HttpApiGroup.make("executions") |
| 35 | + .add( | |
| 36 | + HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/executions` | |
| 37 | + .addSuccess(Schema.Array(ExecutionSchema)) | |
| 38 | + .addError(ControlPlaneBadRequestError) | |
| 39 | + .addError(ControlPlaneUnauthorizedError) | |
| 40 | + .addError(ControlPlaneForbiddenError) | |
| 41 | + .addError(ControlPlaneNotFoundError) | |
| 42 | + .addError(ControlPlaneStorageError), | |
| 43 | + ) | |
| 33 | 44 | .add( |
| 34 | 45 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions` |
| 35 | 46 | .setPayload(CreateExecutionPayloadSchema) |
| @@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines | ||
| 9 | 9 | "executions", |
| 10 | 10 | (handlers) => |
| 11 | 11 | handlers |
| 12 | + .handle("list", ({ path }) => | |
| 13 | + resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe( | |
| 14 | + Effect.flatMap((executor) => executor.executions.list()), | |
| 15 | + ), | |
| 16 | + ) | |
| 12 | 17 | .handle("create", ({ path, payload }) => |
| 13 | 18 | resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe( |
| 14 | 19 | Effect.flatMap((executor) => executor.executions.create(payload)), |
| @@ -409,6 +409,15 @@ export const createLocalExecutorStateStore = ( · 408 unmodified lines | ||
| 409 | 409 | }), |
| 410 | 410 | }, |
| 411 | 411 | executions: { |
| 412 | + listByScope: (scopeId: Execution["scopeId"]) => | |
| 413 | + stateManager.read((state) => | |
| 414 | + cloneValue( | |
| 415 | + state.executions | |
| 416 | + .filter((candidate) => candidate.scopeId === scopeId) | |
| 417 | + .sort((left, right) => right.createdAt - left.createdAt), | |
| 418 | + ) | |
| 419 | + ), | |
| 420 | + | |
| 412 | 421 | getById: (executionId: Execution["id"]) => |
| 413 | 422 | stateManager.read((state) => { |
| 414 | 423 | const execution = state.executions.find( |
| @@ -183,6 +183,7 @@ const toExecutionsBackend = ( · 182 unmodified lines | ||
| 183 | 183 | input: ExecutorExecutionRepository, |
| 184 | 184 | ): RuntimeExecutionStorageServices => ({ |
| 185 | 185 | runs: { |
| 186 | + listByScope: (scopeId) => toEffect(input.runs.listByScope(scopeId)), | |
| 186 | 187 | getById: (executionId) => toOptionEffect(input.runs.getById(executionId)), |
| 187 | 188 | getByScopeAndId: (scopeId, executionId) => |
| 188 | 189 | toOptionEffect(input.runs.getByScopeAndId(scopeId, executionId)), |
| @@ -66,6 +66,7 @@ import { · 65 unmodified lines | ||
| 66 | 66 | import { |
| 67 | 67 | createExecution, |
| 68 | 68 | getExecution, |
| 69 | + listExecutions, | |
| 69 | 70 | resumeExecution, |
| 70 | 71 | } from "./runtime/execution/service"; |
| 71 | 72 | import type { |
| @@ -153,6 +154,7 @@ export type ExecutorEffect = { · 81 unmodified lines | ||
| 153 | 154 | }; |
| 154 | 155 | }; |
| 155 | 156 | executions: { |
| 157 | + list: () => ProvidedEffect<ReturnType<typeof listExecutions>>; | |
| 156 | 158 | create: ( |
| 157 | 159 | payload: CreateExecutionPayload, |
| 158 | 160 | ) => ProvidedEffect<ReturnType<typeof createExecution>>; |
| @@ -241,6 +243,7 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 82 unmodified lines | ||
| 241 | 243 | }, |
| 242 | 244 | }, |
| 243 | 245 | executions: { |
| 246 | + list: () => provide(listExecutions({ scopeId })), | |
| 244 | 247 | create: (payload) => |
| 245 | 248 | provide( |
| 246 | 249 | createExecution({ |
| @@ -120,6 +120,7 @@ const toPromiseExecutor = < · 119 unmodified lines | ||
| 120 | 120 | }, |
| 121 | 121 | }, |
| 122 | 122 | executions: { |
| 123 | + list: () => run(executor.executions.list()), | |
| 123 | 124 | create: (payload: Parameters<typeof executor.executions.create>[0]) => |
| 124 | 125 | run(executor.executions.create(payload)), |
| 125 | 126 | get: (executionId: Parameters<typeof executor.executions.get>[0]) => |
| @@ -132,4 +132,45 @@ describe("execution-http", () => { · 131 unmodified lines | ||
| 132 | 132 | }), |
| 133 | 133 | 60_000, |
| 134 | 134 | ); |
| 135 | + | |
| 136 | + it.scoped("lists executions through the HTTP API", () => | |
| 137 | + Effect.gen(function* () { | |
| 138 | + const runtime = yield* makeRuntime; | |
| 139 | + const installation = runtime.localInstallation; | |
| 140 | + | |
| 141 | + const created = yield* withExecutorApiClient( | |
| 142 | + { | |
| 143 | + runtime, | |
| 144 | + actorScopeId: installation.actorScopeId, | |
| 145 | + }, | |
| 146 | + (client) => | |
| 147 | + client.executions.create({ | |
| 148 | + path: { | |
| 149 | + workspaceId: installation.scopeId, | |
| 150 | + }, | |
| 151 | + payload: { | |
| 152 | + code: "return await tools.math.add({ a: 1, b: 2 });", | |
| 153 | + }, | |
| 154 | + }), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + const executions = yield* withExecutorApiClient( | |
| 158 | + { | |
| 159 | + runtime, | |
| 160 | + actorScopeId: installation.actorScopeId, | |
| 161 | + }, | |
| 162 | + (client) => | |
| 163 | + client.executions.list({ | |
| 164 | + path: { | |
| 165 | + workspaceId: installation.scopeId, | |
| 166 | + }, | |
| 167 | + }), | |
| 168 | + ); | |
| 169 | + | |
| 170 | + expect(executions.some((execution) => execution.id === created.execution.id)).toBe( | |
| 171 | + true, | |
| 172 | + ); | |
| 173 | + }), | |
| 174 | + 60_000, | |
| 175 | + ); | |
| 135 | 176 | }); |
| @@ -58,6 +58,7 @@ import { · 57 unmodified lines | ||
| 58 | 58 | |
| 59 | 59 | const executionOps = { |
| 60 | 60 | create: operationErrors("executions.create"), |
| 61 | + list: operationErrors("executions.list"), | |
| 61 | 62 | get: operationErrors("executions.get"), |
| 62 | 63 | resume: operationErrors("executions.resume"), |
| 63 | 64 | } as const; |
| @@ -904,6 +905,13 @@ export const getExecution = (input: { · 840 unmodified lines | ||
| 904 | 905 | }) |
| 905 | 906 | ); |
| 906 | 907 | |
| 908 | +export const listExecutions = (input: { | |
| 909 | + scopeId: ScopeId; | |
| 910 | +}) => | |
| 911 | + Effect.flatMap(ExecutorStateStore, (store) => | |
| 912 | + executionOps.list.mapStorage(store.executions.listByScope(input.scopeId)) | |
| 913 | + ); | |
| 914 | + | |
| 907 | 915 | export const submitExecutionInteractionResponse = (input: { |
| 908 | 916 | executionId: ExecutionId; |
| 909 | 917 | response: ElicitationResponse; |
| @@ -35,6 +35,9 @@ export type ExecutorStateStoreShape = { · 34 unmodified lines | ||
| 35 | 35 | removeById: (id: SecretMaterial["id"]) => Effect.Effect<boolean, Error, never>; |
| 36 | 36 | }; |
| 37 | 37 | executions: { |
| 38 | + listByScope: ( | |
| 39 | + scopeId: Execution["scopeId"], | |
| 40 | + ) => Effect.Effect<readonly Execution[], Error, never>; | |
| 38 | 41 | getById: ( |
| 39 | 42 | executionId: Execution["id"], |
| 40 | 43 | ) => Effect.Effect<import("effect/Option").Option<Execution>, Error, never>; |
| @@ -0,0 +1,301 @@ | ||
| 1 | +import type { | |
| 2 | + Execution, | |
| 3 | + ExecutionEnvelope, | |
| 4 | +} from "@executor/react"; | |
| 5 | +import { | |
| 6 | + useExecution, | |
| 7 | + useExecutions, | |
| 8 | +} from "@executor/react"; | |
| 9 | +import { | |
| 10 | + Badge, | |
| 11 | + DocumentPanel, | |
| 12 | + EmptyState, | |
| 13 | + LoadableBlock, | |
| 14 | + useExecutorPluginNavigation, | |
| 15 | + useExecutorPluginRouteParams, | |
| 16 | +} from "@executor/react/plugins"; | |
| 17 | + | |
| 18 | +const formatTimestamp = (value: number | null): string => { | |
| 19 | + if (value === null) { | |
| 20 | + return "—"; | |
| 21 | + } | |
| 22 | + | |
| 23 | + return new Intl.DateTimeFormat(undefined, { | |
| 24 | + dateStyle: "medium", | |
| 25 | + timeStyle: "short", | |
| 26 | + }).format(value); | |
| 27 | +}; | |
| 28 | + | |
| 29 | +const formatDuration = (execution: Execution): string | null => { | |
| 30 | + if (execution.startedAt === null || execution.completedAt === null) { | |
| 31 | + return null; | |
| 32 | + } | |
| 33 | + | |
| 34 | + const durationMs = Math.max(0, execution.completedAt - execution.startedAt); | |
| 35 | + if (durationMs < 1_000) { | |
| 36 | + return `${durationMs} ms`; | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (durationMs < 60_000) { | |
| 40 | + return `${(durationMs / 1_000).toFixed(1)} s`; | |
| 41 | + } | |
| 42 | + | |
| 43 | + return `${(durationMs / 60_000).toFixed(1)} min`; | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const summarizeCode = (code: string): string => | |
| 47 | + code | |
| 48 | + .trim() | |
| 49 | + .replace(/\s+/g, " ") | |
| 50 | + .slice(0, 220); | |
| 51 | + | |
| 52 | +const formatJsonDocument = (value: string | null): string | null => { | |
| 53 | + if (value === null) { | |
| 54 | + return null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + try { | |
| 58 | + return JSON.stringify(JSON.parse(value), null, 2); | |
| 59 | + } catch { | |
| 60 | + return value; | |
| 61 | + } | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const statusVariant = ( | |
| 65 | + status: Execution["status"], | |
| 66 | +): "default" | "muted" | "outline" | "destructive" => { | |
| 67 | + switch (status) { | |
| 68 | + case "completed": | |
| 69 | + return "default"; | |
| 70 | + case "failed": | |
| 71 | + return "destructive"; | |
| 72 | + case "running": | |
| 73 | + case "waiting_for_interaction": | |
| 74 | + return "outline"; | |
| 75 | + default: | |
| 76 | + return "muted"; | |
| 77 | + } | |
| 78 | +}; | |
| 79 | + | |
| 80 | +const MetadataItem = (props: { | |
| 81 | + label: string; | |
| 82 | + value: string; | |
| 83 | +}) => ( | |
| 84 | + <div className="rounded-lg border border-border bg-card/60 px-3 py-2"> | |
| 85 | + <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 86 | + {props.label} | |
| 87 | + </div> | |
| 88 | + <div className="mt-1 text-sm text-foreground">{props.value}</div> | |
| 89 | + </div> | |
| 90 | +); | |
| 91 | + | |
| 92 | +const ExecutionCard = (props: { | |
| 93 | + execution: Execution; | |
| 94 | + onOpen: () => void; | |
| 95 | +}) => { | |
| 96 | + const { execution } = props; | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <button | |
| 100 | + type="button" | |
| 101 | + onClick={props.onOpen} | |
| 102 | + className="w-full rounded-2xl border border-border bg-card px-5 py-4 text-left transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 103 | + > | |
| 104 | + <div className="flex items-start justify-between gap-3"> | |
| 105 | + <div className="min-w-0 flex-1"> | |
| 106 | + <div className="flex items-center gap-2"> | |
| 107 | + <div className="truncate text-sm font-semibold text-foreground"> | |
| 108 | + {execution.id} | |
| 109 | + </div> | |
| 110 | + <Badge variant={statusVariant(execution.status)}> | |
| 111 | + {execution.status.replaceAll("_", " ")} | |
| 112 | + </Badge> | |
| 113 | + </div> | |
| 114 | + <div className="mt-1 text-xs text-muted-foreground"> | |
| 115 | + Created {formatTimestamp(execution.createdAt)} | |
| 116 | + {formatDuration(execution) ? ` • ${formatDuration(execution)}` : ""} | |
| 117 | + </div> | |
| 118 | + <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground"> | |
| 119 | + {summarizeCode(execution.code) || "No code captured."} | |
| 120 | + </p> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + {execution.errorText && ( | |
| 125 | + <div className="mt-3 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive"> | |
| 126 | + {execution.errorText} | |
| 127 | + </div> | |
| 128 | + )} | |
| 129 | + </button> | |
| 130 | + ); | |
| 131 | +}; | |
| 132 | + | |
| 133 | +export function ExecutionHistoryPage() { | |
| 134 | + const executions = useExecutions(); | |
| 135 | + const navigation = useExecutorPluginNavigation(); | |
| 136 | + | |
| 137 | + return ( | |
| 138 | + <div className="h-full overflow-y-auto"> | |
| 139 | + <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 140 | + <div className="mb-8"> | |
| 141 | + <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | |
| 142 | + Execution history | |
| 143 | + </h1> | |
| 144 | + <p className="mt-1.5 text-[14px] text-muted-foreground"> | |
| 145 | + Every execution recorded for this workspace, newest first. | |
| 146 | + </p> | |
| 147 | + </div> | |
| 148 | + | |
| 149 | + <LoadableBlock loadable={executions} loading="Loading executions..."> | |
| 150 | + {(items) => | |
| 151 | + items.length === 0 ? ( | |
| 152 | + <div className="rounded-2xl border border-dashed border-border py-20"> | |
| 153 | + <EmptyState | |
| 154 | + title="No executions yet" | |
| 155 | + description="Run something and it will show up here." | |
| 156 | + /> | |
| 157 | + </div> | |
| 158 | + ) : ( | |
| 159 | + <div className="grid gap-3"> | |
| 160 | + {items.map((execution) => ( | |
| 161 | + <ExecutionCard | |
| 162 | + key={execution.id} | |
| 163 | + execution={execution} | |
| 164 | + onOpen={() => { | |
| 165 | + void navigation.route(execution.id); | |
| 166 | + }} | |
| 167 | + /> | |
| 168 | + ))} | |
| 169 | + </div> | |
| 170 | + ) | |
| 171 | + } | |
| 172 | + </LoadableBlock> | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + ); | |
| 176 | +} | |
| 177 | + | |
| 178 | +const ExecutionDetailSections = (props: { | |
| 179 | + envelope: ExecutionEnvelope; | |
| 180 | +}) => { | |
| 181 | + const { execution, pendingInteraction } = props.envelope; | |
| 182 | + | |
| 183 | + return ( | |
| 184 | + <div className="space-y-6"> | |
| 185 | + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> | |
| 186 | + <MetadataItem label="Status" value={execution.status.replaceAll("_", " ")} /> | |
| 187 | + <MetadataItem label="Created" value={formatTimestamp(execution.createdAt)} /> | |
| 188 | + <MetadataItem label="Started" value={formatTimestamp(execution.startedAt)} /> | |
| 189 | + <MetadataItem | |
| 190 | + label="Completed" | |
| 191 | + value={formatTimestamp(execution.completedAt)} | |
| 192 | + /> | |
| 193 | + </div> | |
| 194 | + | |
| 195 | + <DocumentPanel | |
| 196 | + title="Code" | |
| 197 | + body={execution.code} | |
| 198 | + lang="ts" | |
| 199 | + empty="No code captured." | |
| 200 | + /> | |
| 201 | + | |
| 202 | + <div className="grid gap-4 xl:grid-cols-2"> | |
| 203 | + <DocumentPanel | |
| 204 | + title="Result" | |
| 205 | + body={formatJsonDocument(execution.resultJson)} | |
| 206 | + lang="json" | |
| 207 | + empty="No result recorded." | |
| 208 | + /> | |
| 209 | + <DocumentPanel | |
| 210 | + title="Logs" | |
| 211 | + body={formatJsonDocument(execution.logsJson)} | |
| 212 | + lang="json" | |
| 213 | + empty="No logs recorded." | |
| 214 | + /> | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + {execution.errorText && ( | |
| 218 | + <DocumentPanel | |
| 219 | + title="Error" | |
| 220 | + body={execution.errorText} | |
| 221 | + lang="text" | |
| 222 | + empty="No error recorded." | |
| 223 | + /> | |
| 224 | + )} | |
| 225 | + | |
| 226 | + {pendingInteraction && ( | |
| 227 | + <div className="space-y-4"> | |
| 228 | + <div> | |
| 229 | + <h2 className="text-sm font-semibold text-foreground"> | |
| 230 | + Pending interaction | |
| 231 | + </h2> | |
| 232 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 233 | + {pendingInteraction.kind} • {pendingInteraction.purpose} | |
| 234 | + </p> | |
| 235 | + </div> | |
| 236 | + | |
| 237 | + <div className="grid gap-3 sm:grid-cols-3"> | |
| 238 | + <MetadataItem label="Kind" value={pendingInteraction.kind} /> | |
| 239 | + <MetadataItem label="Purpose" value={pendingInteraction.purpose} /> | |
| 240 | + <MetadataItem | |
| 241 | + label="Status" | |
| 242 | + value={pendingInteraction.status.replaceAll("_", " ")} | |
| 243 | + /> | |
| 244 | + </div> | |
| 245 | + | |
| 246 | + <div className="grid gap-4 xl:grid-cols-2"> | |
| 247 | + <DocumentPanel | |
| 248 | + title="Request" | |
| 249 | + body={formatJsonDocument(pendingInteraction.payloadJson)} | |
| 250 | + lang="json" | |
| 251 | + empty="No interaction request recorded." | |
| 252 | + /> | |
| 253 | + <DocumentPanel | |
| 254 | + title="Response" | |
| 255 | + body={formatJsonDocument(pendingInteraction.responseJson)} | |
| 256 | + lang="json" | |
| 257 | + empty="No interaction response recorded." | |
| 258 | + /> | |
| 259 | + </div> | |
| 260 | + </div> | |
| 261 | + )} | |
| 262 | + </div> | |
| 263 | + ); | |
| 264 | +}; | |
| 265 | + | |
| 266 | +export function ExecutionHistoryDetailPage() { | |
| 267 | + const { executionId } = useExecutorPluginRouteParams<{ | |
| 268 | + executionId: string; | |
| 269 | + }>(); | |
| 270 | + const navigation = useExecutorPluginNavigation(); | |
| 271 | + const execution = useExecution(executionId); | |
| 272 | + | |
| 273 | + return ( | |
| 274 | + <div className="h-full overflow-y-auto"> | |
| 275 | + <div className="mx-auto max-w-6xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 276 | + <div className="mb-8 flex flex-wrap items-center justify-between gap-4"> | |
| 277 | + <div> | |
| 278 | + <button | |
| 279 | + type="button" | |
| 280 | + onClick={() => { | |
| 281 | + void navigation.route(); | |
| 282 | + }} | |
| 283 | + className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground" | |
| 284 | + > | |
| 285 | + Back to history | |
| 286 | + </button> | |
| 287 | + <div className="flex items-center gap-3"> | |
| 288 | + <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl"> | |
| 289 | + {executionId} | |
| 290 | + </h1> | |
| 291 | + </div> | |
| 292 | + </div> | |
| 293 | + </div> | |
| 294 | + | |
| 295 | + <LoadableBlock loadable={execution} loading="Loading execution..."> | |
| 296 | + {(envelope) => <ExecutionDetailSections envelope={envelope} />} | |
| 297 | + </LoadableBlock> | |
| 298 | + </div> | |
| 299 | + </div> | |
| 300 | + ); | |
| 301 | +} | |
| @@ -0,0 +1,29 @@ | ||
| 1 | +import { | |
| 2 | + defineExecutorFrontendPlugin, | |
| 3 | +} from "@executor/react/plugins"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + ExecutionHistoryDetailPage, | |
| 7 | + ExecutionHistoryPage, | |
| 8 | +} from "./components"; | |
| 9 | + | |
| 10 | +export const ExecutionHistoryReactPlugin = defineExecutorFrontendPlugin({ | |
| 11 | + key: "execution-history", | |
| 12 | + displayName: "Execution History", | |
| 13 | + description: "Browse previous executions for this workspace.", | |
| 14 | + routes: [ | |
| 15 | + { | |
| 16 | + key: "history", | |
| 17 | + component: ExecutionHistoryPage, | |
| 18 | + nav: { | |
| 19 | + label: "Runs", | |
| 20 | + section: "main", | |
| 21 | + }, | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + key: "detail", | |
| 25 | + path: "$executionId", | |
| 26 | + component: ExecutionHistoryDetailPage, | |
| 27 | + }, | |
| 28 | + ], | |
| 29 | +}); | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor/plugin-execution-history-react", | |
| 3 | + "private": true, | |
| 4 | + "type": "module", | |
| 5 | + "exports": { | |
| 6 | + ".": "./index.ts" | |
| 7 | + }, | |
| 8 | + "files": [ | |
| 9 | + "components.tsx", | |
| 10 | + "index.ts" | |
| 11 | + ], | |
| 12 | + "scripts": { | |
| 13 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 14 | + }, | |
| 15 | + "dependencies": { | |
| 16 | + "@executor/react": "workspace:*", | |
| 17 | + "react": "^19.2.0" | |
| 18 | + }, | |
| 19 | + "devDependencies": { | |
| 20 | + "@types/node": "catalog:", | |
| 21 | + "@types/react": "^19.2.10", | |
| 22 | + "bun-types": "catalog:" | |
| 23 | + }, | |
| 24 | + "version": "0.0.0" | |
| 25 | +} | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "strict": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "noEmit": true, | |
| 9 | + "lib": ["ES2022", "DOM", "DOM.Iterable"], | |
| 10 | + "jsx": "react-jsx", | |
| 11 | + "types": ["bun-types", "node"] | |
| 12 | + }, | |
| 13 | + "include": [ | |
| 14 | + "*.ts", | |
| 15 | + "*.tsx" | |
| 16 | + ] | |
| 17 | +} | |