History

add execution history plugin

Rhys Sullivan committed 7 months ago

Commit c2aefc61 parent c7503a222 files · +579 −2Browse files

Unified · Split

22 files
  1. apps/web/package.json +1 −0
  2. apps/web/src/plugins/index.ts +4 −0
  3. apps/web/src/plugins/pages.tsx +1 −1
  4. bun.lock +16 −0
  5. examples/sqlite-sdk-consumer/src/sqlite-backend.ts +6 −0
  6. packages/clients/react/src/core/api-atoms.ts +30 −0
  7. packages/clients/react/src/core/reactivity.ts +14 −1
  8. packages/clients/react/src/hooks/executions.ts +46 −0
  9. packages/clients/react/src/index.ts +7 −0
  10. packages/platform/api/src/executions/api.ts +11 −0
  11. packages/platform/api/src/executions/http.ts +5 −0
  12. packages/platform/sdk-file/src/executor-state-store.ts +9 −0
  13. packages/platform/sdk/src/backend.ts +1 −0
  14. packages/platform/sdk/src/executor-effect.ts +3 −0
  15. packages/platform/sdk/src/executor.ts +1 −0
  16. packages/platform/sdk/src/runtime/execution/http.test.ts +41 −0
  17. packages/platform/sdk/src/runtime/execution/service.ts +8 −0
  18. packages/platform/sdk/src/runtime/executor-state-store.ts +3 −0
  19. plugins/execution-history/react/components.tsx +301 −0
  20. plugins/execution-history/react/index.ts +29 −0
  21. plugins/execution-history/react/package.json +25 −0
  22. plugins/execution-history/react/tsconfig.json +17 −0
