Unified · Split
| @@ -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:*", |
| @@ -1,6 +1,9 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | Source, | 2 | Source, |
| 3 | } from "@executor/react"; | 3 | } from "@executor/react"; |
| 4 | import { | ||
| 5 | ExecutionHistoryReactPlugin, | ||
| 6 | } from "@executor/plugin-execution-history-react"; | ||
| 4 | import { | 7 | import { |
| 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 | ||
| 24 | const frontendPlugins = [ | 27 | const frontendPlugins = [ |
| 28 | ExecutionHistoryReactPlugin, | ||
| 25 | McpReactPlugin, | 29 | McpReactPlugin, |
| 26 | GraphqlReactPlugin, | 30 | GraphqlReactPlugin, |
| 27 | GoogleDiscoveryReactPlugin, | 31 | GoogleDiscoveryReactPlugin, |
| @@ -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> |
| @@ -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"], |
| @@ -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; |
| @@ -7,6 +7,8 @@ import type { · 6 unmodified lines | |||
| 7 | SecretListItem, | 7 | SecretListItem, |
| 8 | } from "@executor/platform-api"; | 8 | } from "@executor/platform-api"; |
| 9 | import type { | 9 | import 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 | |||
| 17 | import { getExecutorApiBaseUrl } from "./base-url"; | 19 | import { getExecutorApiBaseUrl } from "./base-url"; |
| 18 | import { getExecutorApiHttpClient } from "./http-client"; | 20 | import { getExecutorApiHttpClient } from "./http-client"; |
| 19 | import { | 21 | import { |
| 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 | ||
| 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 | export const sourcesAtom = (workspaceId: Source["scopeId"]) => | 75 | export 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; |
| @@ -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 | import type { ReactivityKeys } from "./types"; | 3 | import 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 | ||
| 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 | export const sourceReactivityKey = ( | 36 | export const sourceReactivityKey = ( |
| 24 | workspaceId: Source["scopeId"], | 37 | workspaceId: Source["scopeId"], |
| 25 | sourceId: Source["id"], | 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 | } from "@executor/platform-api"; | 21 | } from "@executor/platform-api"; |
| 22 | 22 | ||
| 23 | export type { | 23 | export 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"; |
| 69 | export { | ||
| 70 | useExecution, | ||
| 71 | useExecutions, | ||
| 72 | } from "./hooks/executions"; | ||
| 66 | export { | 73 | export { |
| 67 | usePrefetchToolDetail, | 74 | usePrefetchToolDetail, |
| 68 | useRemoveSource, | 75 | useRemoveSource, |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import * as Schema from "effect/Schema"; | ||
| 2 | import { | 3 | import { |
| 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"; |
| 10 | import { | 11 | import { |
| 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 | |||
| 30 | const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema); | 32 | const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema); |
| 31 | 33 | ||
| 32 | export class ExecutionsApi extends HttpApiGroup.make("executions") | 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 | .add( | 44 | .add( |
| 34 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions` | 45 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions` |
| 35 | .setPayload(CreateExecutionPayloadSchema) | 46 | .setPayload(CreateExecutionPayloadSchema) |
| @@ -9,6 +9,11 @@ export const ExecutorExecutionsLive = HttpApiBuilder.group( · 8 unmodified lines | |||
| 9 | "executions", | 9 | "executions", |
| 10 | (handlers) => | 10 | (handlers) => |
| 11 | handlers | 11 | 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)), |
| @@ -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( |
| @@ -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)), |
| @@ -66,6 +66,7 @@ import { · 65 unmodified lines | |||
| 66 | import { | 66 | import { |
| 67 | createExecution, | 67 | createExecution, |
| 68 | getExecution, | 68 | getExecution, |
| 69 | listExecutions, | ||
| 69 | resumeExecution, | 70 | resumeExecution, |
| 70 | } from "./runtime/execution/service"; | 71 | } from "./runtime/execution/service"; |
| 71 | import type { | 72 | import 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({ |
| @@ -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]) => |
| @@ -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 | }); |
| @@ -58,6 +58,7 @@ import { · 57 unmodified lines | |||
| 58 | 58 | ||
| 59 | const executionOps = { | 59 | const 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 | ||
| 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 | export const submitExecutionInteractionResponse = (input: { | 915 | export const submitExecutionInteractionResponse = (input: { |
| 908 | executionId: ExecutionId; | 916 | executionId: ExecutionId; |
| 909 | response: ElicitationResponse; | 917 | response: ElicitationResponse; |
| @@ -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>; |
| @@ -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 | } | ||