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
99 "start": "NODE_ENV=production bun src/server.ts"
1010 },
1111 "dependencies": {
12+ "@executor/plugin-execution-history-react": "workspace:*",
1213 "@executor/plugin-google-discovery-react": "workspace:*",
1314 "@executor/plugin-graphql-react": "workspace:*",
1415 "@executor/plugin-mcp-react": "workspace:*",
apps/web/src/plugins/index.ts+4−0
@@ -1,6 +1,9 @@
11 import type {
22 Source,
33 } from "@executor/react";
4+import {
5+ ExecutionHistoryReactPlugin,
6+} from "@executor/plugin-execution-history-react";
47 import {
58 GoogleDiscoveryReactPlugin,
69 getGoogleDiscoveryIconUrl,
@@ -22,6 +25,7 @@ import { · 15 unmodified lines
2225 } from "@executor/react/plugins";
2326
2427 const frontendPlugins = [
28+ ExecutionHistoryReactPlugin,
2529 McpReactPlugin,
2630 GraphqlReactPlugin,
2731 GoogleDiscoveryReactPlugin,
apps/web/src/plugins/pages.tsx+1−1
@@ -129,7 +129,7 @@ export function ExecutorPluginRoutePage(input: { · 128 unmodified lines
129129 navigation: input.navigation,
130130 }}
131131 >
132− <div className="h-full min-h-0">
132+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
133133 <PluginPage />
134134 </div>
135135 </ExecutorPluginRouteProvider>
bun.lock+16−0
@@ -54,6 +54,7 @@ · 53 unmodified lines
5454 "name": "@executor/web",
5555 "version": "0.0.0",
5656 "dependencies": {
57+ "@executor/plugin-execution-history-react": "workspace:*",
5758 "@executor/plugin-google-discovery-react": "workspace:*",
5859 "@executor/plugin-graphql-react": "workspace:*",
5960 "@executor/plugin-mcp-react": "workspace:*",
@@ -405,6 +406,19 @@ · 345 unmodified lines
405406 "bun-types": "catalog:",
406407 },
407408 },
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+ },
408422 "plugins/google-discovery/http": {
409423 "name": "@executor/plugin-google-discovery-http",
410424 "version": "0.0.0",
@@ -874,6 +888,8 @@ · 463 unmodified lines
874888
875889 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],
876890
891+ "@executor/plugin-execution-history-react": ["@executor/plugin-execution-history-react@workspace:plugins/execution-history/react"],
892+
877893 "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"],
878894
879895 "@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
326326 },
327327 executions: {
328328 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),
329335 getById: (executionId: Execution["id"]) => {
330336 const row = store.db.select().from(executions).where(eq(executions.id, executionId)).get();
331337 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
77 SecretListItem,
88 } from "@executor/platform-api";
99 import type {
10+ Execution,
11+ ExecutionEnvelope,
1012 Source,
1113 SourceInspection,
1214 SourceInspectionDiscoverResult,
@@ -17,6 +19,8 @@ import * as Effect from "effect/Effect"; · 4 unmodified lines
1719 import { getExecutorApiBaseUrl } from "./base-url";
1820 import { getExecutorApiHttpClient } from "./http-client";
1921 import {
22+ executionReactivityKey,
23+ executionsReactivityKey,
2024 instanceConfigReactivityKey,
2125 localInstallationReactivityKey,
2226 secretsReactivityKey,
@@ -46,6 +50,28 @@ export const secretsAtom = (baseUrl: string = getExecutorApiBaseUrl()) => · 23 unmodified lines
4650 timeToLive: "1 minute",
4751 });
4852
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+
4975 export const sourcesAtom = (workspaceId: Source["scopeId"]) =>
5076 getExecutorApiHttpClient().query("sources", "list", {
5177 path: {
@@ -142,6 +168,8 @@ export type ExecutorApiAtoms = { · 90 unmodified lines
142168 localInstallationAtom: typeof localInstallationAtom;
143169 instanceConfigAtom: typeof instanceConfigAtom;
144170 secretsAtom: typeof secretsAtom;
171+ executionsAtom: typeof executionsAtom;
172+ executionAtom: typeof executionAtom;
145173 sourcesAtom: typeof sourcesAtom;
146174 sourceAtom: typeof sourceAtom;
147175 sourceInspectionAtom: typeof sourceInspectionAtom;
@@ -153,6 +181,8 @@ export type ExecutorApiAtomValues = { · 5 unmodified lines
153181 localInstallation: LocalInstallation;
154182 instanceConfig: InstanceConfig;
155183 secrets: ReadonlyArray<SecretListItem>;
184+ executions: ReadonlyArray<Execution>;
185+ execution: ExecutionEnvelope;
156186 sources: ReadonlyArray<Source>;
157187 source: Source;
158188 sourceInspection: SourceInspection;
packages/clients/react/src/core/reactivity.ts+14−1
@@ -1,4 +1,4 @@
1−import type { Source } from "@executor/platform-sdk/schema";
1+import type { Execution, Source } from "@executor/platform-sdk/schema";
22
33 import type { ReactivityKeys } from "./types";
44
@@ -20,6 +20,19 @@ export const sourcesReactivityKey = ( · 15 unmodified lines
2020 sources: [workspaceId],
2121 });
2222
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+
2336 export const sourceReactivityKey = (
2437 workspaceId: Source["scopeId"],
2538 sourceId: Source["id"],
packages/clients/react/src/hooks/executions.ts+46−0
@@ -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+};
packages/clients/react/src/index.ts+7−0
@@ -21,6 +21,9 @@ export type { · 20 unmodified lines
2121 } from "@executor/platform-api";
2222
2323 export type {
24+ Execution,
25+ ExecutionEnvelope,
26+ ExecutionStatus,
2427 Source,
2528 SourceInspection,
2629 SourceInspectionDiscoverResult,
@@ -63,6 +66,10 @@ export { · 36 unmodified lines
6366 useSecrets,
6467 useUpdateSecret,
6568 } from "./hooks/secrets";
69+export {
70+ useExecution,
71+ useExecutions,
72+} from "./hooks/executions";
6673 export {
6774 usePrefetchToolDetail,
6875 useRemoveSource,
packages/platform/api/src/executions/api.ts+11−0
@@ -1,4 +1,5 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import * as Schema from "effect/Schema";
23 import {
34 CreateExecutionPayloadSchema,
45 ResumeExecutionPayloadSchema,
@@ -8,6 +9,7 @@ export type { · 3 unmodified lines
89 ResumeExecutionPayload,
910 } from "@executor/platform-sdk/contracts";
1011 import {
12+ ExecutionSchema,
1113 ExecutionIdSchema,
1214 ExecutionEnvelopeSchema,
1315 ScopeIdSchema as WorkspaceIdSchema,
@@ -30,6 +32,15 @@ const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); · 16 unmodified lines
3032 const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);
3133
3234 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+ )
3344 .add(
3445 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions`
3546 .setPayload(CreateExecutionPayloadSchema)
packages/platform/api/src/executions/http.ts+5−0
@@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines
99 "executions",
1010 (handlers) =>
1111 handlers
12+ .handle("list", ({ path }) =>
13+ resolveRequestedLocalWorkspace("executions.list", path.workspaceId).pipe(
14+ Effect.flatMap((executor) => executor.executions.list()),
15+ ),
16+ )
1217 .handle("create", ({ path, payload }) =>
1318 resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(
1419 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
409409 }),
410410 },
411411 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+
412421 getById: (executionId: Execution["id"]) =>
413422 stateManager.read((state) => {
414423 const execution = state.executions.find(
packages/platform/sdk/src/backend.ts+1−0
@@ -183,6 +183,7 @@ const toExecutionsBackend = ( · 182 unmodified lines
183183 input: ExecutorExecutionRepository,
184184 ): RuntimeExecutionStorageServices => ({
185185 runs: {
186+ listByScope: (scopeId) => toEffect(input.runs.listByScope(scopeId)),
186187 getById: (executionId) => toOptionEffect(input.runs.getById(executionId)),
187188 getByScopeAndId: (scopeId, executionId) =>
188189 toOptionEffect(input.runs.getByScopeAndId(scopeId, executionId)),
packages/platform/sdk/src/executor-effect.ts+3−0
@@ -66,6 +66,7 @@ import { · 65 unmodified lines
6666 import {
6767 createExecution,
6868 getExecution,
69+ listExecutions,
6970 resumeExecution,
7071 } from "./runtime/execution/service";
7172 import type {
@@ -153,6 +154,7 @@ export type ExecutorEffect = { · 81 unmodified lines
153154 };
154155 };
155156 executions: {
157+ list: () => ProvidedEffect<ReturnType<typeof listExecutions>>;
156158 create: (
157159 payload: CreateExecutionPayload,
158160 ) => ProvidedEffect<ReturnType<typeof createExecution>>;
@@ -241,6 +243,7 @@ const fromRuntime = (runtime: ExecutorRuntime): ExecutorEffect => { · 82 unmodified lines
241243 },
242244 },
243245 executions: {
246+ list: () => provide(listExecutions({ scopeId })),
244247 create: (payload) =>
245248 provide(
246249 createExecution({
packages/platform/sdk/src/executor.ts+1−0
@@ -120,6 +120,7 @@ const toPromiseExecutor = < · 119 unmodified lines
120120 },
121121 },
122122 executions: {
123+ list: () => run(executor.executions.list()),
123124 create: (payload: Parameters<typeof executor.executions.create>[0]) =>
124125 run(executor.executions.create(payload)),
125126 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
132132 }),
133133 60_000,
134134 );
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+ );
135176 });
packages/platform/sdk/src/runtime/execution/service.ts+8−0
@@ -58,6 +58,7 @@ import { · 57 unmodified lines
5858
5959 const executionOps = {
6060 create: operationErrors("executions.create"),
61+ list: operationErrors("executions.list"),
6162 get: operationErrors("executions.get"),
6263 resume: operationErrors("executions.resume"),
6364 } as const;
@@ -904,6 +905,13 @@ export const getExecution = (input: { · 840 unmodified lines
904905 })
905906 );
906907
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+
907915 export const submitExecutionInteractionResponse = (input: {
908916 executionId: ExecutionId;
909917 response: ElicitationResponse;
packages/platform/sdk/src/runtime/executor-state-store.ts+3−0
@@ -35,6 +35,9 @@ export type ExecutorStateStoreShape = { · 34 unmodified lines
3535 removeById: (id: SecretMaterial["id"]) => Effect.Effect<boolean, Error, never>;
3636 };
3737 executions: {
38+ listByScope: (
39+ scopeId: Execution["scopeId"],
40+ ) => Effect.Effect<readonly Execution[], Error, never>;
3841 getById: (
3942 executionId: Execution["id"],
4043 ) => Effect.Effect<import("effect/Option").Option<Execution>, Error, never>;
plugins/execution-history/react/components.tsx+301−0
@@ -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+}
plugins/execution-history/react/index.ts+29−0
@@ -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+});
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+}