apps/web/package.json+1−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 "start": "NODE_ENV=production bun src/server.ts"9 "start": "NODE_ENV=production bun src/server.ts"
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@executor/plugin-execution-history-react": "workspace:*",
12 "@executor/plugin-google-discovery-react": "workspace:*",13 "@executor/plugin-google-discovery-react": "workspace:*",
13 "@executor/plugin-graphql-react": "workspace:*",14 "@executor/plugin-graphql-react": "workspace:*",
14 "@executor/plugin-mcp-react": "workspace:*",15 "@executor/plugin-mcp-react": "workspace:*",
apps/web/src/plugins/index.ts+4−0
@@ -1,6 +1,9 @@
1import type {1import type {
2 Source,2 Source,
3} from "@executor/react";3} from "@executor/react";
4import {
5 ExecutionHistoryReactPlugin,
6} from "@executor/plugin-execution-history-react";
4import {7import {
5 GoogleDiscoveryReactPlugin,8 GoogleDiscoveryReactPlugin,
6 getGoogleDiscoveryIconUrl,9 getGoogleDiscoveryIconUrl,
@@ -22,6 +25,7 @@ import { · 15 unmodified lines
22} from "@executor/react/plugins";25} from "@executor/react/plugins";
23 26
24const frontendPlugins = [27const frontendPlugins = [
28 ExecutionHistoryReactPlugin,
25 McpReactPlugin,29 McpReactPlugin,
26 GraphqlReactPlugin,30 GraphqlReactPlugin,
27 GoogleDiscoveryReactPlugin,31 GoogleDiscoveryReactPlugin,
apps/web/src/plugins/pages.tsx+1−1
@@ -129,7 +129,7 @@ export function ExecutorPluginRoutePage(input: { · 128 unmodified lines
129 navigation: input.navigation,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 <PluginPage />133 <PluginPage />
134 </div>134 </div>
135 </ExecutorPluginRouteProvider>135 </ExecutorPluginRouteProvider>
bun.lock+16−0
@@ -54,6 +54,7 @@ · 53 unmodified lines
54 "name": "@executor/web",54 "name": "@executor/web",
55 "version": "0.0.0",55 "version": "0.0.0",
56 "dependencies": {56 "dependencies": {
57 "@executor/plugin-execution-history-react": "workspace:*",
57 "@executor/plugin-google-discovery-react": "workspace:*",58 "@executor/plugin-google-discovery-react": "workspace:*",
58 "@executor/plugin-graphql-react": "workspace:*",59 "@executor/plugin-graphql-react": "workspace:*",
59 "@executor/plugin-mcp-react": "workspace:*",60 "@executor/plugin-mcp-react": "workspace:*",
@@ -405,6 +406,19 @@ · 345 unmodified lines
405 "bun-types": "catalog:",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 "plugins/google-discovery/http": {422 "plugins/google-discovery/http": {
409 "name": "@executor/plugin-google-discovery-http",423 "name": "@executor/plugin-google-discovery-http",
410 "version": "0.0.0",424 "version": "0.0.0",
@@ -874,6 +888,8 @@ · 463 unmodified lines
874 888
875 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],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 "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"],893 "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"],
878 894
879 "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"],895 "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"],
examples/sqlite-sdk-consumer/src/sqlite-backend.ts+6−0
@@ -326,6 +326,12 @@ const createStorageDomains = (store: SqliteStore) => ({ · 325 unmodified lines
326 },326 },
327 executions: {327 executions: {
328 runs: {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 getById: (executionId: Execution["id"]) => {335 getById: (executionId: Execution["id"]) => {
330 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();336 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();
331 return row ? parseJson<Execution>(row.json) : null;337 return row ? parseJson<Execution>(row.json) : null;
packages/clients/react/src/core/api-atoms.ts+30−0
@@ -7,6 +7,8 @@ import type { · 6 unmodified lines
7 SecretListItem,7 SecretListItem,
8} from "@executor/platform-api";8} from "@executor/platform-api";
9import type {9import type {
10 Execution,
11 ExecutionEnvelope,
10 Source,12 Source,
11 SourceInspection,13 SourceInspection,
12 SourceInspectionDiscoverResult,14 SourceInspectionDiscoverResult,
@@ -17,6 +19,8 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines
17import { getExecutorApiBaseUrl } from "./base-url";19import { getExecutorApiBaseUrl } from "./base-url";
18import { getExecutorApiHttpClient } from "./http-client";20import { getExecutorApiHttpClient } from "./http-client";
19import {21import {
22 executionReactivityKey,
23 executionsReactivityKey,
20 instanceConfigReactivityKey,24 instanceConfigReactivityKey,
21 localInstallationReactivityKey,25 localInstallationReactivityKey,
22 secretsReactivityKey,26 secretsReactivityKey,
@@ -46,6 +50,28 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 23 unmodified lines
46 timeToLive: "1 minute",50 timeToLive: "1 minute",
47 });51 });
48 52
53export 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
62export 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
49export const sourcesAtom = (workspaceId: Source["scopeId"]) =>75export const sourcesAtom = (workspaceId: Source["scopeId"]) =>
50 getExecutorApiHttpClient().query("sources", "list", {76 getExecutorApiHttpClient().query("sources", "list", {
51 path: {77 path: {
@@ -142,6 +168,8 @@ export type ExecutorApiAtoms = { · 90 unmodified lines
142 localInstallationAtom: typeof localInstallationAtom;168 localInstallationAtom: typeof localInstallationAtom;
143 instanceConfigAtom: typeof instanceConfigAtom;169 instanceConfigAtom: typeof instanceConfigAtom;
144 secretsAtom: typeof secretsAtom;170 secretsAtom: typeof secretsAtom;
171 executionsAtom: typeof executionsAtom;
172 executionAtom: typeof executionAtom;
145 sourcesAtom: typeof sourcesAtom;173 sourcesAtom: typeof sourcesAtom;
146 sourceAtom: typeof sourceAtom;174 sourceAtom: typeof sourceAtom;
147 sourceInspectionAtom: typeof sourceInspectionAtom;175 sourceInspectionAtom: typeof sourceInspectionAtom;
@@ -153,6 +181,8 @@ export type ExecutorApiAtomValues = { · 5 unmodified lines
153 localInstallation: LocalInstallation;181 localInstallation: LocalInstallation;
154 instanceConfig: InstanceConfig;182 instanceConfig: InstanceConfig;
155 secrets: ReadonlyArray<SecretListItem>;183 secrets: ReadonlyArray<SecretListItem>;
184 executions: ReadonlyArray<Execution>;
185 execution: ExecutionEnvelope;
156 sources: ReadonlyArray<Source>;186 sources: ReadonlyArray<Source>;
157 source: Source;187 source: Source;
158 sourceInspection: SourceInspection;188 sourceInspection: SourceInspection;
packages/clients/react/src/core/reactivity.ts+14−1
@@ -1,4 +1,4 @@
1import type { Source } from "@executor/platform-sdk/schema";1import type { Execution, Source } from "@executor/platform-sdk/schema";
2 2
3import type { ReactivityKeys } from "./types";3import type { ReactivityKeys } from "./types";
4 4
@@ -20,6 +20,19 @@ export const sourcesReactivityKey = ( · 15 unmodified lines
20 sources: [workspaceId],20 sources: [workspaceId],
21});21});
22 22
23export const executionsReactivityKey = (
24 workspaceId: Execution["scopeId"],
25): ReactivityKeys => ({
26 executions: [workspaceId],
27});
28
29export const executionReactivityKey = (
30 workspaceId: Execution["scopeId"],
31 executionId: Execution["id"],
32): ReactivityKeys => ({
33 execution: [workspaceId, executionId],
34});
35
23export const sourceReactivityKey = (36export const sourceReactivityKey = (
24 workspaceId: Source["scopeId"],37 workspaceId: Source["scopeId"],
25 sourceId: Source["id"],38 sourceId: Source["id"],
packages/clients/react/src/hooks/executions.ts+46−0
@@ -0,0 +1,46 @@
1import type {
2 Execution,
3 ExecutionEnvelope,
4} from "@executor/platform-sdk/schema";
5
6import {
7 executionAtom,
8 executionsAtom,
9} from "../core/api-atoms";
10import {
11 disabledAtom,
12 useLoadableAtom,
13} from "../core/loadable";
14import type {
15 Loadable,
16} from "../core/types";
17import {
18 pendingLoadable,
19 useWorkspaceRequestContext,
20} from "../core/workspace";
21
22export 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
34export 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};
packages/clients/react/src/index.ts+7−0
@@ -21,6 +21,9 @@ export type { · 20 unmodified lines
21} from "@executor/platform-api";21} from "@executor/platform-api";
22 22
23export type {23export type {
24 Execution,
25 ExecutionEnvelope,
26 ExecutionStatus,
24 Source,27 Source,
25 SourceInspection,28 SourceInspection,
26 SourceInspectionDiscoverResult,29 SourceInspectionDiscoverResult,
@@ -63,6 +66,10 @@ export { · 36 unmodified lines
63 useSecrets,66 useSecrets,
64 useUpdateSecret,67 useUpdateSecret,
65} from "./hooks/secrets";68} from "./hooks/secrets";
69export {
70 useExecution,
71 useExecutions,
72} from "./hooks/executions";
66export {73export {
67 usePrefetchToolDetail,74 usePrefetchToolDetail,
68 useRemoveSource,75 useRemoveSource,
packages/platform/api/src/executions/api.ts+11−0
@@ -1,4 +1,5 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import * as Schema from "effect/Schema";
2import {3import {
3 CreateExecutionPayloadSchema,4 CreateExecutionPayloadSchema,
4 ResumeExecutionPayloadSchema,5 ResumeExecutionPayloadSchema,
@@ -8,6 +9,7 @@ export type { · 3 unmodified lines
8 ResumeExecutionPayload,9 ResumeExecutionPayload,
9} from "@executor/platform-sdk/contracts";10} from "@executor/platform-sdk/contracts";
10import {11import {
12 ExecutionSchema,
11 ExecutionIdSchema,13 ExecutionIdSchema,
12 ExecutionEnvelopeSchema,14 ExecutionEnvelopeSchema,
13 ScopeIdSchema as WorkspaceIdSchema,15 ScopeIdSchema as WorkspaceIdSchema,
@@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines
30const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);32const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);
31 33
32export class ExecutionsApi extends HttpApiGroup.make("executions")34export 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 .add(44 .add(
34 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`45 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`
35 .setPayload(CreateExecutionPayloadSchema)46 .setPayload(CreateExecutionPayloadSchema)
packages/platform/api/src/executions/http.ts+5−0
@@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines
9 "executions",9 "executions",
10 (handlers) =>10 (handlers) =>
11 handlers11 handlers
12 .handle("list", ({ path }) =>
13 resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe(
14 Effect.flatMap((executor) => executor.executions.list()),
15 ),
16 )
12 .handle("create", ({ path, payload }) =>17 .handle("create", ({ path, payload }) =>
13 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(18 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(
14 Effect.flatMap((executor) => executor.executions.create(payload)),19 Effect.flatMap((executor) => executor.executions.create(payload)),
packages/platform/sdk-file/src/executor-state-store.ts+9−0
@@ -409,6 +409,15 @@ export const createLocalExecutorStateStore = ( · 408 unmodified lines
409 }),409 }),
410 },410 },
411 executions: {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 getById: (executionId: Execution["id"]) =>421 getById: (executionId: Execution["id"]) =>
413 stateManager.read((state) => {422 stateManager.read((state) => {
414 const execution = state.executions.find(423 const execution = state.executions.find(
packages/platform/sdk/src/backend.ts+1−0
@@ -183,6 +183,7 @@ const toExecutionsBackend = ( · 182 unmodified lines
183 input: ExecutorExecutionRepository,183 input: ExecutorExecutionRepository,
184): RuntimeExecutionStorageServices => ({184): RuntimeExecutionStorageServices => ({
185 runs: {185 runs: {
186 listByScope: (scopeId) => toEffect(input.runs.listByScope(scopeId)),
186 getById: (executionId) => toOptionEffect(input.runs.getById(executionId)),187 getById: (executionId) => toOptionEffect(input.runs.getById(executionId)),
187 getByScopeAndId: (scopeId, executionId) =>188 getByScopeAndId: (scopeId, executionId) =>
188 toOptionEffect(input.runs.getByScopeAndId(scopeId, executionId)),189 toOptionEffect(input.runs.getByScopeAndId(scopeId, executionId)),
packages/platform/sdk/src/executor-effect.ts+3−0
@@ -66,6 +66,7 @@ import { · 65 unmodified lines
66import {66import {
67 createExecution,67 createExecution,
68 getExecution,68 getExecution,
69 listExecutions,
69 resumeExecution,70 resumeExecution,
70} from "./runtime/execution/service";71} from "./runtime/execution/service";
71import type {72import type {
@@ -153,6 +154,7 @@ export type ExecutorEffect = { · 81 unmodified lines
153 };154 };
154 };155 };
155 executions: {156 executions: {
157 list: () => ProvidedEffect<ReturnType<typeof listExecutions>>;
156 create: (158 create: (
157 payload: CreateExecutionPayload,159 payload: CreateExecutionPayload,
158 ) => ProvidedEffect<ReturnType<typeof createExecution>>;160 ) => ProvidedEffect<ReturnType<typeof createExecution>>;
@@ -241,6 +243,7 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 82 unmodified lines
241 },243 },
242 },244 },
243 executions: {245 executions: {
246 list: () => provide(listExecutions({ scopeId })),
244 create: (payload) =>247 create: (payload) =>
245 provide(248 provide(
246 createExecution({249 createExecution({
packages/platform/sdk/src/executor.ts+1−0
@@ -120,6 +120,7 @@ const toPromiseExecutor = < · 119 unmodified lines
120 },120 },
121 },121 },
122 executions: {122 executions: {
123 list: () => run(executor.executions.list()),
123 create: (payload: Parameters<typeof executor.executions.create>[0]) =>124 create: (payload: Parameters<typeof executor.executions.create>[0]) =>
124 run(executor.executions.create(payload)),125 run(executor.executions.create(payload)),
125 get: (executionId: Parameters<typeof executor.executions.get>[0]) =>126 get: (executionId: Parameters<typeof executor.executions.get>[0]) =>
packages/platform/sdk/src/runtime/execution/http.test.ts+41−0
@@ -132,4 +132,45 @@ describe("execution-http", () => { · 131 unmodified lines
132 }),132 }),
133 60_000,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});
packages/platform/sdk/src/runtime/execution/service.ts+8−0
@@ -58,6 +58,7 @@ import { · 57 unmodified lines
58 58
59const executionOps = {59const executionOps = {
60 create: operationErrors("executions.create"),60 create: operationErrors("executions.create"),
61 list: operationErrors("executions.list"),
61 get: operationErrors("executions.get"),62 get: operationErrors("executions.get"),
62 resume: operationErrors("executions.resume"),63 resume: operationErrors("executions.resume"),
63} as const;64} as const;
@@ -904,6 +905,13 @@ export const getExecution = (input: { · 840 unmodified lines
904 })905 })
905 );906 );
906 907
908export const listExecutions = (input: {
909 scopeId: ScopeId;
910}) =>
911 Effect.flatMap(ExecutorStateStore, (store) =>
912 executionOps.list.mapStorage(store.executions.listByScope(input.scopeId))
913 );
914
907export const submitExecutionInteractionResponse = (input: {915export const submitExecutionInteractionResponse = (input: {
908 executionId: ExecutionId;916 executionId: ExecutionId;
909 response: ElicitationResponse;917 response: ElicitationResponse;
packages/platform/sdk/src/runtime/executor-state-store.ts+3−0
@@ -35,6 +35,9 @@ export type ExecutorStateStoreShape = { · 34 unmodified lines
35 removeById: (id: SecretMaterial["id"]) => Effect.Effect<boolean, Error, never>;35 removeById: (id: SecretMaterial["id"]) => Effect.Effect<boolean, Error, never>;
36 };36 };
37 executions: {37 executions: {
38 listByScope: (
39 scopeId: Execution["scopeId"],
40 ) => Effect.Effect<readonly Execution[], Error, never>;
38 getById: (41 getById: (
39 executionId: Execution["id"],42 executionId: Execution["id"],
40 ) => Effect.Effect<import("effect/Option").Option<Execution>, Error, never>;43 ) => Effect.Effect<import("effect/Option").Option<Execution>, Error, never>;
plugins/execution-history/react/components.tsx+301−0
@@ -0,0 +1,301 @@
1import type {
2 Execution,
3 ExecutionEnvelope,
4} from "@executor/react";
5import {
6 useExecution,
7 useExecutions,
8} from "@executor/react";
9import {
10 Badge,
11 DocumentPanel,
12 EmptyState,
13 LoadableBlock,
14 useExecutorPluginNavigation,
15 useExecutorPluginRouteParams,
16} from "@executor/react/plugins";
17
18const 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
29const 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
46const summarizeCode = (code: string): string =>
47 code
48 .trim()
49 .replace(/\s+/g, " ")
50 .slice(0, 220);
51
52const 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
64const 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
80const 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
92const 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
133export 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
178const 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
266export 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}
plugins/execution-history/react/index.ts+29−0
@@ -0,0 +1,29 @@
1import {
2 defineExecutorFrontendPlugin,
3} from "@executor/react/plugins";
4
5import {
6 ExecutionHistoryDetailPage,
7 ExecutionHistoryPage,
8} from "./components";
9
10export 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});
plugins/execution-history/react/package.json+25−0
@@ -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}
plugins/execution-history/react/tsconfig.json+17−0
@@ -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}