Unified · Split
| @@ -1,35 +1,22 @@ | |||
| 1 | # OpenAssistant | 1 | # Prototype Monorepo |
| 2 | 2 | ||
| 3 | ## Install | 3 | This repository is now split into two nested monorepos: |
| 4 | 4 | ||
| 5 | ```bash | 5 | - `assistant/`: assistant-side code and integrations. |
| 6 | bun install | 6 | - `executor/`: executor infrastructure (sandbox runtime, approvals API, task history, web UI). |
| 7 | ``` | ||
| 8 | |||
| 9 | ## Local MVP (remote executor mode) | ||
| 10 | 7 | ||
| 11 | Start the executor (private code runner): | 8 | ## Focus Area |
| 12 | 9 | ||
| 13 | ```bash | 10 | The active prototype work is in `executor/`. |
| 14 | bun run --filter '@openassistant/executor' dev | ||
| 15 | ``` | ||
| 16 | |||
| 17 | Start the server (agent + approvals + tool broker): | ||
| 18 | |||
| 19 | ```bash | ||
| 20 | OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 \ | ||
| 21 | OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 \ | ||
| 22 | bun run --filter '@openassistant/server' dev | ||
| 23 | ``` | ||
| 24 | 11 | ||
| 25 | Or run all services together: | 12 | ## Quick Start |
| 26 | 13 | ||
| 27 | ```bash | 14 | ```bash |
| 28 | bun run dev | 15 | bun install --cwd executor |
| 16 | bun run --cwd executor dev | ||
| 29 | ``` | 17 | ``` |
| 30 | 18 | ||
| 31 | ## Notes | 19 | This starts: |
| 32 | 20 | ||
| 33 | - Untrusted generated code executes in the executor service. | 21 | - Executor API server (task execution, approvals, SQLite storage) |
| 34 | - Tool calls are proxied back to the server via `/internal/runs/:runId/invoke`. | 22 | - Executor web UI (pending approvals + task history) |
| 35 | - Approval flow and secret-backed tool execution stay on the server side. | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | # Assistant Monorepo | ||
| 2 | |||
| 3 | Assistant-side code lives here. | ||
| 4 | |||
| 5 | - `packages/agent-executor-adapter`: lightweight adapter for talking to the executor API. | ||
| 6 | - `legacy/openassistant`: previous prototype code moved out of the executor-focused workspace. | ||
File renamed without changes.
| @@ -0,0 +1,35 @@ | |||
| 1 | # OpenAssistant | ||
| 2 | |||
| 3 | ## Install | ||
| 4 | |||
| 5 | ```bash | ||
| 6 | bun install | ||
| 7 | ``` | ||
| 8 | |||
| 9 | ## Local MVP (remote executor mode) | ||
| 10 | |||
| 11 | Start the executor (private code runner): | ||
| 12 | |||
| 13 | ```bash | ||
| 14 | bun run --filter '@openassistant/executor' dev | ||
| 15 | ``` | ||
| 16 | |||
| 17 | Start the server (agent + approvals + tool broker): | ||
| 18 | |||
| 19 | ```bash | ||
| 20 | OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 \ | ||
| 21 | OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 \ | ||
| 22 | bun run --filter '@openassistant/server' dev | ||
| 23 | ``` | ||
| 24 | |||
| 25 | Or run all services together: | ||
| 26 | |||
| 27 | ```bash | ||
| 28 | bun run dev | ||
| 29 | ``` | ||
| 30 | |||
| 31 | ## Notes | ||
| 32 | |||
| 33 | - Untrusted generated code executes in the executor service. | ||
| 34 | - Tool calls are proxied back to the server via `/internal/runs/:runId/invoke`. | ||
| 35 | - Approval flow and secret-backed tool execution stay on the server side. | ||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
| @@ -0,0 +1,52 @@ | |||
| 1 | { | ||
| 2 | "name": "openassistant", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "workspaces": [ | ||
| 6 | "apps/*", | ||
| 7 | "packages/*" | ||
| 8 | ], | ||
| 9 | "scripts": { | ||
| 10 | "with-env": "bun run --env-file=.env --", | ||
| 11 | "with-env:prod": "bun run --env-file=.env.production --", | ||
| 12 | "prepare": "effect-language-service patch", | ||
| 13 | "typecheck": "bun run --filter '@openassistant/core' typecheck && bun run --filter '@openassistant/reacord' typecheck && bun run --filter '@openassistant/server' typecheck && bun run --filter '@openassistant/executor' typecheck", | ||
| 14 | "typecheck:tsc": "tsc --build", | ||
| 15 | "build": "bun run typecheck", | ||
| 16 | "test": "bun test packages/ apps/", | ||
| 17 | "test:watch": "bun test --watch packages/ apps/", | ||
| 18 | "dev": "bun run dev:server & bun run dev:executor & bun run dev:bot & wait", | ||
| 19 | "dev:server": "bun run --filter '@openassistant/server' dev", | ||
| 20 | "dev:executor": "bun run --filter '@openassistant/executor' dev", | ||
| 21 | "dev:bot": "bun run --filter '@openassistant/bot' dev" | ||
| 22 | }, | ||
| 23 | "devDependencies": { | ||
| 24 | "@effect/language-service": "latest", | ||
| 25 | "@effect/vitest": "^0.27.0", | ||
| 26 | "@types/bun": "latest", | ||
| 27 | "@typescript/native-preview": "7.0.0-dev.20260205.1", | ||
| 28 | "typescript": "latest", | ||
| 29 | "vitest": "^3.2.0" | ||
| 30 | }, | ||
| 31 | "overrides": { | ||
| 32 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 33 | "@effect/platform": "latest", | ||
| 34 | "@effect/platform-bun": "latest", | ||
| 35 | "@effect/rpc": "latest", | ||
| 36 | "@effect-atom/atom": "^0.4.11", | ||
| 37 | "@effect-atom/atom-react": "^0.4.4", | ||
| 38 | "@types/react": "^19.0.0", | ||
| 39 | "@types/react-reconciler": "^0.31.0", | ||
| 40 | "ai": "^6.0.73", | ||
| 41 | "discord.js": "^14.17.3", | ||
| 42 | "effect": "latest", | ||
| 43 | "react": "^19.0.0", | ||
| 44 | "react-reconciler": "^0.31.0", | ||
| 45 | "zod": "^4.3.6" | ||
| 46 | }, | ||
| 47 | "dependencies": { | ||
| 48 | "@apidevtools/swagger-parser": "^12.1.0", | ||
| 49 | "@modelcontextprotocol/sdk": "^1.26.0", | ||
| 50 | "yaml": "^2.8.2" | ||
| 51 | } | ||
| 52 | } | ||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "name": "assistant-monorepo", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "workspaces": [ | ||
| 6 | "packages/*" | ||
| 7 | ], | ||
| 8 | "scripts": { | ||
| 9 | "test": "bun test packages/" | ||
| 10 | }, | ||
| 11 | "devDependencies": { | ||
| 12 | "@types/bun": "latest", | ||
| 13 | "typescript": "latest" | ||
| 14 | } | ||
| 15 | } | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | { | ||
| 2 | "name": "@assistant/agent-executor-adapter", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 8 | "scripts": { | ||
| 9 | "test": "bun test src/" | ||
| 10 | }, | ||
| 11 | "devDependencies": { | ||
| 12 | "@types/bun": "latest" | ||
| 13 | } | ||
| 14 | } | ||
| @@ -0,0 +1,58 @@ | |||
| 1 | export interface RunCodeRequest { | ||
| 2 | code: string; | ||
| 3 | timeoutMs?: number; | ||
| 4 | runtimeId?: string; | ||
| 5 | } | ||
| 6 | |||
| 7 | export interface RunCodeResponse { | ||
| 8 | taskId: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export async function runCode( | ||
| 12 | executorBaseUrl: string, | ||
| 13 | request: RunCodeRequest, | ||
| 14 | ): Promise<RunCodeResponse> { | ||
| 15 | const response = await fetch(`${executorBaseUrl.replace(/\/$/, "")}/api/tasks`, { | ||
| 16 | method: "POST", | ||
| 17 | headers: { "content-type": "application/json" }, | ||
| 18 | body: JSON.stringify(request), | ||
| 19 | }); | ||
| 20 | |||
| 21 | if (!response.ok) { | ||
| 22 | throw new Error(`Failed to create executor task: HTTP ${response.status}`); | ||
| 23 | } | ||
| 24 | |||
| 25 | const payload = (await response.json()) as { taskId: string }; | ||
| 26 | return { taskId: payload.taskId }; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function subscribeToTaskEvents( | ||
| 30 | executorBaseUrl: string, | ||
| 31 | taskId: string, | ||
| 32 | onMessage: (eventType: string, data: unknown) => void, | ||
| 33 | ): EventSource { | ||
| 34 | const streamUrl = `${executorBaseUrl.replace(/\/$/, "")}/api/tasks/${encodeURIComponent(taskId)}/events`; | ||
| 35 | const source = new EventSource(streamUrl); | ||
| 36 | |||
| 37 | source.onmessage = (event) => { | ||
| 38 | let data: unknown = event.data; | ||
| 39 | try { | ||
| 40 | data = JSON.parse(event.data); | ||
| 41 | } catch { | ||
| 42 | // Keep raw message if JSON parse fails. | ||
| 43 | } | ||
| 44 | onMessage("message", data); | ||
| 45 | }; | ||
| 46 | |||
| 47 | source.addEventListener("task", (event) => { | ||
| 48 | const message = event as MessageEvent<string>; | ||
| 49 | onMessage("task", JSON.parse(message.data)); | ||
| 50 | }); | ||
| 51 | |||
| 52 | source.addEventListener("approval", (event) => { | ||
| 53 | const message = event as MessageEvent<string>; | ||
| 54 | onMessage("approval", JSON.parse(message.data)); | ||
| 55 | }); | ||
| 56 | |||
| 57 | return source; | ||
| 58 | } | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | node_modules | ||
| 2 | **/node_modules | ||
| 3 | *.sqlite | ||
| 4 | *.sqlite-shm | ||
| 5 | *.sqlite-wal | ||
| 6 | *.db | ||
| 7 | *.db-shm | ||
| 8 | *.db-wal | ||
| 9 | .vercel | ||
| 10 | .env*.local | ||
| @@ -0,0 +1,63 @@ | |||
| 1 | # Executor Monorepo | ||
| 2 | |||
| 3 | Prototype executor control plane for running AI-generated code with tool-call approval gates. | ||
| 4 | |||
| 5 | ## What Is Here | ||
| 6 | |||
| 7 | - `apps/server`: Bun server with: | ||
| 8 | - task execution API | ||
| 9 | - per-tool-call approval API | ||
| 10 | - task event stream (SSE) | ||
| 11 | - SQLite-backed task and approval history | ||
| 12 | - pluggable sandbox runtime interface | ||
| 13 | - `apps/web`: web interface for: | ||
| 14 | - pending approvals | ||
| 15 | - task history | ||
| 16 | - task details/logs | ||
| 17 | - `packages/contracts`: shared API/types | ||
| 18 | - `packages/client`: lightweight client SDK for other consumers (including assistant-side integrations) | ||
| 19 | |||
| 20 | ## Architecture Notes | ||
| 21 | |||
| 22 | - The runtime executes generated code and exposes a `tools.*` proxy to that code. | ||
| 23 | - The generated-code runtime uses an `ExecutionAdapter` boundary for tool calls and output streaming. | ||
| 24 | - Current default uses an in-process adapter; an HTTP adapter is also included for process/network boundaries. | ||
| 25 | - Tools can be marked `auto` or `required` approval. | ||
| 26 | - Required tools create approval records per function call (`toolPath` + `input`). | ||
| 27 | - Task execution pauses on required tool calls until that specific call is approved or denied. | ||
| 28 | - Runtime targets are swappable by ID (`runtimeId`) so sandbox backends can change later. | ||
| 29 | - SQLite is used as a prototype event/history store. | ||
| 30 | |||
| 31 | ## Vercel Sandbox Runtime | ||
| 32 | |||
| 33 | This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow. | ||
| 34 | |||
| 35 | Local setup: | ||
| 36 | |||
| 37 | ```bash | ||
| 38 | vercel project add <project-name> | ||
| 39 | vercel link --yes --project <project-name> | ||
| 40 | vercel env pull .env.local --yes | ||
| 41 | ``` | ||
| 42 | |||
| 43 | Required runtime env: | ||
| 44 | |||
| 45 | - `VERCEL_OIDC_TOKEN` (pulled by `vercel env pull`) | ||
| 46 | - `EXECUTOR_INTERNAL_BASE_URL` (optional override for callback URL; if unset, server can auto-bootstrap a Tailscale Funnel URL in dev) | ||
| 47 | |||
| 48 | Optional: | ||
| 49 | |||
| 50 | - `EXECUTOR_INTERNAL_TOKEN` (shared bearer token for internal callback routes; auto-generated if unset) | ||
| 51 | - `EXECUTOR_VERCEL_SANDBOX_RUNTIME` (`node22` by default, supports `node24`) | ||
| 52 | - `EXECUTOR_AUTO_TAILSCALE_FUNNEL` (`1` by default; set `0` to disable automatic `tailscale funnel --bg` bootstrap) | ||
| 53 | |||
| 54 | When creating tasks, set `runtimeId` to `vercel-sandbox` to use this backend. | ||
| 55 | |||
| 56 | ## Run | ||
| 57 | |||
| 58 | ```bash | ||
| 59 | bun install | ||
| 60 | bun run dev | ||
| 61 | ``` | ||
| 62 | |||
| 63 | Server defaults to `http://localhost:4001`. | ||
| @@ -0,0 +1,18 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/server", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "dependencies": { | ||
| 6 | "@vercel/sandbox": "latest" | ||
| 7 | }, | ||
| 8 | "scripts": { | ||
| 9 | "dev": "bun --hot src/index.ts", | ||
| 10 | "start": "bun src/index.ts", | ||
| 11 | "test": "bun test", | ||
| 12 | "typecheck": "bunx tsc --noEmit" | ||
| 13 | }, | ||
| 14 | "devDependencies": { | ||
| 15 | "@types/bun": "latest", | ||
| 16 | "typescript": "latest" | ||
| 17 | } | ||
| 18 | } | ||
| @@ -0,0 +1,76 @@ | |||
| 1 | import type { | ||
| 2 | ExecutionAdapter, | ||
| 3 | RuntimeOutputEvent, | ||
| 4 | ToolCallRequest, | ||
| 5 | ToolCallResult, | ||
| 6 | } from "../types"; | ||
| 7 | |||
| 8 | interface HttpExecutionAdapterOptions { | ||
| 9 | controlPlaneBaseUrl: string; | ||
| 10 | runId: string; | ||
| 11 | token?: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | function withBase(baseUrl: string, path: string): string { | ||
| 15 | return `${baseUrl.replace(/\/$/, "")}${path}`; | ||
| 16 | } | ||
| 17 | |||
| 18 | export class HttpExecutionAdapter implements ExecutionAdapter { | ||
| 19 | constructor(private readonly options: HttpExecutionAdapterOptions) {} | ||
| 20 | |||
| 21 | private authHeaders(): Record<string, string> { | ||
| 22 | if (!this.options.token) { | ||
| 23 | return {}; | ||
| 24 | } | ||
| 25 | return { authorization: `Bearer ${this.options.token}` }; | ||
| 26 | } | ||
| 27 | |||
| 28 | async invokeTool(call: ToolCallRequest): Promise<ToolCallResult> { | ||
| 29 | if (call.runId !== this.options.runId) { | ||
| 30 | return { | ||
| 31 | ok: false, | ||
| 32 | error: `Run mismatch for call ${call.callId}`, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | const response = await fetch( | ||
| 37 | withBase(this.options.controlPlaneBaseUrl, `/internal/runs/${encodeURIComponent(call.runId)}/tool-call`), | ||
| 38 | { | ||
| 39 | method: "POST", | ||
| 40 | headers: { | ||
| 41 | "content-type": "application/json", | ||
| 42 | ...this.authHeaders(), | ||
| 43 | }, | ||
| 44 | body: JSON.stringify(call), | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | |||
| 48 | if (!response.ok) { | ||
| 49 | const text = await response.text(); | ||
| 50 | return { | ||
| 51 | ok: false, | ||
| 52 | error: `Tool call request failed (${response.status}): ${text}`, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | return (await response.json()) as ToolCallResult; | ||
| 57 | } | ||
| 58 | |||
| 59 | async emitOutput(event: RuntimeOutputEvent): Promise<void> { | ||
| 60 | if (event.runId !== this.options.runId) { | ||
| 61 | return; | ||
| 62 | } | ||
| 63 | |||
| 64 | await fetch( | ||
| 65 | withBase(this.options.controlPlaneBaseUrl, `/internal/runs/${encodeURIComponent(event.runId)}/output`), | ||
| 66 | { | ||
| 67 | method: "POST", | ||
| 68 | headers: { | ||
| 69 | "content-type": "application/json", | ||
| 70 | ...this.authHeaders(), | ||
| 71 | }, | ||
| 72 | body: JSON.stringify(event), | ||
| 73 | }, | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | } | ||
| @@ -0,0 +1,56 @@ | |||
| 1 | import { APPROVAL_DENIED_PREFIX } from "../execution-constants"; | ||
| 2 | import type { | ||
| 3 | ExecutionAdapter, | ||
| 4 | RuntimeOutputEvent, | ||
| 5 | ToolCallRequest, | ||
| 6 | ToolCallResult, | ||
| 7 | } from "../types"; | ||
| 8 | |||
| 9 | interface InProcessExecutionAdapterOptions { | ||
| 10 | runId: string; | ||
| 11 | invokeTool: (call: ToolCallRequest) => Promise<unknown>; | ||
| 12 | emitOutput: (event: RuntimeOutputEvent) => void; | ||
| 13 | } | ||
| 14 | |||
| 15 | function describeError(error: unknown): string { | ||
| 16 | return error instanceof Error ? error.message : String(error); | ||
| 17 | } | ||
| 18 | |||
| 19 | export class InProcessExecutionAdapter implements ExecutionAdapter { | ||
| 20 | constructor(private readonly options: InProcessExecutionAdapterOptions) {} | ||
| 21 | |||
| 22 | async invokeTool(call: ToolCallRequest): Promise<ToolCallResult> { | ||
| 23 | if (call.runId !== this.options.runId) { | ||
| 24 | return { | ||
| 25 | ok: false, | ||
| 26 | error: `Run mismatch for call ${call.callId}`, | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 30 | try { | ||
| 31 | const value = await this.options.invokeTool(call); | ||
| 32 | return { ok: true, value }; | ||
| 33 | } catch (error) { | ||
| 34 | const message = describeError(error); | ||
| 35 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | ||
| 36 | return { | ||
| 37 | ok: false, | ||
| 38 | denied: true, | ||
| 39 | error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | return { | ||
| 44 | ok: false, | ||
| 45 | error: message, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | emitOutput(event: RuntimeOutputEvent): void { | ||
| 51 | if (event.runId !== this.options.runId) { | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | this.options.emitOutput(event); | ||
| 55 | } | ||
| 56 | } | ||
| @@ -0,0 +1,370 @@ | |||
| 1 | import { Database } from "bun:sqlite"; | ||
| 2 | import type { | ||
| 3 | ApprovalRecord, | ||
| 4 | ApprovalStatus, | ||
| 5 | PendingApprovalRecord, | ||
| 6 | TaskEventRecord, | ||
| 7 | TaskRecord, | ||
| 8 | TaskStatus, | ||
| 9 | } from "./types"; | ||
| 10 | |||
| 11 | const DEFAULT_TIMEOUT_MS = 15_000; | ||
| 12 | |||
| 13 | function parseJson(value: unknown): unknown { | ||
| 14 | if (typeof value !== "string" || value.length === 0) { | ||
| 15 | return {}; | ||
| 16 | } | ||
| 17 | |||
| 18 | try { | ||
| 19 | return JSON.parse(value); | ||
| 20 | } catch { | ||
| 21 | return {}; | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | function parseMetadata(value: unknown): Record<string, unknown> { | ||
| 26 | const parsed = parseJson(value); | ||
| 27 | return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}; | ||
| 28 | } | ||
| 29 | |||
| 30 | function optionalNumber(value: unknown): number | undefined { | ||
| 31 | return typeof value === "number" ? value : undefined; | ||
| 32 | } | ||
| 33 | |||
| 34 | function optionalString(value: unknown): string | undefined { | ||
| 35 | return typeof value === "string" ? value : undefined; | ||
| 36 | } | ||
| 37 | |||
| 38 | function mapTaskRow(row: Record<string, unknown>): TaskRecord { | ||
| 39 | return { | ||
| 40 | id: String(row.id), | ||
| 41 | code: String(row.code), | ||
| 42 | runtimeId: String(row.runtime_id), | ||
| 43 | status: String(row.status) as TaskStatus, | ||
| 44 | timeoutMs: typeof row.timeout_ms === "number" ? row.timeout_ms : DEFAULT_TIMEOUT_MS, | ||
| 45 | metadata: parseMetadata(row.metadata), | ||
| 46 | createdAt: Number(row.created_at), | ||
| 47 | updatedAt: Number(row.updated_at), | ||
| 48 | startedAt: optionalNumber(row.started_at), | ||
| 49 | completedAt: optionalNumber(row.completed_at), | ||
| 50 | error: optionalString(row.error), | ||
| 51 | stdout: optionalString(row.stdout), | ||
| 52 | stderr: optionalString(row.stderr), | ||
| 53 | exitCode: optionalNumber(row.exit_code), | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | function mapApprovalRow(row: Record<string, unknown>): ApprovalRecord { | ||
| 58 | return { | ||
| 59 | id: String(row.id), | ||
| 60 | taskId: String(row.task_id), | ||
| 61 | toolPath: String(row.tool_path), | ||
| 62 | input: parseJson(row.input_json), | ||
| 63 | status: String(row.status) as ApprovalStatus, | ||
| 64 | reason: optionalString(row.reason), | ||
| 65 | reviewerId: optionalString(row.reviewer_id), | ||
| 66 | createdAt: Number(row.created_at), | ||
| 67 | resolvedAt: optionalNumber(row.resolved_at), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | function mapEventRow(row: Record<string, unknown>): TaskEventRecord { | ||
| 72 | const payload = parseJson(row.payload_json); | ||
| 73 | return { | ||
| 74 | id: Number(row.id), | ||
| 75 | taskId: String(row.task_id), | ||
| 76 | eventName: String(row.event_name), | ||
| 77 | type: String(row.event_type), | ||
| 78 | payload: payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}, | ||
| 79 | createdAt: Number(row.created_at), | ||
| 80 | }; | ||
| 81 | } | ||
| 82 | |||
| 83 | export class ExecutorDatabase { | ||
| 84 | private readonly db: Database; | ||
| 85 | |||
| 86 | constructor(dbPath = Bun.env.EXECUTOR_DB_PATH ?? "./executor-v2.sqlite") { | ||
| 87 | this.db = new Database(dbPath, { create: true }); | ||
| 88 | this.db.exec("PRAGMA journal_mode = WAL;"); | ||
| 89 | this.db.exec("PRAGMA foreign_keys = ON;"); | ||
| 90 | this.migrate(); | ||
| 91 | } | ||
| 92 | |||
| 93 | private migrate(): void { | ||
| 94 | this.db.exec(` | ||
| 95 | CREATE TABLE IF NOT EXISTS tasks ( | ||
| 96 | id TEXT PRIMARY KEY, | ||
| 97 | code TEXT NOT NULL, | ||
| 98 | runtime_id TEXT NOT NULL, | ||
| 99 | status TEXT NOT NULL, | ||
| 100 | timeout_ms INTEGER NOT NULL, | ||
| 101 | metadata TEXT NOT NULL, | ||
| 102 | error TEXT, | ||
| 103 | stdout TEXT, | ||
| 104 | stderr TEXT, | ||
| 105 | exit_code INTEGER, | ||
| 106 | created_at INTEGER NOT NULL, | ||
| 107 | updated_at INTEGER NOT NULL, | ||
| 108 | started_at INTEGER, | ||
| 109 | completed_at INTEGER | ||
| 110 | ); | ||
| 111 | |||
| 112 | CREATE TABLE IF NOT EXISTS approvals ( | ||
| 113 | id TEXT PRIMARY KEY, | ||
| 114 | task_id TEXT NOT NULL, | ||
| 115 | tool_path TEXT NOT NULL, | ||
| 116 | input_json TEXT NOT NULL, | ||
| 117 | status TEXT NOT NULL, | ||
| 118 | reason TEXT, | ||
| 119 | reviewer_id TEXT, | ||
| 120 | created_at INTEGER NOT NULL, | ||
| 121 | resolved_at INTEGER, | ||
| 122 | FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE | ||
| 123 | ); | ||
| 124 | |||
| 125 | CREATE TABLE IF NOT EXISTS task_events ( | ||
| 126 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 127 | task_id TEXT NOT NULL, | ||
| 128 | event_name TEXT NOT NULL, | ||
| 129 | event_type TEXT NOT NULL, | ||
| 130 | payload_json TEXT NOT NULL, | ||
| 131 | created_at INTEGER NOT NULL, | ||
| 132 | FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE | ||
| 133 | ); | ||
| 134 | |||
| 135 | CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC); | ||
| 136 | CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); | ||
| 137 | CREATE INDEX IF NOT EXISTS idx_approvals_status ON approvals(status); | ||
| 138 | CREATE INDEX IF NOT EXISTS idx_task_events_task_id ON task_events(task_id, id ASC); | ||
| 139 | `); | ||
| 140 | } | ||
| 141 | |||
| 142 | createTask(params: { | ||
| 143 | id: string; | ||
| 144 | code: string; | ||
| 145 | runtimeId: string; | ||
| 146 | timeoutMs?: number; | ||
| 147 | metadata?: Record<string, unknown>; | ||
| 148 | }): TaskRecord { | ||
| 149 | const now = Date.now(); | ||
| 150 | this.db.prepare(` | ||
| 151 | INSERT INTO tasks ( | ||
| 152 | id, | ||
| 153 | code, | ||
| 154 | runtime_id, | ||
| 155 | status, | ||
| 156 | timeout_ms, | ||
| 157 | metadata, | ||
| 158 | created_at, | ||
| 159 | updated_at | ||
| 160 | ) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?) | ||
| 161 | `).run( | ||
| 162 | params.id, | ||
| 163 | params.code, | ||
| 164 | params.runtimeId, | ||
| 165 | params.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 166 | JSON.stringify(params.metadata ?? {}), | ||
| 167 | now, | ||
| 168 | now, | ||
| 169 | ); | ||
| 170 | |||
| 171 | const task = this.getTask(params.id); | ||
| 172 | if (!task) { | ||
| 173 | throw new Error(`Failed to fetch created task ${params.id}`); | ||
| 174 | } | ||
| 175 | return task; | ||
| 176 | } | ||
| 177 | |||
| 178 | getTask(taskId: string): TaskRecord | null { | ||
| 179 | const row = this.db.query("SELECT * FROM tasks WHERE id = ?").get(taskId) as | ||
| 180 | | Record<string, unknown> | ||
| 181 | | null; | ||
| 182 | return row ? mapTaskRow(row) : null; | ||
| 183 | } | ||
| 184 | |||
| 185 | listTasks(): TaskRecord[] { | ||
| 186 | const rows = this.db | ||
| 187 | .query("SELECT * FROM tasks ORDER BY created_at DESC LIMIT 500") | ||
| 188 | .all() as Record<string, unknown>[]; | ||
| 189 | return rows.map(mapTaskRow); | ||
| 190 | } | ||
| 191 | |||
| 192 | markTaskRunning(taskId: string): TaskRecord | null { | ||
| 193 | const now = Date.now(); | ||
| 194 | this.db.prepare(` | ||
| 195 | UPDATE tasks | ||
| 196 | SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? | ||
| 197 | WHERE id = ? | ||
| 198 | `).run(now, now, taskId); | ||
| 199 | return this.getTask(taskId); | ||
| 200 | } | ||
| 201 | |||
| 202 | markTaskFinished(params: { | ||
| 203 | taskId: string; | ||
| 204 | status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | ||
| 205 | stdout: string; | ||
| 206 | stderr: string; | ||
| 207 | exitCode?: number; | ||
| 208 | error?: string; | ||
| 209 | }): TaskRecord | null { | ||
| 210 | const now = Date.now(); | ||
| 211 | this.db.prepare(` | ||
| 212 | UPDATE tasks | ||
| 213 | SET | ||
| 214 | status = ?, | ||
| 215 | stdout = ?, | ||
| 216 | stderr = ?, | ||
| 217 | exit_code = ?, | ||
| 218 | error = ?, | ||
| 219 | completed_at = ?, | ||
| 220 | updated_at = ? | ||
| 221 | WHERE id = ? | ||
| 222 | `).run( | ||
| 223 | params.status, | ||
| 224 | params.stdout, | ||
| 225 | params.stderr, | ||
| 226 | params.exitCode ?? null, | ||
| 227 | params.error ?? null, | ||
| 228 | now, | ||
| 229 | now, | ||
| 230 | params.taskId, | ||
| 231 | ); | ||
| 232 | return this.getTask(params.taskId); | ||
| 233 | } | ||
| 234 | |||
| 235 | createApproval(params: { | ||
| 236 | id: string; | ||
| 237 | taskId: string; | ||
| 238 | toolPath: string; | ||
| 239 | input: unknown; | ||
| 240 | }): ApprovalRecord { | ||
| 241 | const now = Date.now(); | ||
| 242 | this.db.prepare(` | ||
| 243 | INSERT INTO approvals ( | ||
| 244 | id, | ||
| 245 | task_id, | ||
| 246 | tool_path, | ||
| 247 | input_json, | ||
| 248 | status, | ||
| 249 | created_at | ||
| 250 | ) VALUES (?, ?, ?, ?, 'pending', ?) | ||
| 251 | `).run( | ||
| 252 | params.id, | ||
| 253 | params.taskId, | ||
| 254 | params.toolPath, | ||
| 255 | JSON.stringify(params.input ?? {}), | ||
| 256 | now, | ||
| 257 | ); | ||
| 258 | |||
| 259 | const approval = this.getApproval(params.id); | ||
| 260 | if (!approval) { | ||
| 261 | throw new Error(`Failed to fetch approval ${params.id}`); | ||
| 262 | } | ||
| 263 | return approval; | ||
| 264 | } | ||
| 265 | |||
| 266 | getApproval(approvalId: string): ApprovalRecord | null { | ||
| 267 | const row = this.db.query("SELECT * FROM approvals WHERE id = ?").get(approvalId) as | ||
| 268 | | Record<string, unknown> | ||
| 269 | | null; | ||
| 270 | return row ? mapApprovalRow(row) : null; | ||
| 271 | } | ||
| 272 | |||
| 273 | listApprovals(status?: ApprovalStatus): ApprovalRecord[] { | ||
| 274 | const rows = status | ||
| 275 | ? this.db.query("SELECT * FROM approvals WHERE status = ? ORDER BY created_at DESC").all(status) as Record<string, unknown>[] | ||
| 276 | : this.db.query("SELECT * FROM approvals ORDER BY created_at DESC LIMIT 500").all() as Record<string, unknown>[]; | ||
| 277 | return rows.map(mapApprovalRow); | ||
| 278 | } | ||
| 279 | |||
| 280 | listPendingApprovals(): PendingApprovalRecord[] { | ||
| 281 | const rows = this.db.prepare(` | ||
| 282 | SELECT | ||
| 283 | a.*, | ||
| 284 | t.status AS task_status, | ||
| 285 | t.runtime_id, | ||
| 286 | t.timeout_ms, | ||
| 287 | t.created_at AS task_created_at | ||
| 288 | FROM approvals a | ||
| 289 | JOIN tasks t ON t.id = a.task_id | ||
| 290 | WHERE a.status = 'pending' | ||
| 291 | ORDER BY a.created_at ASC | ||
| 292 | `).all() as Array<Record<string, unknown>>; | ||
| 293 | |||
| 294 | return rows.map((row) => ({ | ||
| 295 | ...mapApprovalRow(row), | ||
| 296 | task: { | ||
| 297 | id: String(row.task_id), | ||
| 298 | status: String(row.task_status) as TaskStatus, | ||
| 299 | runtimeId: String(row.runtime_id), | ||
| 300 | timeoutMs: Number(row.timeout_ms), | ||
| 301 | createdAt: Number(row.task_created_at), | ||
| 302 | }, | ||
| 303 | })); | ||
| 304 | } | ||
| 305 | |||
| 306 | resolveApproval(params: { | ||
| 307 | approvalId: string; | ||
| 308 | decision: Exclude<ApprovalStatus, "pending">; | ||
| 309 | reviewerId?: string; | ||
| 310 | reason?: string; | ||
| 311 | }): ApprovalRecord | null { | ||
| 312 | const pending = this.db.query( | ||
| 313 | "SELECT * FROM approvals WHERE id = ? AND status = 'pending'", | ||
| 314 | ).get(params.approvalId) as Record<string, unknown> | null; | ||
| 315 | |||
| 316 | if (!pending) { | ||
| 317 | return null; | ||
| 318 | } | ||
| 319 | |||
| 320 | const now = Date.now(); | ||
| 321 | this.db.prepare(` | ||
| 322 | UPDATE approvals | ||
| 323 | SET status = ?, reason = ?, reviewer_id = ?, resolved_at = ? | ||
| 324 | WHERE id = ? | ||
| 325 | `).run( | ||
| 326 | params.decision, | ||
| 327 | params.reason ?? null, | ||
| 328 | params.reviewerId ?? null, | ||
| 329 | now, | ||
| 330 | params.approvalId, | ||
| 331 | ); | ||
| 332 | |||
| 333 | return this.getApproval(params.approvalId); | ||
| 334 | } | ||
| 335 | |||
| 336 | createTaskEvent(input: { | ||
| 337 | taskId: string; | ||
| 338 | eventName: TaskEventRecord["eventName"]; | ||
| 339 | type: string; | ||
| 340 | payload: Record<string, unknown>; | ||
| 341 | }): TaskEventRecord { | ||
| 342 | const createdAt = Date.now(); | ||
| 343 | this.db.prepare(` | ||
| 344 | INSERT INTO task_events (task_id, event_name, event_type, payload_json, created_at) | ||
| 345 | VALUES (?, ?, ?, ?, ?) | ||
| 346 | `).run( | ||
| 347 | input.taskId, | ||
| 348 | input.eventName, | ||
| 349 | input.type, | ||
| 350 | JSON.stringify(input.payload), | ||
| 351 | createdAt, | ||
| 352 | ); | ||
| 353 | |||
| 354 | const row = this.db.query("SELECT * FROM task_events WHERE id = last_insert_rowid()").get() as | ||
| 355 | | Record<string, unknown> | ||
| 356 | | undefined; | ||
| 357 | if (!row) { | ||
| 358 | throw new Error("Failed to read inserted task event"); | ||
| 359 | } | ||
| 360 | |||
| 361 | return mapEventRow(row); | ||
| 362 | } | ||
| 363 | |||
| 364 | listTaskEvents(taskId: string): TaskEventRecord[] { | ||
| 365 | const rows = this.db | ||
| 366 | .query("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC") | ||
| 367 | .all(taskId) as Record<string, unknown>[]; | ||
| 368 | return rows.map(mapEventRow); | ||
| 369 | } | ||
| 370 | } | ||
| @@ -0,0 +1,49 @@ | |||
| 1 | export interface LiveTaskEvent { | ||
| 2 | id: number; | ||
| 3 | eventName: string; | ||
| 4 | payload: unknown; | ||
| 5 | createdAt: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | type Subscriber = (event: LiveTaskEvent) => void; | ||
| 9 | |||
| 10 | export class TaskEventHub { | ||
| 11 | private readonly subscribers = new Map<string, Set<Subscriber>>(); | ||
| 12 | |||
| 13 | publish(taskId: string, event: LiveTaskEvent): void { | ||
| 14 | const listeners = this.subscribers.get(taskId); | ||
| 15 | if (!listeners || listeners.size === 0) { | ||
| 16 | return; | ||
| 17 | } | ||
| 18 | |||
| 19 | for (const listener of listeners) { | ||
| 20 | try { | ||
| 21 | listener(event); | ||
| 22 | } catch { | ||
| 23 | listeners.delete(listener); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | if (listeners.size === 0) { | ||
| 28 | this.subscribers.delete(taskId); | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | subscribe(taskId: string, listener: Subscriber): () => void { | ||
| 33 | const listeners = this.subscribers.get(taskId) ?? new Set<Subscriber>(); | ||
| 34 | listeners.add(listener); | ||
| 35 | this.subscribers.set(taskId, listeners); | ||
| 36 | |||
| 37 | return () => { | ||
| 38 | const active = this.subscribers.get(taskId); | ||
| 39 | if (!active) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | active.delete(listener); | ||
| 44 | if (active.size === 0) { | ||
| 45 | this.subscribers.delete(taskId); | ||
| 46 | } | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | } | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | export const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; | ||
| 2 | export const TASK_TIMEOUT_MARKER = "TASK_TIMEOUT"; | ||
| @@ -0,0 +1,315 @@ | |||
| 1 | import webApp from "../../web/index.html"; | ||
| 2 | import { ExecutorDatabase } from "./database"; | ||
| 3 | import { TaskEventHub } from "./events"; | ||
| 4 | import { LocalBunRuntime } from "./runtimes/local-bun-runtime"; | ||
| 5 | import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime"; | ||
| 6 | import { ExecutorService, getTaskTerminalState } from "./service"; | ||
| 7 | import { ensureTailscaleFunnel } from "./tailscale-funnel"; | ||
| 8 | import { DEFAULT_TOOLS } from "./tools"; | ||
| 9 | import type { | ||
| 10 | ApprovalStatus, | ||
| 11 | CreateTaskInput, | ||
| 12 | PendingApprovalRecord, | ||
| 13 | TaskStatus, | ||
| 14 | ToolCallRequest, | ||
| 15 | RuntimeOutputEvent, | ||
| 16 | } from "./types"; | ||
| 17 | |||
| 18 | const port = Number(Bun.env.PORT ?? "4001"); | ||
| 19 | const autoFunnelEnabled = Bun.env.EXECUTOR_AUTO_TAILSCALE_FUNNEL !== "0"; | ||
| 20 | const explicitInternalBaseUrl = Bun.env.EXECUTOR_INTERNAL_BASE_URL ?? Bun.env.EXECUTOR_PUBLIC_BASE_URL; | ||
| 21 | const generatedInternalToken = Bun.env.EXECUTOR_INTERNAL_TOKEN ?? `executor_internal_${crypto.randomUUID()}`; | ||
| 22 | const internalToken = generatedInternalToken; | ||
| 23 | |||
| 24 | let internalBaseUrl = explicitInternalBaseUrl ?? `http://127.0.0.1:${port}`; | ||
| 25 | let internalBaseSource = explicitInternalBaseUrl ? "env" : "localhost-default"; | ||
| 26 | |||
| 27 | if (!explicitInternalBaseUrl && autoFunnelEnabled) { | ||
| 28 | try { | ||
| 29 | const funnel = ensureTailscaleFunnel(port); | ||
| 30 | internalBaseUrl = funnel.url; | ||
| 31 | internalBaseSource = funnel.created ? "tailscale-funnel-created" : "tailscale-funnel-existing"; | ||
| 32 | } catch (error) { | ||
| 33 | const message = error instanceof Error ? error.message : String(error); | ||
| 34 | console.warn(`[executor] tailscale funnel unavailable, using localhost callbacks: ${message}`); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | const service = new ExecutorService(new ExecutorDatabase(), new TaskEventHub(), [ | ||
| 39 | new LocalBunRuntime(), | ||
| 40 | new VercelSandboxRuntime({ | ||
| 41 | controlPlaneBaseUrl: internalBaseUrl, | ||
| 42 | internalToken, | ||
| 43 | runtime: Bun.env.EXECUTOR_VERCEL_SANDBOX_RUNTIME as "node24" | "node22" | undefined, | ||
| 44 | }), | ||
| 45 | ], DEFAULT_TOOLS); | ||
| 46 | |||
| 47 | const jsonHeaders = { | ||
| 48 | "content-type": "application/json; charset=utf-8", | ||
| 49 | "access-control-allow-origin": "*", | ||
| 50 | "access-control-allow-methods": "GET,POST,OPTIONS", | ||
| 51 | "access-control-allow-headers": "content-type", | ||
| 52 | }; | ||
| 53 | |||
| 54 | function json(data: unknown, status = 200): Response { | ||
| 55 | return new Response(JSON.stringify(data), { | ||
| 56 | status, | ||
| 57 | headers: jsonHeaders, | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | |||
| 61 | function error(status: number, message: string): Response { | ||
| 62 | return json({ error: message }, status); | ||
| 63 | } | ||
| 64 | |||
| 65 | function isInternalAuthorized(request: Request): boolean { | ||
| 66 | if (!internalToken) { | ||
| 67 | return true; | ||
| 68 | } | ||
| 69 | |||
| 70 | const header = request.headers.get("authorization"); | ||
| 71 | if (!header || !header.startsWith("Bearer ")) { | ||
| 72 | return false; | ||
| 73 | } | ||
| 74 | |||
| 75 | return header.slice("Bearer ".length) === internalToken; | ||
| 76 | } | ||
| 77 | |||
| 78 | async function parseBody<T>(request: Request): Promise<T | null> { | ||
| 79 | try { | ||
| 80 | return (await request.json()) as T; | ||
| 81 | } catch { | ||
| 82 | return null; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | function createTaskEventsResponse(taskId: string): Response { | ||
| 87 | const task = service.getTask(taskId); | ||
| 88 | if (!task) { | ||
| 89 | return error(404, "Task not found"); | ||
| 90 | } | ||
| 91 | |||
| 92 | const encoder = new TextEncoder(); | ||
| 93 | let unsubscribe: (() => void) | undefined; | ||
| 94 | let keepalive: ReturnType<typeof setInterval> | undefined; | ||
| 95 | |||
| 96 | const stream = new ReadableStream<Uint8Array>({ | ||
| 97 | start(controller) { | ||
| 98 | const replay = service.listTaskEvents(taskId); | ||
| 99 | for (const event of replay) { | ||
| 100 | const frame = `event: ${event.eventName}\ndata: ${JSON.stringify(event)}\n\n`; | ||
| 101 | controller.enqueue(encoder.encode(frame)); | ||
| 102 | } | ||
| 103 | |||
| 104 | if (getTaskTerminalState(task.status)) { | ||
| 105 | controller.close(); | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | unsubscribe = service.subscribe(taskId, (event) => { | ||
| 110 | try { | ||
| 111 | const frame = `event: ${event.eventName}\ndata: ${JSON.stringify(event)}\n\n`; | ||
| 112 | controller.enqueue(encoder.encode(frame)); | ||
| 113 | |||
| 114 | if ( | ||
| 115 | event.eventName === "task" && | ||
| 116 | typeof event.payload === "object" && | ||
| 117 | event.payload !== null && | ||
| 118 | "status" in event.payload | ||
| 119 | ) { | ||
| 120 | const status = String((event.payload as { status?: unknown }).status ?? "") as TaskStatus; | ||
| 121 | if (getTaskTerminalState(status)) { | ||
| 122 | if (keepalive) clearInterval(keepalive); | ||
| 123 | if (unsubscribe) unsubscribe(); | ||
| 124 | controller.close(); | ||
| 125 | } | ||
| 126 | } | ||
| 127 | } catch { | ||
| 128 | if (keepalive) clearInterval(keepalive); | ||
| 129 | if (unsubscribe) unsubscribe(); | ||
| 130 | controller.close(); | ||
| 131 | } | ||
| 132 | }); | ||
| 133 | |||
| 134 | keepalive = setInterval(() => { | ||
| 135 | controller.enqueue(encoder.encode(": keepalive\n\n")); | ||
| 136 | }, 15_000); | ||
| 137 | }, | ||
| 138 | cancel() { | ||
| 139 | if (keepalive) clearInterval(keepalive); | ||
| 140 | if (unsubscribe) unsubscribe(); | ||
| 141 | }, | ||
| 142 | }); | ||
| 143 | |||
| 144 | return new Response(stream, { | ||
| 145 | status: 200, | ||
| 146 | headers: { | ||
| 147 | "content-type": "text/event-stream; charset=utf-8", | ||
| 148 | "cache-control": "no-cache", | ||
| 149 | connection: "keep-alive", | ||
| 150 | "access-control-allow-origin": "*", | ||
| 151 | }, | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 155 | const server = Bun.serve({ | ||
| 156 | port, | ||
| 157 | routes: { | ||
| 158 | "/": webApp, | ||
| 159 | "/api/health": { | ||
| 160 | GET: () => json({ ok: true, tools: service.listTools().length }), | ||
| 161 | }, | ||
| 162 | "/api/runtime-targets": { | ||
| 163 | GET: () => json(service.listRuntimes()), | ||
| 164 | }, | ||
| 165 | "/api/tools": { | ||
| 166 | GET: () => json(service.listTools()), | ||
| 167 | }, | ||
| 168 | "/api/tasks": { | ||
| 169 | GET: () => json(service.listTasks()), | ||
| 170 | POST: async (request) => { | ||
| 171 | const body = await parseBody<CreateTaskInput>(request); | ||
| 172 | if (!body) { | ||
| 173 | return error(400, "Invalid JSON body"); | ||
| 174 | } | ||
| 175 | |||
| 176 | try { | ||
| 177 | const created = service.createTask(body); | ||
| 178 | return json({ taskId: created.task.id, status: created.task.status }, 201); | ||
| 179 | } catch (cause) { | ||
| 180 | return error(400, cause instanceof Error ? cause.message : String(cause)); | ||
| 181 | } | ||
| 182 | }, | ||
| 183 | }, | ||
| 184 | "/api/tasks/:taskId": { | ||
| 185 | GET: (request) => { | ||
| 186 | const task = service.getTask(request.params.taskId); | ||
| 187 | if (!task) { | ||
| 188 | return error(404, "Task not found"); | ||
| 189 | } | ||
| 190 | return json(task); | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | "/api/tasks/:taskId/events": { | ||
| 194 | GET: (request) => createTaskEventsResponse(request.params.taskId), | ||
| 195 | }, | ||
| 196 | "/api/approvals": { | ||
| 197 | GET: (request) => { | ||
| 198 | const query = new URL(request.url).searchParams; | ||
| 199 | const status = query.get("status") as ApprovalStatus | null; | ||
| 200 | |||
| 201 | if (status === "pending") { | ||
| 202 | return json(service.listPendingApprovals() satisfies PendingApprovalRecord[]); | ||
| 203 | } | ||
| 204 | |||
| 205 | if (status && status !== "approved" && status !== "denied") { | ||
| 206 | return error(400, "Invalid approval status"); | ||
| 207 | } | ||
| 208 | |||
| 209 | return json(service.listApprovals(status ?? undefined)); | ||
| 210 | }, | ||
| 211 | }, | ||
| 212 | "/api/approvals/:approvalId": { | ||
| 213 | POST: async (request) => { | ||
| 214 | const body = await parseBody<{ | ||
| 215 | decision?: "approved" | "denied"; | ||
| 216 | reviewerId?: string; | ||
| 217 | reason?: string; | ||
| 218 | }>(request); | ||
| 219 | |||
| 220 | if (!body || (body.decision !== "approved" && body.decision !== "denied")) { | ||
| 221 | return error(400, "decision must be 'approved' or 'denied'"); | ||
| 222 | } | ||
| 223 | |||
| 224 | const resolved = service.resolveApproval( | ||
| 225 | request.params.approvalId, | ||
| 226 | body.decision, | ||
| 227 | body.reviewerId, | ||
| 228 | body.reason, | ||
| 229 | ); | ||
| 230 | |||
| 231 | if (!resolved) { | ||
| 232 | return error(404, "Approval not found or already resolved"); | ||
| 233 | } | ||
| 234 | |||
| 235 | return json(resolved); | ||
| 236 | }, | ||
| 237 | }, | ||
| 238 | "/internal/runs/:runId/tool-call": { | ||
| 239 | POST: async (request) => { | ||
| 240 | if (!isInternalAuthorized(request)) { | ||
| 241 | return error(401, "Unauthorized internal call"); | ||
| 242 | } | ||
| 243 | |||
| 244 | const body = await parseBody<{ | ||
| 245 | callId?: string; | ||
| 246 | toolPath?: string; | ||
| 247 | input?: unknown; | ||
| 248 | }>(request); | ||
| 249 | |||
| 250 | if (!body || !body.callId || !body.toolPath) { | ||
| 251 | return error(400, "callId and toolPath are required"); | ||
| 252 | } | ||
| 253 | |||
| 254 | const call: ToolCallRequest = { | ||
| 255 | runId: request.params.runId, | ||
| 256 | callId: body.callId, | ||
| 257 | toolPath: body.toolPath, | ||
| 258 | input: body.input, | ||
| 259 | }; | ||
| 260 | |||
| 261 | return json(await service.handleExternalToolCall(call)); | ||
| 262 | }, | ||
| 263 | }, | ||
| 264 | "/internal/runs/:runId/output": { | ||
| 265 | POST: async (request) => { | ||
| 266 | if (!isInternalAuthorized(request)) { | ||
| 267 | return error(401, "Unauthorized internal call"); | ||
| 268 | } | ||
| 269 | |||
| 270 | const body = await parseBody<{ | ||
| 271 | stream?: "stdout" | "stderr"; | ||
| 272 | line?: string; | ||
| 273 | timestamp?: number; | ||
| 274 | }>(request); | ||
| 275 | |||
| 276 | if (!body || (body.stream !== "stdout" && body.stream !== "stderr") || typeof body.line !== "string") { | ||
| 277 | return error(400, "stream and line are required"); | ||
| 278 | } | ||
| 279 | |||
| 280 | const event: RuntimeOutputEvent = { | ||
| 281 | runId: request.params.runId, | ||
| 282 | stream: body.stream, | ||
| 283 | line: body.line, | ||
| 284 | timestamp: typeof body.timestamp === "number" ? body.timestamp : Date.now(), | ||
| 285 | }; | ||
| 286 | |||
| 287 | const task = service.getTask(event.runId); | ||
| 288 | if (!task) { | ||
| 289 | return error(404, `Run not found: ${event.runId}`); | ||
| 290 | } | ||
| 291 | |||
| 292 | service.appendRuntimeOutput(event); | ||
| 293 | return json({ ok: true }); | ||
| 294 | }, | ||
| 295 | }, | ||
| 296 | }, | ||
| 297 | fetch(request) { | ||
| 298 | if (request.method === "OPTIONS") { | ||
| 299 | return new Response(null, { status: 204, headers: jsonHeaders }); | ||
| 300 | } | ||
| 301 | return new Response("Not found", { status: 404 }); | ||
| 302 | }, | ||
| 303 | error(cause) { | ||
| 304 | console.error("executor server error", cause); | ||
| 305 | return error(500, "Internal server error"); | ||
| 306 | }, | ||
| 307 | development: { | ||
| 308 | hmr: true, | ||
| 309 | console: true, | ||
| 310 | }, | ||
| 311 | }); | ||
| 312 | |||
| 313 | console.log(`executor server listening on http://localhost:${server.port}`); | ||
| 314 | console.log(`[executor] internal callback base: ${internalBaseUrl} (${internalBaseSource})`); | ||
| 315 | console.log(`[executor] internal callback auth token enabled: yes`); | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | import { runCodeWithAdapter } from "./runtime-core"; | ||
| 2 | import type { | ||
| 3 | ExecutionAdapter, | ||
| 4 | SandboxExecutionRequest, | ||
| 5 | SandboxExecutionResult, | ||
| 6 | SandboxRuntime, | ||
| 7 | } from "../types"; | ||
| 8 | |||
| 9 | export class LocalBunRuntime implements SandboxRuntime { | ||
| 10 | readonly id = "local-bun"; | ||
| 11 | readonly label = "Local JS Runtime"; | ||
| 12 | readonly description = "Runs generated JavaScript in-process with pluggable execution adapter."; | ||
| 13 | |||
| 14 | async run( | ||
| 15 | request: SandboxExecutionRequest, | ||
| 16 | adapter: ExecutionAdapter, | ||
| 17 | ): Promise<SandboxExecutionResult> { | ||
| 18 | return await runCodeWithAdapter(request, adapter); | ||
| 19 | } | ||
| 20 | } | ||
| @@ -0,0 +1,178 @@ | |||
| 1 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants"; | ||
| 2 | import type { | ||
| 3 | ExecutionAdapter, | ||
| 4 | SandboxExecutionRequest, | ||
| 5 | SandboxExecutionResult, | ||
| 6 | } from "../types"; | ||
| 7 | |||
| 8 | function formatArgs(args: unknown[]): string { | ||
| 9 | return args | ||
| 10 | .map((value) => { | ||
| 11 | if (typeof value === "string") return value; | ||
| 12 | try { | ||
| 13 | return JSON.stringify(value); | ||
| 14 | } catch { | ||
| 15 | return String(value); | ||
| 16 | } | ||
| 17 | }) | ||
| 18 | .join(" "); | ||
| 19 | } | ||
| 20 | |||
| 21 | function fireAndForget(promise: void | Promise<void>): void { | ||
| 22 | if (promise && typeof promise === "object" && "then" in promise) { | ||
| 23 | void (promise as Promise<void>).catch(() => {}); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | function createToolsProxy( | ||
| 28 | adapter: ExecutionAdapter, | ||
| 29 | runId: string, | ||
| 30 | path: string[] = [], | ||
| 31 | ): unknown { | ||
| 32 | const callable = () => {}; | ||
| 33 | return new Proxy(callable, { | ||
| 34 | get(_target, prop) { | ||
| 35 | if (prop === "then") return undefined; | ||
| 36 | if (typeof prop !== "string") return undefined; | ||
| 37 | return createToolsProxy(adapter, runId, [...path, prop]); | ||
| 38 | }, | ||
| 39 | async apply(_target, _thisArg, args) { | ||
| 40 | const toolPath = path.join("."); | ||
| 41 | if (!toolPath) { | ||
| 42 | throw new Error("Tool path missing in invocation"); | ||
| 43 | } | ||
| 44 | |||
| 45 | const input = args.length > 0 ? args[0] : {}; | ||
| 46 | const result = await adapter.invokeTool({ | ||
| 47 | runId, | ||
| 48 | callId: `call_${crypto.randomUUID()}`, | ||
| 49 | toolPath, | ||
| 50 | input, | ||
| 51 | }); | ||
| 52 | |||
| 53 | if (result.ok) { | ||
| 54 | return result.value; | ||
| 55 | } | ||
| 56 | |||
| 57 | if (result.denied) { | ||
| 58 | throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); | ||
| 59 | } | ||
| 60 | |||
| 61 | throw new Error(result.error); | ||
| 62 | }, | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | export async function runCodeWithAdapter( | ||
| 67 | request: SandboxExecutionRequest, | ||
| 68 | adapter: ExecutionAdapter, | ||
| 69 | ): Promise<SandboxExecutionResult> { | ||
| 70 | const startedAt = Date.now(); | ||
| 71 | const stdoutLines: string[] = []; | ||
| 72 | const stderrLines: string[] = []; | ||
| 73 | const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new ( | ||
| 74 | ...args: string[] | ||
| 75 | ) => (...inputs: unknown[]) => Promise<unknown>; | ||
| 76 | |||
| 77 | const appendStdout = (line: string): void => { | ||
| 78 | stdoutLines.push(line); | ||
| 79 | fireAndForget( | ||
| 80 | adapter.emitOutput({ | ||
| 81 | runId: request.taskId, | ||
| 82 | stream: "stdout", | ||
| 83 | line, | ||
| 84 | timestamp: Date.now(), | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | }; | ||
| 88 | |||
| 89 | const appendStderr = (line: string): void => { | ||
| 90 | stderrLines.push(line); | ||
| 91 | fireAndForget( | ||
| 92 | adapter.emitOutput({ | ||
| 93 | runId: request.taskId, | ||
| 94 | stream: "stderr", | ||
| 95 | line, | ||
| 96 | timestamp: Date.now(), | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | }; | ||
| 100 | |||
| 101 | const tools = createToolsProxy(adapter, request.taskId); | ||
| 102 | const consoleProxy = { | ||
| 103 | log: (...args: unknown[]) => appendStdout(formatArgs(args)), | ||
| 104 | info: (...args: unknown[]) => appendStdout(formatArgs(args)), | ||
| 105 | warn: (...args: unknown[]) => appendStderr(formatArgs(args)), | ||
| 106 | error: (...args: unknown[]) => appendStderr(formatArgs(args)), | ||
| 107 | }; | ||
| 108 | |||
| 109 | const runner = new AsyncFunction( | ||
| 110 | "tools", | ||
| 111 | "console", | ||
| 112 | `"use strict";\n${request.code}`, | ||
| 113 | ); | ||
| 114 | |||
| 115 | let timeoutHandle: ReturnType<typeof setTimeout> | undefined; | ||
| 116 | const timeoutPromise = new Promise<never>((_resolve, reject) => { | ||
| 117 | timeoutHandle = setTimeout(() => { | ||
| 118 | reject(new Error(TASK_TIMEOUT_MARKER)); | ||
| 119 | }, request.timeoutMs); | ||
| 120 | }); | ||
| 121 | |||
| 122 | try { | ||
| 123 | const value = await Promise.race([ | ||
| 124 | runner(tools, consoleProxy), | ||
| 125 | timeoutPromise, | ||
| 126 | ]); | ||
| 127 | |||
| 128 | if (value !== undefined) { | ||
| 129 | appendStdout(`result: ${formatArgs([value])}`); | ||
| 130 | } | ||
| 131 | |||
| 132 | return { | ||
| 133 | status: "completed", | ||
| 134 | stdout: stdoutLines.join("\n"), | ||
| 135 | stderr: stderrLines.join("\n"), | ||
| 136 | exitCode: 0, | ||
| 137 | durationMs: Date.now() - startedAt, | ||
| 138 | }; | ||
| 139 | } catch (error) { | ||
| 140 | const message = error instanceof Error ? error.message : String(error); | ||
| 141 | if (message === TASK_TIMEOUT_MARKER) { | ||
| 142 | const timeoutMessage = `Execution timed out after ${request.timeoutMs}ms`; | ||
| 143 | appendStderr(timeoutMessage); | ||
| 144 | return { | ||
| 145 | status: "timed_out", | ||
| 146 | stdout: stdoutLines.join("\n"), | ||
| 147 | stderr: stderrLines.join("\n"), | ||
| 148 | error: timeoutMessage, | ||
| 149 | durationMs: Date.now() - startedAt, | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | ||
| 154 | const deniedMessage = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); | ||
| 155 | appendStderr(deniedMessage); | ||
| 156 | return { | ||
| 157 | status: "denied", | ||
| 158 | stdout: stdoutLines.join("\n"), | ||
| 159 | stderr: stderrLines.join("\n"), | ||
| 160 | error: deniedMessage, | ||
| 161 | durationMs: Date.now() - startedAt, | ||
| 162 | }; | ||
| 163 | } | ||
| 164 | |||
| 165 | appendStderr(message); | ||
| 166 | return { | ||
| 167 | status: "failed", | ||
| 168 | stdout: stdoutLines.join("\n"), | ||
| 169 | stderr: stderrLines.join("\n"), | ||
| 170 | error: message, | ||
| 171 | durationMs: Date.now() - startedAt, | ||
| 172 | }; | ||
| 173 | } finally { | ||
| 174 | if (timeoutHandle) { | ||
| 175 | clearTimeout(timeoutHandle); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | } | ||
| @@ -0,0 +1,312 @@ | |||
| 1 | import { Sandbox } from "@vercel/sandbox"; | ||
| 2 | import type { | ||
| 3 | ExecutionAdapter, | ||
| 4 | SandboxExecutionRequest, | ||
| 5 | SandboxExecutionResult, | ||
| 6 | SandboxRuntime, | ||
| 7 | } from "../types"; | ||
| 8 | |||
| 9 | const RESULT_MARKER = "__EXECUTOR_RESULT__"; | ||
| 10 | |||
| 11 | interface VercelSandboxRuntimeOptions { | ||
| 12 | controlPlaneBaseUrl: string; | ||
| 13 | internalToken?: string; | ||
| 14 | runtime?: "node24" | "node22"; | ||
| 15 | } | ||
| 16 | |||
| 17 | function stripTrailingSlash(value: string): string { | ||
| 18 | return value.replace(/\/$/, ""); | ||
| 19 | } | ||
| 20 | |||
| 21 | function buildRunnerScript(codeFilePath: string): string { | ||
| 22 | return ` | ||
| 23 | import { readFile } from "node:fs/promises"; | ||
| 24 | import { randomUUID } from "node:crypto"; | ||
| 25 | |||
| 26 | const RESULT_MARKER = ${JSON.stringify(RESULT_MARKER)}; | ||
| 27 | const runId = process.env.EXECUTOR_RUN_ID; | ||
| 28 | const baseUrl = process.env.EXECUTOR_INTERNAL_BASE_URL; | ||
| 29 | const token = process.env.EXECUTOR_INTERNAL_TOKEN || ""; | ||
| 30 | const requestTimeoutMs = Number(process.env.EXECUTOR_REQUEST_TIMEOUT_MS || "15000"); | ||
| 31 | |||
| 32 | if (!runId) { | ||
| 33 | throw new Error("Missing EXECUTOR_RUN_ID"); | ||
| 34 | } | ||
| 35 | |||
| 36 | if (!baseUrl) { | ||
| 37 | throw new Error("Missing EXECUTOR_INTERNAL_BASE_URL"); | ||
| 38 | } | ||
| 39 | |||
| 40 | const userCode = await readFile(${JSON.stringify(codeFilePath)}, "utf8"); | ||
| 41 | const startedAt = Date.now(); | ||
| 42 | const stdoutLines = []; | ||
| 43 | const stderrLines = []; | ||
| 44 | |||
| 45 | function formatArgs(args) { | ||
| 46 | return args.map((value) => { | ||
| 47 | if (typeof value === "string") return value; | ||
| 48 | try { | ||
| 49 | return JSON.stringify(value); | ||
| 50 | } catch { | ||
| 51 | return String(value); | ||
| 52 | } | ||
| 53 | }).join(" "); | ||
| 54 | } | ||
| 55 | |||
| 56 | async function callInternal(path, payload) { | ||
| 57 | const response = await fetch(baseUrl + path, { | ||
| 58 | method: "POST", | ||
| 59 | headers: { | ||
| 60 | "content-type": "application/json", | ||
| 61 | ...(token ? { authorization: "Bearer " + token } : {}), | ||
| 62 | }, | ||
| 63 | body: JSON.stringify(payload), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const text = await response.text(); | ||
| 67 | let data = {}; | ||
| 68 | if (text.length > 0) { | ||
| 69 | try { | ||
| 70 | data = JSON.parse(text); | ||
| 71 | } catch { | ||
| 72 | data = { error: text }; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | if (!response.ok) { | ||
| 77 | const message = typeof data.error === "string" | ||
| 78 | ? data.error | ||
| 79 | : "Internal request failed (" + response.status + ")"; | ||
| 80 | throw new Error(message); | ||
| 81 | } | ||
| 82 | |||
| 83 | return data; | ||
| 84 | } | ||
| 85 | |||
| 86 | function emitOutput(stream, line) { | ||
| 87 | return callInternal("/internal/runs/" + encodeURIComponent(runId) + "/output", { | ||
| 88 | stream, | ||
| 89 | line, | ||
| 90 | timestamp: Date.now(), | ||
| 91 | }); | ||
| 92 | } | ||
| 93 | |||
| 94 | function appendStdout(line) { | ||
| 95 | stdoutLines.push(line); | ||
| 96 | void emitOutput("stdout", line); | ||
| 97 | } | ||
| 98 | |||
| 99 | function appendStderr(line) { | ||
| 100 | stderrLines.push(line); | ||
| 101 | void emitOutput("stderr", line); | ||
| 102 | } | ||
| 103 | |||
| 104 | function createToolsProxy(path = []) { | ||
| 105 | const callable = () => {}; | ||
| 106 | return new Proxy(callable, { | ||
| 107 | get(_target, prop) { | ||
| 108 | if (prop === "then") return undefined; | ||
| 109 | if (typeof prop !== "string") return undefined; | ||
| 110 | return createToolsProxy([...path, prop]); | ||
| 111 | }, | ||
| 112 | async apply(_target, _thisArg, args) { | ||
| 113 | const toolPath = path.join("."); | ||
| 114 | if (!toolPath) { | ||
| 115 | throw new Error("Tool path missing in invocation"); | ||
| 116 | } | ||
| 117 | |||
| 118 | const data = await callInternal( | ||
| 119 | "/internal/runs/" + encodeURIComponent(runId) + "/tool-call", | ||
| 120 | { | ||
| 121 | callId: "call_" + randomUUID(), | ||
| 122 | toolPath, | ||
| 123 | input: args.length > 0 ? args[0] : {}, | ||
| 124 | }, | ||
| 125 | ); | ||
| 126 | |||
| 127 | if (data.ok) { | ||
| 128 | return data.value; | ||
| 129 | } | ||
| 130 | |||
| 131 | if (data.denied) { | ||
| 132 | throw new Error("APPROVAL_DENIED:" + String(data.error || "Tool call denied")); | ||
| 133 | } | ||
| 134 | |||
| 135 | throw new Error(String(data.error || "Tool call failed")); | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | |||
| 140 | const tools = createToolsProxy(); | ||
| 141 | const consoleProxy = { | ||
| 142 | log: (...args) => appendStdout(formatArgs(args)), | ||
| 143 | info: (...args) => appendStdout(formatArgs(args)), | ||
| 144 | warn: (...args) => appendStderr(formatArgs(args)), | ||
| 145 | error: (...args) => appendStderr(formatArgs(args)), | ||
| 146 | }; | ||
| 147 | |||
| 148 | const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; | ||
| 149 | const runner = new AsyncFunction("tools", "console", '"use strict";\\n' + userCode); | ||
| 150 | |||
| 151 | const timeoutPromise = new Promise((_, reject) => { | ||
| 152 | setTimeout(() => reject(new Error("TASK_TIMEOUT")), requestTimeoutMs); | ||
| 153 | }); | ||
| 154 | |||
| 155 | let result; | ||
| 156 | try { | ||
| 157 | const value = await Promise.race([runner(tools, consoleProxy), timeoutPromise]); | ||
| 158 | if (value !== undefined) { | ||
| 159 | appendStdout("result: " + formatArgs([value])); | ||
| 160 | } | ||
| 161 | |||
| 162 | result = { | ||
| 163 | status: "completed", | ||
| 164 | stdout: stdoutLines.join("\\n"), | ||
| 165 | stderr: stderrLines.join("\\n"), | ||
| 166 | exitCode: 0, | ||
| 167 | durationMs: Date.now() - startedAt, | ||
| 168 | }; | ||
| 169 | } catch (error) { | ||
| 170 | const message = error instanceof Error ? error.message : String(error); | ||
| 171 | |||
| 172 | if (message === "TASK_TIMEOUT") { | ||
| 173 | const timeoutMessage = "Execution timed out after " + requestTimeoutMs + "ms"; | ||
| 174 | appendStderr(timeoutMessage); | ||
| 175 | result = { | ||
| 176 | status: "timed_out", | ||
| 177 | stdout: stdoutLines.join("\\n"), | ||
| 178 | stderr: stderrLines.join("\\n"), | ||
| 179 | error: timeoutMessage, | ||
| 180 | durationMs: Date.now() - startedAt, | ||
| 181 | }; | ||
| 182 | } else if (message.startsWith("APPROVAL_DENIED:")) { | ||
| 183 | const deniedMessage = message.slice("APPROVAL_DENIED:".length).trim(); | ||
| 184 | appendStderr(deniedMessage); | ||
| 185 | result = { | ||
| 186 | status: "denied", | ||
| 187 | stdout: stdoutLines.join("\\n"), | ||
| 188 | stderr: stderrLines.join("\\n"), | ||
| 189 | error: deniedMessage, | ||
| 190 | durationMs: Date.now() - startedAt, | ||
| 191 | }; | ||
| 192 | } else { | ||
| 193 | appendStderr(message); | ||
| 194 | result = { | ||
| 195 | status: "failed", | ||
| 196 | stdout: stdoutLines.join("\\n"), | ||
| 197 | stderr: stderrLines.join("\\n"), | ||
| 198 | error: message, | ||
| 199 | durationMs: Date.now() - startedAt, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | process.stdout.write(RESULT_MARKER + JSON.stringify(result) + "\\n"); | ||
| 205 | `; | ||
| 206 | } | ||
| 207 | |||
| 208 | function parseResultFromStdout(stdout: string): SandboxExecutionResult | null { | ||
| 209 | const lines = stdout.split("\n"); | ||
| 210 | const resultLine = lines.find((line) => line.startsWith(RESULT_MARKER)); | ||
| 211 | if (!resultLine) { | ||
| 212 | return null; | ||
| 213 | } | ||
| 214 | |||
| 215 | try { | ||
| 216 | return JSON.parse(resultLine.slice(RESULT_MARKER.length)) as SandboxExecutionResult; | ||
| 217 | } catch { | ||
| 218 | return null; | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | export class VercelSandboxRuntime implements SandboxRuntime { | ||
| 223 | readonly id = "vercel-sandbox"; | ||
| 224 | readonly label = "Vercel Sandbox Runtime"; | ||
| 225 | readonly description = "Runs generated JavaScript inside Vercel Sandbox microVMs."; | ||
| 226 | |||
| 227 | constructor(private readonly options: VercelSandboxRuntimeOptions) {} | ||
| 228 | |||
| 229 | async run( | ||
| 230 | request: SandboxExecutionRequest, | ||
| 231 | _adapter: ExecutionAdapter, | ||
| 232 | ): Promise<SandboxExecutionResult> { | ||
| 233 | const startedAt = Date.now(); | ||
| 234 | const baseUrl = stripTrailingSlash(this.options.controlPlaneBaseUrl); | ||
| 235 | if (!baseUrl) { | ||
| 236 | return { | ||
| 237 | status: "failed", | ||
| 238 | stdout: "", | ||
| 239 | stderr: "", | ||
| 240 | error: "Vercel sandbox runtime misconfigured: missing controlPlaneBaseUrl", | ||
| 241 | durationMs: Date.now() - startedAt, | ||
| 242 | }; | ||
| 243 | } | ||
| 244 | |||
| 245 | if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(baseUrl)) { | ||
| 246 | return { | ||
| 247 | status: "failed", | ||
| 248 | stdout: "", | ||
| 249 | stderr: "", | ||
| 250 | error: | ||
| 251 | "Vercel sandbox runtime requires a publicly reachable EXECUTOR_INTERNAL_BASE_URL (localhost is not reachable from Vercel).", | ||
| 252 | durationMs: Date.now() - startedAt, | ||
| 253 | }; | ||
| 254 | } | ||
| 255 | |||
| 256 | const sandboxTimeoutMs = Math.max(request.timeoutMs + 30_000, 120_000); | ||
| 257 | const sandbox = await Sandbox.create({ | ||
| 258 | runtime: this.options.runtime ?? "node22", | ||
| 259 | timeout: sandboxTimeoutMs, | ||
| 260 | }); | ||
| 261 | |||
| 262 | const codePath = "task-code.js"; | ||
| 263 | const runnerPath = "executor-runner.mjs"; | ||
| 264 | |||
| 265 | try { | ||
| 266 | await sandbox.writeFiles([ | ||
| 267 | { path: codePath, content: Buffer.from(request.code, "utf8") }, | ||
| 268 | { path: runnerPath, content: Buffer.from(buildRunnerScript(codePath), "utf8") }, | ||
| 269 | ]); | ||
| 270 | |||
| 271 | const command = await sandbox.runCommand({ | ||
| 272 | cmd: "node", | ||
| 273 | args: [runnerPath], | ||
| 274 | env: { | ||
| 275 | EXECUTOR_RUN_ID: request.taskId, | ||
| 276 | EXECUTOR_INTERNAL_BASE_URL: baseUrl, | ||
| 277 | EXECUTOR_INTERNAL_TOKEN: this.options.internalToken ?? "", | ||
| 278 | EXECUTOR_REQUEST_TIMEOUT_MS: String(request.timeoutMs), | ||
| 279 | }, | ||
| 280 | }); | ||
| 281 | |||
| 282 | const [stdout, stderr] = await Promise.all([ | ||
| 283 | command.stdout(), | ||
| 284 | command.stderr(), | ||
| 285 | ]); | ||
| 286 | |||
| 287 | const parsed = parseResultFromStdout(stdout); | ||
| 288 | if (parsed) { | ||
| 289 | return parsed; | ||
| 290 | } | ||
| 291 | |||
| 292 | return { | ||
| 293 | status: command.exitCode === 0 ? "completed" : "failed", | ||
| 294 | stdout, | ||
| 295 | stderr, | ||
| 296 | exitCode: command.exitCode, | ||
| 297 | error: command.exitCode === 0 ? undefined : `Sandbox command exited with code ${command.exitCode}`, | ||
| 298 | durationMs: Date.now() - startedAt, | ||
| 299 | }; | ||
| 300 | } catch (error) { | ||
| 301 | return { | ||
| 302 | status: "failed", | ||
| 303 | stdout: "", | ||
| 304 | stderr: "", | ||
| 305 | error: error instanceof Error ? error.message : String(error), | ||
| 306 | durationMs: Date.now() - startedAt, | ||
| 307 | }; | ||
| 308 | } finally { | ||
| 309 | await sandbox.stop(); | ||
| 310 | } | ||
| 311 | } | ||
| 312 | } | ||
| @@ -0,0 +1,93 @@ | |||
| 1 | import { expect, test } from "bun:test"; | ||
| 2 | import { ExecutorDatabase } from "./database"; | ||
| 3 | import { TaskEventHub } from "./events"; | ||
| 4 | import { ExecutorService } from "./service"; | ||
| 5 | import type { | ||
| 6 | ExecutionAdapter, | ||
| 7 | SandboxExecutionRequest, | ||
| 8 | SandboxExecutionResult, | ||
| 9 | SandboxRuntime, | ||
| 10 | ToolDefinition, | ||
| 11 | } from "./types"; | ||
| 12 | |||
| 13 | class InlineToolRuntime implements SandboxRuntime { | ||
| 14 | readonly id = "inline"; | ||
| 15 | readonly label = "Inline"; | ||
| 16 | readonly description = "Calls one tool then exits"; | ||
| 17 | |||
| 18 | async run( | ||
| 19 | request: SandboxExecutionRequest, | ||
| 20 | adapter: ExecutionAdapter, | ||
| 21 | ): Promise<SandboxExecutionResult> { | ||
| 22 | const startedAt = Date.now(); | ||
| 23 | const result = await adapter.invokeTool({ | ||
| 24 | runId: request.taskId, | ||
| 25 | callId: "call_inline_1", | ||
| 26 | toolPath: "admin.delete_data", | ||
| 27 | input: { key: "abc" }, | ||
| 28 | }); | ||
| 29 | |||
| 30 | if (!result.ok) { | ||
| 31 | return { | ||
| 32 | status: result.denied ? "denied" : "failed", | ||
| 33 | stdout: "", | ||
| 34 | stderr: result.error, | ||
| 35 | error: result.error, | ||
| 36 | durationMs: Date.now() - startedAt, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 40 | await adapter.emitOutput({ | ||
| 41 | runId: request.taskId, | ||
| 42 | stream: "stdout", | ||
| 43 | line: `tool_result:${JSON.stringify(result.value)}`, | ||
| 44 | timestamp: Date.now(), | ||
| 45 | }); | ||
| 46 | |||
| 47 | return { | ||
| 48 | status: "completed", | ||
| 49 | stdout: `task:${request.taskId}`, | ||
| 50 | stderr: "", | ||
| 51 | exitCode: 0, | ||
| 52 | durationMs: Date.now() - startedAt, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | const tools: ToolDefinition[] = [ | ||
| 58 | { | ||
| 59 | path: "admin.delete_data", | ||
| 60 | description: "Requires approval", | ||
| 61 | approval: "required", | ||
| 62 | run: async (input) => ({ deleted: true, input }), | ||
| 63 | }, | ||
| 64 | ]; | ||
| 65 | |||
| 66 | test("tool-level approval gates individual function call", async () => { | ||
| 67 | const service = new ExecutorService( | ||
| 68 | new ExecutorDatabase(":memory:"), | ||
| 69 | new TaskEventHub(), | ||
| 70 | [new InlineToolRuntime()], | ||
| 71 | tools, | ||
| 72 | ); | ||
| 73 | |||
| 74 | const created = service.createTask({ | ||
| 75 | code: "unused", | ||
| 76 | runtimeId: "inline", | ||
| 77 | }); | ||
| 78 | |||
| 79 | await new Promise((resolve) => setTimeout(resolve, 5)); | ||
| 80 | const approvals = service.listPendingApprovals(); | ||
| 81 | |||
| 82 | expect(approvals.length).toBe(1); | ||
| 83 | expect(approvals[0]?.toolPath).toBe("admin.delete_data"); | ||
| 84 | |||
| 85 | const resolved = service.resolveApproval(approvals[0]!.id, "approved", "test-user"); | ||
| 86 | expect(resolved).toBeTruthy(); | ||
| 87 | |||
| 88 | await new Promise((resolve) => setTimeout(resolve, 20)); | ||
| 89 | const task = service.getTask(created.task.id); | ||
| 90 | |||
| 91 | expect(task?.status).toBe("completed"); | ||
| 92 | expect(service.listApprovals("approved").length).toBe(1); | ||
| 93 | }); | ||
| @@ -0,0 +1,438 @@ | |||
| 1 | import { ExecutorDatabase } from "./database"; | ||
| 2 | import { TaskEventHub, type LiveTaskEvent } from "./events"; | ||
| 3 | import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapter"; | ||
| 4 | import { APPROVAL_DENIED_PREFIX } from "./execution-constants"; | ||
| 5 | import type { | ||
| 6 | ApprovalRecord, | ||
| 7 | ApprovalStatus, | ||
| 8 | CreateTaskInput, | ||
| 9 | PendingApprovalRecord, | ||
| 10 | SandboxRuntime, | ||
| 11 | TaskEventRecord, | ||
| 12 | TaskRecord, | ||
| 13 | TaskStatus, | ||
| 14 | ToolCallResult, | ||
| 15 | ToolCallRequest, | ||
| 16 | ToolDefinition, | ||
| 17 | ToolDescriptor, | ||
| 18 | RuntimeOutputEvent, | ||
| 19 | } from "./types"; | ||
| 20 | |||
| 21 | interface ApprovalWaiter { | ||
| 22 | resolve: (decision: Exclude<ApprovalStatus, "pending">) => void; | ||
| 23 | } | ||
| 24 | |||
| 25 | function createTaskId(): string { | ||
| 26 | return `task_${crypto.randomUUID()}`; | ||
| 27 | } | ||
| 28 | |||
| 29 | function createApprovalId(): string { | ||
| 30 | return `approval_${crypto.randomUUID()}`; | ||
| 31 | } | ||
| 32 | |||
| 33 | function asPayload(value: unknown): Record<string, unknown> { | ||
| 34 | if (value && typeof value === "object") { | ||
| 35 | return value as Record<string, unknown>; | ||
| 36 | } | ||
| 37 | return { value }; | ||
| 38 | } | ||
| 39 | |||
| 40 | function describeError(error: unknown): string { | ||
| 41 | return error instanceof Error ? error.message : String(error); | ||
| 42 | } | ||
| 43 | |||
| 44 | export class ExecutorService { | ||
| 45 | private readonly db: ExecutorDatabase; | ||
| 46 | private readonly hub: TaskEventHub; | ||
| 47 | private readonly runtimes = new Map<string, SandboxRuntime>(); | ||
| 48 | private readonly tools = new Map<string, ToolDefinition>(); | ||
| 49 | private readonly inFlightTaskIds = new Set<string>(); | ||
| 50 | private readonly approvalWaiters = new Map<string, ApprovalWaiter>(); | ||
| 51 | |||
| 52 | constructor( | ||
| 53 | db: ExecutorDatabase, | ||
| 54 | hub: TaskEventHub, | ||
| 55 | runtimes: SandboxRuntime[], | ||
| 56 | tools: ToolDefinition[], | ||
| 57 | ) { | ||
| 58 | this.db = db; | ||
| 59 | this.hub = hub; | ||
| 60 | for (const runtime of runtimes) { | ||
| 61 | this.runtimes.set(runtime.id, runtime); | ||
| 62 | } | ||
| 63 | for (const tool of tools) { | ||
| 64 | this.tools.set(tool.path, tool); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | listTasks(): TaskRecord[] { | ||
| 69 | return this.db.listTasks(); | ||
| 70 | } | ||
| 71 | |||
| 72 | getTask(taskId: string): TaskRecord | null { | ||
| 73 | return this.db.getTask(taskId); | ||
| 74 | } | ||
| 75 | |||
| 76 | listTaskEvents(taskId: string): TaskEventRecord[] { | ||
| 77 | return this.db.listTaskEvents(taskId); | ||
| 78 | } | ||
| 79 | |||
| 80 | subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void { | ||
| 81 | return this.hub.subscribe(taskId, listener); | ||
| 82 | } | ||
| 83 | |||
| 84 | listApprovals(status?: ApprovalStatus): ApprovalRecord[] { | ||
| 85 | return this.db.listApprovals(status); | ||
| 86 | } | ||
| 87 | |||
| 88 | listPendingApprovals(): PendingApprovalRecord[] { | ||
| 89 | return this.db.listPendingApprovals(); | ||
| 90 | } | ||
| 91 | |||
| 92 | listTools(): ToolDescriptor[] { | ||
| 93 | return [...this.tools.values()].map((tool) => ({ | ||
| 94 | path: tool.path, | ||
| 95 | description: tool.description, | ||
| 96 | approval: tool.approval, | ||
| 97 | })); | ||
| 98 | } | ||
| 99 | |||
| 100 | listRuntimes(): Array<{ id: string; label: string; description: string }> { | ||
| 101 | return [...this.runtimes.values()].map((runtime) => ({ | ||
| 102 | id: runtime.id, | ||
| 103 | label: runtime.label, | ||
| 104 | description: runtime.description, | ||
| 105 | })); | ||
| 106 | } | ||
| 107 | |||
| 108 | createTask(input: CreateTaskInput): { task: TaskRecord } { | ||
| 109 | if (!input.code || input.code.trim().length === 0) { | ||
| 110 | throw new Error("Task code is required"); | ||
| 111 | } | ||
| 112 | |||
| 113 | const runtimeId = input.runtimeId ?? "local-bun"; | ||
| 114 | if (!this.runtimes.has(runtimeId)) { | ||
| 115 | throw new Error(`Unknown runtime: ${runtimeId}`); | ||
| 116 | } | ||
| 117 | |||
| 118 | const task = this.db.createTask({ | ||
| 119 | id: createTaskId(), | ||
| 120 | code: input.code, | ||
| 121 | runtimeId, | ||
| 122 | timeoutMs: input.timeoutMs, | ||
| 123 | metadata: input.metadata, | ||
| 124 | }); | ||
| 125 | |||
| 126 | this.publish(task.id, "task", "task.created", { | ||
| 127 | taskId: task.id, | ||
| 128 | status: task.status, | ||
| 129 | runtimeId: task.runtimeId, | ||
| 130 | timeoutMs: task.timeoutMs, | ||
| 131 | createdAt: task.createdAt, | ||
| 132 | }); | ||
| 133 | |||
| 134 | this.publish(task.id, "task", "task.queued", { | ||
| 135 | taskId: task.id, | ||
| 136 | status: "queued", | ||
| 137 | }); | ||
| 138 | |||
| 139 | void this.executeTask(task.id); | ||
| 140 | return { task }; | ||
| 141 | } | ||
| 142 | |||
| 143 | resolveApproval( | ||
| 144 | approvalId: string, | ||
| 145 | decision: "approved" | "denied", | ||
| 146 | reviewerId?: string, | ||
| 147 | reason?: string, | ||
| 148 | ): { approval: ApprovalRecord; task: TaskRecord } | null { | ||
| 149 | const approval = this.db.resolveApproval({ | ||
| 150 | approvalId, | ||
| 151 | decision, | ||
| 152 | reviewerId, | ||
| 153 | reason, | ||
| 154 | }); | ||
| 155 | |||
| 156 | if (!approval) { | ||
| 157 | return null; | ||
| 158 | } | ||
| 159 | |||
| 160 | this.publish(approval.taskId, "approval", "approval.resolved", { | ||
| 161 | approvalId: approval.id, | ||
| 162 | taskId: approval.taskId, | ||
| 163 | toolPath: approval.toolPath, | ||
| 164 | decision: approval.status, | ||
| 165 | reviewerId: approval.reviewerId, | ||
| 166 | reason: approval.reason, | ||
| 167 | resolvedAt: approval.resolvedAt, | ||
| 168 | }); | ||
| 169 | |||
| 170 | const waiter = this.approvalWaiters.get(approval.id); | ||
| 171 | if (waiter) { | ||
| 172 | this.approvalWaiters.delete(approval.id); | ||
| 173 | waiter.resolve(approval.status as "approved" | "denied"); | ||
| 174 | } | ||
| 175 | |||
| 176 | const task = this.db.getTask(approval.taskId); | ||
| 177 | if (!task) { | ||
| 178 | throw new Error(`Task ${approval.taskId} missing while resolving approval`); | ||
| 179 | } | ||
| 180 | |||
| 181 | return { approval, task }; | ||
| 182 | } | ||
| 183 | |||
| 184 | async handleExternalToolCall(call: ToolCallRequest): Promise<ToolCallResult> { | ||
| 185 | const task = this.db.getTask(call.runId); | ||
| 186 | if (!task) { | ||
| 187 | return { | ||
| 188 | ok: false, | ||
| 189 | error: `Run not found: ${call.runId}`, | ||
| 190 | }; | ||
| 191 | } | ||
| 192 | |||
| 193 | try { | ||
| 194 | const value = await this.invokeTool(task, call); | ||
| 195 | return { ok: true, value }; | ||
| 196 | } catch (error) { | ||
| 197 | const message = describeError(error); | ||
| 198 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | ||
| 199 | return { | ||
| 200 | ok: false, | ||
| 201 | denied: true, | ||
| 202 | error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | ||
| 203 | }; | ||
| 204 | } | ||
| 205 | |||
| 206 | return { | ||
| 207 | ok: false, | ||
| 208 | error: message, | ||
| 209 | }; | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | appendRuntimeOutput(event: RuntimeOutputEvent): void { | ||
| 214 | this.publish( | ||
| 215 | event.runId, | ||
| 216 | "task", | ||
| 217 | event.stream === "stdout" ? "task.stdout" : "task.stderr", | ||
| 218 | { | ||
| 219 | taskId: event.runId, | ||
| 220 | line: event.line, | ||
| 221 | timestamp: event.timestamp, | ||
| 222 | }, | ||
| 223 | ); | ||
| 224 | } | ||
| 225 | |||
| 226 | private publish( | ||
| 227 | taskId: string, | ||
| 228 | eventName: TaskEventRecord["eventName"], | ||
| 229 | type: string, | ||
| 230 | payload: Record<string, unknown>, | ||
| 231 | ): void { | ||
| 232 | const event = this.db.createTaskEvent({ taskId, eventName, type, payload }); | ||
| 233 | this.hub.publish(taskId, { | ||
| 234 | id: event.id, | ||
| 235 | eventName, | ||
| 236 | payload, | ||
| 237 | createdAt: event.createdAt, | ||
| 238 | }); | ||
| 239 | } | ||
| 240 | |||
| 241 | private async waitForApproval(approvalId: string): Promise<"approved" | "denied"> { | ||
| 242 | const approval = this.db.getApproval(approvalId); | ||
| 243 | if (!approval) { | ||
| 244 | throw new Error(`Approval ${approvalId} not found`); | ||
| 245 | } | ||
| 246 | |||
| 247 | if (approval.status !== "pending") { | ||
| 248 | return approval.status as "approved" | "denied"; | ||
| 249 | } | ||
| 250 | |||
| 251 | return await new Promise<"approved" | "denied">((resolve) => { | ||
| 252 | this.approvalWaiters.set(approvalId, { resolve }); | ||
| 253 | }); | ||
| 254 | } | ||
| 255 | |||
| 256 | private async invokeTool(task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | ||
| 257 | const { toolPath, input, callId } = call; | ||
| 258 | const tool = this.tools.get(toolPath); | ||
| 259 | if (!tool) { | ||
| 260 | throw new Error(`Unknown tool: ${toolPath}`); | ||
| 261 | } | ||
| 262 | |||
| 263 | this.publish(task.id, "task", "tool.call.started", { | ||
| 264 | taskId: task.id, | ||
| 265 | callId, | ||
| 266 | toolPath, | ||
| 267 | approval: tool.approval, | ||
| 268 | input: asPayload(input), | ||
| 269 | }); | ||
| 270 | |||
| 271 | if (tool.approval === "required") { | ||
| 272 | const approval = this.db.createApproval({ | ||
| 273 | id: createApprovalId(), | ||
| 274 | taskId: task.id, | ||
| 275 | toolPath, | ||
| 276 | input, | ||
| 277 | }); | ||
| 278 | |||
| 279 | this.publish(task.id, "approval", "approval.requested", { | ||
| 280 | approvalId: approval.id, | ||
| 281 | taskId: task.id, | ||
| 282 | callId, | ||
| 283 | toolPath: approval.toolPath, | ||
| 284 | input: asPayload(approval.input), | ||
| 285 | createdAt: approval.createdAt, | ||
| 286 | }); | ||
| 287 | |||
| 288 | const decision = await this.waitForApproval(approval.id); | ||
| 289 | if (decision === "denied") { | ||
| 290 | this.publish(task.id, "task", "tool.call.denied", { | ||
| 291 | taskId: task.id, | ||
| 292 | callId, | ||
| 293 | toolPath, | ||
| 294 | approvalId: approval.id, | ||
| 295 | }); | ||
| 296 | throw new Error(`${APPROVAL_DENIED_PREFIX}${toolPath} (${approval.id})`); | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | try { | ||
| 301 | const value = await tool.run(input); | ||
| 302 | this.publish(task.id, "task", "tool.call.completed", { | ||
| 303 | taskId: task.id, | ||
| 304 | callId, | ||
| 305 | toolPath, | ||
| 306 | output: asPayload(value), | ||
| 307 | }); | ||
| 308 | return value; | ||
| 309 | } catch (error) { | ||
| 310 | const message = describeError(error); | ||
| 311 | this.publish(task.id, "task", "tool.call.failed", { | ||
| 312 | taskId: task.id, | ||
| 313 | callId, | ||
| 314 | toolPath, | ||
| 315 | error: message, | ||
| 316 | }); | ||
| 317 | throw error; | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | private async executeTask(taskId: string): Promise<void> { | ||
| 322 | if (this.inFlightTaskIds.has(taskId)) { | ||
| 323 | return; | ||
| 324 | } | ||
| 325 | |||
| 326 | this.inFlightTaskIds.add(taskId); | ||
| 327 | try { | ||
| 328 | const task = this.db.getTask(taskId); | ||
| 329 | if (!task || task.status !== "queued") { | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | |||
| 333 | const runtime = this.runtimes.get(task.runtimeId); | ||
| 334 | if (!runtime) { | ||
| 335 | const failed = this.db.markTaskFinished({ | ||
| 336 | taskId, | ||
| 337 | status: "failed", | ||
| 338 | stdout: "", | ||
| 339 | stderr: "", | ||
| 340 | error: `Runtime not found: ${task.runtimeId}`, | ||
| 341 | }); | ||
| 342 | |||
| 343 | if (failed) { | ||
| 344 | this.publish(taskId, "task", "task.failed", { | ||
| 345 | taskId, | ||
| 346 | status: failed.status, | ||
| 347 | error: failed.error, | ||
| 348 | }); | ||
| 349 | } | ||
| 350 | return; | ||
| 351 | } | ||
| 352 | |||
| 353 | const running = this.db.markTaskRunning(taskId); | ||
| 354 | if (!running) { | ||
| 355 | return; | ||
| 356 | } | ||
| 357 | |||
| 358 | this.publish(taskId, "task", "task.running", { | ||
| 359 | taskId, | ||
| 360 | status: running.status, | ||
| 361 | startedAt: running.startedAt, | ||
| 362 | }); | ||
| 363 | |||
| 364 | const adapter = new InProcessExecutionAdapter({ | ||
| 365 | runId: taskId, | ||
| 366 | invokeTool: async (call) => await this.invokeTool(running, call), | ||
| 367 | emitOutput: (event) => { | ||
| 368 | this.appendRuntimeOutput(event); | ||
| 369 | }, | ||
| 370 | }); | ||
| 371 | |||
| 372 | const runtimeResult = await runtime.run( | ||
| 373 | { | ||
| 374 | taskId, | ||
| 375 | code: running.code, | ||
| 376 | timeoutMs: running.timeoutMs, | ||
| 377 | }, | ||
| 378 | adapter, | ||
| 379 | ); | ||
| 380 | |||
| 381 | const finished = this.db.markTaskFinished({ | ||
| 382 | taskId, | ||
| 383 | status: runtimeResult.status, | ||
| 384 | stdout: runtimeResult.stdout, | ||
| 385 | stderr: runtimeResult.stderr, | ||
| 386 | exitCode: runtimeResult.exitCode, | ||
| 387 | error: runtimeResult.error, | ||
| 388 | }); | ||
| 389 | |||
| 390 | if (!finished) { | ||
| 391 | return; | ||
| 392 | } | ||
| 393 | |||
| 394 | const terminalEvent = | ||
| 395 | runtimeResult.status === "completed" | ||
| 396 | ? "task.completed" | ||
| 397 | : runtimeResult.status === "timed_out" | ||
| 398 | ? "task.timed_out" | ||
| 399 | : runtimeResult.status === "denied" | ||
| 400 | ? "task.denied" | ||
| 401 | : "task.failed"; | ||
| 402 | |||
| 403 | this.publish(taskId, "task", terminalEvent, { | ||
| 404 | taskId, | ||
| 405 | status: finished.status, | ||
| 406 | exitCode: finished.exitCode, | ||
| 407 | durationMs: runtimeResult.durationMs, | ||
| 408 | error: finished.error, | ||
| 409 | completedAt: finished.completedAt, | ||
| 410 | }); | ||
| 411 | } catch (error) { | ||
| 412 | const message = describeError(error); | ||
| 413 | const denied = message.startsWith(APPROVAL_DENIED_PREFIX); | ||
| 414 | const finished = this.db.markTaskFinished({ | ||
| 415 | taskId, | ||
| 416 | status: denied ? "denied" : "failed", | ||
| 417 | stdout: "", | ||
| 418 | stderr: "", | ||
| 419 | error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message, | ||
| 420 | }); | ||
| 421 | |||
| 422 | if (finished) { | ||
| 423 | this.publish(taskId, "task", denied ? "task.denied" : "task.failed", { | ||
| 424 | taskId, | ||
| 425 | status: finished.status, | ||
| 426 | error: finished.error, | ||
| 427 | completedAt: finished.completedAt, | ||
| 428 | }); | ||
| 429 | } | ||
| 430 | } finally { | ||
| 431 | this.inFlightTaskIds.delete(taskId); | ||
| 432 | } | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | export function getTaskTerminalState(status: TaskStatus): boolean { | ||
| 437 | return status === "completed" || status === "failed" || status === "timed_out" || status === "denied"; | ||
| 438 | } | ||
| @@ -0,0 +1,116 @@ | |||
| 1 | interface CommandResult { | ||
| 2 | ok: boolean; | ||
| 3 | exitCode: number; | ||
| 4 | stdout: string; | ||
| 5 | stderr: string; | ||
| 6 | } | ||
| 7 | |||
| 8 | interface FunnelStatusEntry { | ||
| 9 | Handlers?: Record<string, { Proxy?: string }>; | ||
| 10 | } | ||
| 11 | |||
| 12 | interface FunnelStatus { | ||
| 13 | Web?: Record<string, FunnelStatusEntry>; | ||
| 14 | AllowFunnel?: Record<string, boolean>; | ||
| 15 | } | ||
| 16 | |||
| 17 | function runCommand(args: string[]): CommandResult { | ||
| 18 | const proc = Bun.spawnSync(args, { | ||
| 19 | stdout: "pipe", | ||
| 20 | stderr: "pipe", | ||
| 21 | }); | ||
| 22 | |||
| 23 | const stdout = proc.stdout.toString(); | ||
| 24 | const stderr = proc.stderr.toString(); | ||
| 25 | const exitCode = proc.exitCode ?? 1; | ||
| 26 | |||
| 27 | return { | ||
| 28 | ok: exitCode === 0, | ||
| 29 | exitCode, | ||
| 30 | stdout, | ||
| 31 | stderr, | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | function normalizeHostname(hostnamePort: string): string { | ||
| 36 | return hostnamePort.endsWith(":443") ? hostnamePort.slice(0, -4) : hostnamePort; | ||
| 37 | } | ||
| 38 | |||
| 39 | function extractFunnelUrlForPort(status: FunnelStatus, port: number): string | null { | ||
| 40 | const web = status.Web; | ||
| 41 | if (!web || typeof web !== "object") { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | const allowFunnel = status.AllowFunnel ?? {}; | ||
| 46 | |||
| 47 | for (const [host, entry] of Object.entries(web)) { | ||
| 48 | if (allowFunnel[host] !== true) { | ||
| 49 | continue; | ||
| 50 | } | ||
| 51 | |||
| 52 | const handlers = entry.Handlers; | ||
| 53 | if (!handlers || typeof handlers !== "object") { | ||
| 54 | continue; | ||
| 55 | } | ||
| 56 | |||
| 57 | for (const handler of Object.values(handlers)) { | ||
| 58 | const proxy = handler.Proxy; | ||
| 59 | if (typeof proxy !== "string") { | ||
| 60 | continue; | ||
| 61 | } | ||
| 62 | |||
| 63 | if (proxy.includes(`:${port}`)) { | ||
| 64 | return `https://${normalizeHostname(host)}`; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | return null; | ||
| 70 | } | ||
| 71 | |||
| 72 | function readFunnelStatus(port: number): string | null { | ||
| 73 | const statusResult = runCommand(["tailscale", "funnel", "status", "--json"]); | ||
| 74 | if (!statusResult.ok || statusResult.stdout.trim().length === 0) { | ||
| 75 | return null; | ||
| 76 | } | ||
| 77 | |||
| 78 | try { | ||
| 79 | const parsed = JSON.parse(statusResult.stdout) as FunnelStatus; | ||
| 80 | return extractFunnelUrlForPort(parsed, port); | ||
| 81 | } catch { | ||
| 82 | return null; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | export interface EnsureFunnelResult { | ||
| 87 | url: string; | ||
| 88 | created: boolean; | ||
| 89 | } | ||
| 90 | |||
| 91 | export function ensureTailscaleFunnel(port: number): EnsureFunnelResult { | ||
| 92 | const existing = readFunnelStatus(port); | ||
| 93 | if (existing) { | ||
| 94 | return { url: existing, created: false }; | ||
| 95 | } | ||
| 96 | |||
| 97 | const startResult = runCommand([ | ||
| 98 | "tailscale", | ||
| 99 | "funnel", | ||
| 100 | "--bg", | ||
| 101 | "--yes", | ||
| 102 | String(port), | ||
| 103 | ]); | ||
| 104 | |||
| 105 | if (!startResult.ok) { | ||
| 106 | const message = startResult.stderr.trim() || startResult.stdout.trim() || "unknown error"; | ||
| 107 | throw new Error(`tailscale funnel failed: ${message}`); | ||
| 108 | } | ||
| 109 | |||
| 110 | const created = readFunnelStatus(port); | ||
| 111 | if (!created) { | ||
| 112 | throw new Error("tailscale funnel started but no URL found in status output"); | ||
| 113 | } | ||
| 114 | |||
| 115 | return { url: created, created: true }; | ||
| 116 | } | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | import type { ToolDefinition } from "./types"; | ||
| 2 | |||
| 3 | function asObject(value: unknown): Record<string, unknown> { | ||
| 4 | return value && typeof value === "object" ? (value as Record<string, unknown>) : {}; | ||
| 5 | } | ||
| 6 | |||
| 7 | export const DEFAULT_TOOLS: ToolDefinition[] = [ | ||
| 8 | { | ||
| 9 | path: "utils.get_time", | ||
| 10 | description: "Return current server time.", | ||
| 11 | approval: "auto", | ||
| 12 | run: async () => ({ | ||
| 13 | iso: new Date().toISOString(), | ||
| 14 | unix: Date.now(), | ||
| 15 | }), | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | path: "math.add", | ||
| 19 | description: "Add two numbers.", | ||
| 20 | approval: "auto", | ||
| 21 | run: async (input) => { | ||
| 22 | const payload = asObject(input); | ||
| 23 | const a = Number(payload.a ?? 0); | ||
| 24 | const b = Number(payload.b ?? 0); | ||
| 25 | if (!Number.isFinite(a) || !Number.isFinite(b)) { | ||
| 26 | throw new Error("math.add expects numeric a and b"); | ||
| 27 | } | ||
| 28 | return { result: a + b }; | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | path: "admin.send_announcement", | ||
| 33 | description: "Mock announcement sender that requires approval.", | ||
| 34 | approval: "required", | ||
| 35 | run: async (input) => { | ||
| 36 | const payload = asObject(input); | ||
| 37 | const channel = String(payload.channel ?? "general"); | ||
| 38 | const message = String(payload.message ?? ""); | ||
| 39 | if (!message.trim()) { | ||
| 40 | throw new Error("admin.send_announcement requires a non-empty message"); | ||
| 41 | } | ||
| 42 | return { | ||
| 43 | sent: true, | ||
| 44 | channel, | ||
| 45 | message, | ||
| 46 | }; | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | path: "admin.delete_data", | ||
| 51 | description: "Mock destructive operation that requires approval.", | ||
| 52 | approval: "required", | ||
| 53 | run: async (input) => { | ||
| 54 | const payload = asObject(input); | ||
| 55 | const key = String(payload.key ?? ""); | ||
| 56 | if (!key.trim()) { | ||
| 57 | throw new Error("admin.delete_data requires key"); | ||
| 58 | } | ||
| 59 | return { | ||
| 60 | deleted: true, | ||
| 61 | key, | ||
| 62 | }; | ||
| 63 | }, | ||
| 64 | }, | ||
| 65 | ]; | ||
| @@ -0,0 +1,123 @@ | |||
| 1 | export type TaskStatus = | ||
| 2 | | "queued" | ||
| 3 | | "running" | ||
| 4 | | "completed" | ||
| 5 | | "failed" | ||
| 6 | | "timed_out" | ||
| 7 | | "denied"; | ||
| 8 | |||
| 9 | export type ApprovalStatus = "pending" | "approved" | "denied"; | ||
| 10 | |||
| 11 | export interface TaskRecord { | ||
| 12 | id: string; | ||
| 13 | code: string; | ||
| 14 | runtimeId: string; | ||
| 15 | status: TaskStatus; | ||
| 16 | timeoutMs: number; | ||
| 17 | metadata: Record<string, unknown>; | ||
| 18 | createdAt: number; | ||
| 19 | updatedAt: number; | ||
| 20 | startedAt?: number; | ||
| 21 | completedAt?: number; | ||
| 22 | error?: string; | ||
| 23 | stdout?: string; | ||
| 24 | stderr?: string; | ||
| 25 | exitCode?: number; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface ApprovalRecord { | ||
| 29 | id: string; | ||
| 30 | taskId: string; | ||
| 31 | toolPath: string; | ||
| 32 | input: unknown; | ||
| 33 | status: ApprovalStatus; | ||
| 34 | reason?: string; | ||
| 35 | reviewerId?: string; | ||
| 36 | createdAt: number; | ||
| 37 | resolvedAt?: number; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface PendingApprovalRecord extends ApprovalRecord { | ||
| 41 | task: Pick<TaskRecord, "id" | "status" | "runtimeId" | "timeoutMs" | "createdAt">; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface TaskEventRecord { | ||
| 45 | id: number; | ||
| 46 | taskId: string; | ||
| 47 | eventName: string; | ||
| 48 | type: string; | ||
| 49 | payload: unknown; | ||
| 50 | createdAt: number; | ||
| 51 | } | ||
| 52 | |||
| 53 | export interface CreateTaskInput { | ||
| 54 | code: string; | ||
| 55 | timeoutMs?: number; | ||
| 56 | runtimeId?: string; | ||
| 57 | metadata?: Record<string, unknown>; | ||
| 58 | } | ||
| 59 | |||
| 60 | export interface SandboxExecutionRequest { | ||
| 61 | taskId: string; | ||
| 62 | code: string; | ||
| 63 | timeoutMs: number; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface SandboxExecutionResult { | ||
| 67 | status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | ||
| 68 | stdout: string; | ||
| 69 | stderr: string; | ||
| 70 | exitCode?: number; | ||
| 71 | error?: string; | ||
| 72 | durationMs: number; | ||
| 73 | } | ||
| 74 | |||
| 75 | export interface ToolCallRequest { | ||
| 76 | runId: string; | ||
| 77 | callId: string; | ||
| 78 | toolPath: string; | ||
| 79 | input: unknown; | ||
| 80 | } | ||
| 81 | |||
| 82 | export type ToolCallResult = | ||
| 83 | | { ok: true; value: unknown } | ||
| 84 | | { ok: false; error: string; denied?: boolean }; | ||
| 85 | |||
| 86 | export type RuntimeOutputStream = "stdout" | "stderr"; | ||
| 87 | |||
| 88 | export interface RuntimeOutputEvent { | ||
| 89 | runId: string; | ||
| 90 | stream: RuntimeOutputStream; | ||
| 91 | line: string; | ||
| 92 | timestamp: number; | ||
| 93 | } | ||
| 94 | |||
| 95 | export interface ExecutionAdapter { | ||
| 96 | invokeTool(call: ToolCallRequest): Promise<ToolCallResult>; | ||
| 97 | emitOutput(event: RuntimeOutputEvent): void | Promise<void>; | ||
| 98 | } | ||
| 99 | |||
| 100 | export interface SandboxRuntime { | ||
| 101 | id: string; | ||
| 102 | label: string; | ||
| 103 | description: string; | ||
| 104 | run( | ||
| 105 | request: SandboxExecutionRequest, | ||
| 106 | adapter: ExecutionAdapter, | ||
| 107 | ): Promise<SandboxExecutionResult>; | ||
| 108 | } | ||
| 109 | |||
| 110 | export type ToolApprovalMode = "auto" | "required"; | ||
| 111 | |||
| 112 | export interface ToolDefinition { | ||
| 113 | path: string; | ||
| 114 | description: string; | ||
| 115 | approval: ToolApprovalMode; | ||
| 116 | run(input: unknown): Promise<unknown>; | ||
| 117 | } | ||
| 118 | |||
| 119 | export interface ToolDescriptor { | ||
| 120 | path: string; | ||
| 121 | description: string; | ||
| 122 | approval: ToolApprovalMode; | ||
| 123 | } | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ESNext", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "bundler", | ||
| 6 | "strict": true, | ||
| 7 | "noEmit": true, | ||
| 8 | "types": [ | ||
| 9 | "bun" | ||
| 10 | ], | ||
| 11 | "skipLibCheck": true | ||
| 12 | }, | ||
| 13 | "include": [ | ||
| 14 | "src/**/*.ts" | ||
| 15 | ] | ||
| 16 | } | ||
| @@ -0,0 +1,97 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| 6 | <title>Executor Control Plane</title> | ||
| 7 | <link rel="stylesheet" href="./src/styles.css" /> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | <div class="background-glow glow-one"></div> | ||
| 11 | <div class="background-glow glow-two"></div> | ||
| 12 | <div class="grain-overlay"></div> | ||
| 13 | <main class="layout"> | ||
| 14 | <header class="hero"> | ||
| 15 | <p class="eyebrow">Executor / Control Room</p> | ||
| 16 | <h1>Approval-First Runtime Console</h1> | ||
| 17 | <p>Run generated code, intercept sensitive tool calls, and approve each action with full traceability.</p> | ||
| 18 | <div class="hero-metrics"> | ||
| 19 | <article class="metric-card"> | ||
| 20 | <span>Pending approvals</span> | ||
| 21 | <strong id="metric-pending">0</strong> | ||
| 22 | </article> | ||
| 23 | <article class="metric-card"> | ||
| 24 | <span>Total tasks</span> | ||
| 25 | <strong id="metric-total-tasks">0</strong> | ||
| 26 | </article> | ||
| 27 | <article class="metric-card"> | ||
| 28 | <span>Active runs</span> | ||
| 29 | <strong id="metric-running">0</strong> | ||
| 30 | </article> | ||
| 31 | </div> | ||
| 32 | </header> | ||
| 33 | |||
| 34 | <section class="panel composer-panel" id="new-task-panel"> | ||
| 35 | <div class="section-title"> | ||
| 36 | <h2>Queue task</h2> | ||
| 37 | <p class="section-note">Generated JavaScript can call <code>tools.*</code>. Required tools pause for approval.</p> | ||
| 38 | </div> | ||
| 39 | <form id="new-task-form"> | ||
| 40 | <div class="form-top-grid"> | ||
| 41 | <label> | ||
| 42 | Runtime target | ||
| 43 | <select id="runtime-id"></select> | ||
| 44 | </label> | ||
| 45 | <label> | ||
| 46 | Timeout (ms) | ||
| 47 | <input id="timeout-ms" type="number" min="100" value="15000" /> | ||
| 48 | </label> | ||
| 49 | </div> | ||
| 50 | <label> | ||
| 51 | Code payload | ||
| 52 | <textarea | ||
| 53 | id="task-code" | ||
| 54 | spellcheck="false" | ||
| 55 | rows="10" | ||
| 56 | >const now = await tools.utils.get_time({}); | ||
| 57 | console.log("time", now.iso); | ||
| 58 | |||
| 59 | await tools.admin.send_announcement({ | ||
| 60 | channel: "ops", | ||
| 61 | message: "Deploy candidate ready for review" | ||
| 62 | }); | ||
| 63 | |||
| 64 | const sum = await tools.math.add({ a: 2, b: 5 }); | ||
| 65 | console.log("sum", sum.result);</textarea> | ||
| 66 | </label> | ||
| 67 | <div class="composer-actions"> | ||
| 68 | <button type="submit">Create task</button> | ||
| 69 | </div> | ||
| 70 | </form> | ||
| 71 | </section> | ||
| 72 | |||
| 73 | <section class="panel" id="approvals-panel" aria-live="polite"> | ||
| 74 | <div class="section-title"> | ||
| 75 | <h2>Pending tool approvals</h2> | ||
| 76 | <span id="approval-count" class="pill">0</span> | ||
| 77 | </div> | ||
| 78 | <div id="approvals-list" class="approval-list"></div> | ||
| 79 | </section> | ||
| 80 | |||
| 81 | <section class="panel" id="tasks-panel"> | ||
| 82 | <div class="section-title"> | ||
| 83 | <h2>Task history</h2> | ||
| 84 | <button id="refresh-button" class="secondary">Refresh</button> | ||
| 85 | </div> | ||
| 86 | <div id="tasks-list" class="task-list"></div> | ||
| 87 | </section> | ||
| 88 | |||
| 89 | <section class="panel" id="task-detail-panel"> | ||
| 90 | <h2>Task details</h2> | ||
| 91 | <div id="task-detail" class="task-detail empty">Select a task to inspect logs.</div> | ||
| 92 | </section> | ||
| 93 | </main> | ||
| 94 | |||
| 95 | <script type="module" src="./src/main.ts"></script> | ||
| 96 | </body> | ||
| 97 | </html> | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/web", | ||
| 3 | "private": true, | ||
| 4 | "type": "module" | ||
| 5 | } | ||
| @@ -0,0 +1,316 @@ | |||
| 1 | type TaskStatus = | ||
| 2 | | "queued" | ||
| 3 | | "running" | ||
| 4 | | "completed" | ||
| 5 | | "failed" | ||
| 6 | | "timed_out" | ||
| 7 | | "denied"; | ||
| 8 | |||
| 9 | interface TaskRecord { | ||
| 10 | id: string; | ||
| 11 | runtimeId: string; | ||
| 12 | timeoutMs: number; | ||
| 13 | status: TaskStatus; | ||
| 14 | code: string; | ||
| 15 | createdAt: number; | ||
| 16 | startedAt?: number; | ||
| 17 | completedAt?: number; | ||
| 18 | stdout?: string; | ||
| 19 | stderr?: string; | ||
| 20 | error?: string; | ||
| 21 | exitCode?: number; | ||
| 22 | } | ||
| 23 | |||
| 24 | interface PendingApproval { | ||
| 25 | id: string; | ||
| 26 | taskId: string; | ||
| 27 | toolPath: string; | ||
| 28 | input: unknown; | ||
| 29 | createdAt: number; | ||
| 30 | task: { | ||
| 31 | id: string; | ||
| 32 | status: TaskStatus; | ||
| 33 | runtimeId: string; | ||
| 34 | timeoutMs: number; | ||
| 35 | createdAt: number; | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface RuntimeTarget { | ||
| 40 | id: string; | ||
| 41 | label: string; | ||
| 42 | description: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | const state = { | ||
| 46 | tasks: [] as TaskRecord[], | ||
| 47 | approvals: [] as PendingApproval[], | ||
| 48 | runtimes: [] as RuntimeTarget[], | ||
| 49 | selectedTaskId: null as string | null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | const els = { | ||
| 53 | form: document.querySelector<HTMLFormElement>("#new-task-form")!, | ||
| 54 | runtimeSelect: document.querySelector<HTMLSelectElement>("#runtime-id")!, | ||
| 55 | timeoutInput: document.querySelector<HTMLInputElement>("#timeout-ms")!, | ||
| 56 | code: document.querySelector<HTMLTextAreaElement>("#task-code")!, | ||
| 57 | metricPending: document.querySelector<HTMLElement>("#metric-pending"), | ||
| 58 | metricTotalTasks: document.querySelector<HTMLElement>("#metric-total-tasks"), | ||
| 59 | metricRunning: document.querySelector<HTMLElement>("#metric-running"), | ||
| 60 | approvalsList: document.querySelector<HTMLDivElement>("#approvals-list")!, | ||
| 61 | approvalCount: document.querySelector<HTMLSpanElement>("#approval-count")!, | ||
| 62 | tasksList: document.querySelector<HTMLDivElement>("#tasks-list")!, | ||
| 63 | taskDetail: document.querySelector<HTMLDivElement>("#task-detail")!, | ||
| 64 | refreshButton: document.querySelector<HTMLButtonElement>("#refresh-button")!, | ||
| 65 | }; | ||
| 66 | |||
| 67 | let selectedTaskStream: EventSource | null = null; | ||
| 68 | |||
| 69 | async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { | ||
| 70 | const response = await fetch(path, init); | ||
| 71 | if (!response.ok) { | ||
| 72 | const message = await response.text(); | ||
| 73 | throw new Error(message || `${response.status} ${response.statusText}`); | ||
| 74 | } | ||
| 75 | return (await response.json()) as T; | ||
| 76 | } | ||
| 77 | |||
| 78 | function escapeHtml(value: string): string { | ||
| 79 | return value | ||
| 80 | .replaceAll("&", "&") | ||
| 81 | .replaceAll("<", "<") | ||
| 82 | .replaceAll(">", ">") | ||
| 83 | .replaceAll('"', """) | ||
| 84 | .replaceAll("'", "'"); | ||
| 85 | } | ||
| 86 | |||
| 87 | function prettyJson(value: unknown): string { | ||
| 88 | try { | ||
| 89 | return JSON.stringify(value, null, 2); | ||
| 90 | } catch { | ||
| 91 | return String(value); | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | function fmtTime(timestamp?: number): string { | ||
| 96 | if (!timestamp) return "-"; | ||
| 97 | return new Date(timestamp).toLocaleTimeString(); | ||
| 98 | } | ||
| 99 | |||
| 100 | function statusClass(status: TaskStatus): string { | ||
| 101 | return `status status-${status.replaceAll("_", "-")}`; | ||
| 102 | } | ||
| 103 | |||
| 104 | function labelStatus(status: TaskStatus): string { | ||
| 105 | return status.replaceAll("_", " "); | ||
| 106 | } | ||
| 107 | |||
| 108 | function renderMetrics(): void { | ||
| 109 | const runningCount = state.tasks.filter( | ||
| 110 | (task) => task.status === "running" || task.status === "queued", | ||
| 111 | ).length; | ||
| 112 | |||
| 113 | if (els.metricPending) { | ||
| 114 | els.metricPending.textContent = String(state.approvals.length); | ||
| 115 | } | ||
| 116 | |||
| 117 | if (els.metricTotalTasks) { | ||
| 118 | els.metricTotalTasks.textContent = String(state.tasks.length); | ||
| 119 | } | ||
| 120 | |||
| 121 | if (els.metricRunning) { | ||
| 122 | els.metricRunning.textContent = String(runningCount); | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | function connectToSelectedTask(taskId: string | null): void { | ||
| 127 | if (selectedTaskStream) { | ||
| 128 | selectedTaskStream.close(); | ||
| 129 | selectedTaskStream = null; | ||
| 130 | } | ||
| 131 | if (!taskId) return; | ||
| 132 | |||
| 133 | selectedTaskStream = new EventSource(`/api/tasks/${encodeURIComponent(taskId)}/events`); | ||
| 134 | const onEvent = () => { | ||
| 135 | void refreshData({ keepSelection: true }); | ||
| 136 | }; | ||
| 137 | selectedTaskStream.addEventListener("task", onEvent); | ||
| 138 | selectedTaskStream.addEventListener("approval", onEvent); | ||
| 139 | } | ||
| 140 | |||
| 141 | function renderRuntimeOptions(): void { | ||
| 142 | els.runtimeSelect.innerHTML = state.runtimes | ||
| 143 | .map((runtime) => `<option value="${runtime.id}">${escapeHtml(runtime.label)}</option>`) | ||
| 144 | .join(""); | ||
| 145 | } | ||
| 146 | |||
| 147 | function renderApprovals(): void { | ||
| 148 | els.approvalCount.textContent = String(state.approvals.length); | ||
| 149 | if (state.approvals.length === 0) { | ||
| 150 | els.approvalsList.innerHTML = '<p class="empty">No pending tool approvals.</p>'; | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | |||
| 154 | els.approvalsList.innerHTML = state.approvals | ||
| 155 | .map((approval) => { | ||
| 156 | const inputJson = prettyJson(approval.input); | ||
| 157 | return ` | ||
| 158 | <article class="approval-card"> | ||
| 159 | <header> | ||
| 160 | <strong>${escapeHtml(approval.toolPath)}</strong> | ||
| 161 | <span>${escapeHtml(approval.taskId)}</span> | ||
| 162 | </header> | ||
| 163 | <p class="meta">Requested ${fmtTime(approval.createdAt)} • status ${escapeHtml(approval.task.status)}</p> | ||
| 164 | <pre>${escapeHtml(inputJson)}</pre> | ||
| 165 | <div class="actions"> | ||
| 166 | <button data-action="approve" data-approval-id="${approval.id}">Approve call</button> | ||
| 167 | <button class="danger" data-action="deny" data-approval-id="${approval.id}">Deny call</button> | ||
| 168 | </div> | ||
| 169 | </article> | ||
| 170 | `; | ||
| 171 | }) | ||
| 172 | .join(""); | ||
| 173 | } | ||
| 174 | |||
| 175 | function renderTasks(): void { | ||
| 176 | if (state.tasks.length === 0) { | ||
| 177 | els.tasksList.innerHTML = '<p class="empty">No tasks yet.</p>'; | ||
| 178 | return; | ||
| 179 | } | ||
| 180 | |||
| 181 | els.tasksList.innerHTML = state.tasks | ||
| 182 | .map((task) => { | ||
| 183 | const selected = task.id === state.selectedTaskId ? "selected" : ""; | ||
| 184 | return ` | ||
| 185 | <button class="task-row ${selected}" data-task-id="${task.id}"> | ||
| 186 | <span>${escapeHtml(task.id)}</span> | ||
| 187 | <span>${escapeHtml(task.runtimeId)}</span> | ||
| 188 | <span class="${statusClass(task.status)}">${labelStatus(task.status)}</span> | ||
| 189 | <span>${fmtTime(task.createdAt)}</span> | ||
| 190 | </button> | ||
| 191 | `; | ||
| 192 | }) | ||
| 193 | .join(""); | ||
| 194 | } | ||
| 195 | |||
| 196 | function renderTaskDetail(): void { | ||
| 197 | const task = state.tasks.find((entry) => entry.id === state.selectedTaskId); | ||
| 198 | if (!task) { | ||
| 199 | els.taskDetail.classList.add("empty"); | ||
| 200 | els.taskDetail.textContent = "Select a task to inspect logs."; | ||
| 201 | return; | ||
| 202 | } | ||
| 203 | |||
| 204 | els.taskDetail.classList.remove("empty"); | ||
| 205 | els.taskDetail.innerHTML = ` | ||
| 206 | <div class="detail-grid"> | ||
| 207 | <div><span>Task</span><strong>${escapeHtml(task.id)}</strong></div> | ||
| 208 | <div><span>Status</span><strong>${escapeHtml(labelStatus(task.status))}</strong></div> | ||
| 209 | <div><span>Runtime</span><strong>${escapeHtml(task.runtimeId)}</strong></div> | ||
| 210 | <div><span>Exit code</span><strong>${task.exitCode ?? "-"}</strong></div> | ||
| 211 | </div> | ||
| 212 | <h3>Code</h3> | ||
| 213 | <pre>${escapeHtml(task.code)}</pre> | ||
| 214 | <h3>Stdout</h3> | ||
| 215 | <pre>${escapeHtml(task.stdout ?? "")}</pre> | ||
| 216 | <h3>Stderr</h3> | ||
| 217 | <pre>${escapeHtml(task.stderr ?? "")}</pre> | ||
| 218 | ${task.error ? `<h3>Error</h3><pre>${escapeHtml(task.error)}</pre>` : ""} | ||
| 219 | `; | ||
| 220 | } | ||
| 221 | |||
| 222 | function renderAll(): void { | ||
| 223 | renderMetrics(); | ||
| 224 | renderRuntimeOptions(); | ||
| 225 | renderApprovals(); | ||
| 226 | renderTasks(); | ||
| 227 | renderTaskDetail(); | ||
| 228 | } | ||
| 229 | |||
| 230 | async function refreshData(options?: { keepSelection?: boolean }): Promise<void> { | ||
| 231 | const [approvals, tasks, runtimes] = await Promise.all([ | ||
| 232 | requestJson<PendingApproval[]>("/api/approvals?status=pending"), | ||
| 233 | requestJson<TaskRecord[]>("/api/tasks"), | ||
| 234 | requestJson<RuntimeTarget[]>("/api/runtime-targets"), | ||
| 235 | ]); | ||
| 236 | |||
| 237 | state.approvals = approvals; | ||
| 238 | state.tasks = tasks; | ||
| 239 | state.runtimes = runtimes; | ||
| 240 | |||
| 241 | const selectedStillExists = state.tasks.some((task) => task.id === state.selectedTaskId); | ||
| 242 | if (!selectedStillExists || !options?.keepSelection) { | ||
| 243 | state.selectedTaskId = state.tasks[0]?.id ?? null; | ||
| 244 | connectToSelectedTask(state.selectedTaskId); | ||
| 245 | } | ||
| 246 | |||
| 247 | renderAll(); | ||
| 248 | } | ||
| 249 | |||
| 250 | async function createTask(): Promise<void> { | ||
| 251 | await requestJson("/api/tasks", { | ||
| 252 | method: "POST", | ||
| 253 | headers: { "content-type": "application/json" }, | ||
| 254 | body: JSON.stringify({ | ||
| 255 | code: els.code.value, | ||
| 256 | timeoutMs: Number(els.timeoutInput.value), | ||
| 257 | runtimeId: els.runtimeSelect.value, | ||
| 258 | }), | ||
| 259 | }); | ||
| 260 | |||
| 261 | await refreshData(); | ||
| 262 | } | ||
| 263 | |||
| 264 | async function resolveApproval(approvalId: string, decision: "approved" | "denied"): Promise<void> { | ||
| 265 | await requestJson(`/api/approvals/${encodeURIComponent(approvalId)}`, { | ||
| 266 | method: "POST", | ||
| 267 | headers: { "content-type": "application/json" }, | ||
| 268 | body: JSON.stringify({ decision, reviewerId: "web-ui" }), | ||
| 269 | }); | ||
| 270 | await refreshData({ keepSelection: true }); | ||
| 271 | } | ||
| 272 | |||
| 273 | els.form.addEventListener("submit", (event) => { | ||
| 274 | event.preventDefault(); | ||
| 275 | void createTask().catch((cause) => { | ||
| 276 | console.error(cause); | ||
| 277 | window.alert(`Failed to create task: ${String(cause)}`); | ||
| 278 | }); | ||
| 279 | }); | ||
| 280 | |||
| 281 | els.approvalsList.addEventListener("click", (event) => { | ||
| 282 | const target = event.target; | ||
| 283 | if (!(target instanceof HTMLElement)) return; | ||
| 284 | |||
| 285 | const action = target.dataset.action; | ||
| 286 | const approvalId = target.dataset.approvalId; | ||
| 287 | if (!action || !approvalId) return; | ||
| 288 | |||
| 289 | const decision = action === "approve" ? "approved" : "denied"; | ||
| 290 | void resolveApproval(approvalId, decision).catch((cause) => { | ||
| 291 | console.error(cause); | ||
| 292 | window.alert(`Failed to resolve approval: ${String(cause)}`); | ||
| 293 | }); | ||
| 294 | }); | ||
| 295 | |||
| 296 | els.tasksList.addEventListener("click", (event) => { | ||
| 297 | const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button.task-row"); | ||
| 298 | if (!button) return; | ||
| 299 | state.selectedTaskId = button.dataset.taskId ?? null; | ||
| 300 | connectToSelectedTask(state.selectedTaskId); | ||
| 301 | renderTasks(); | ||
| 302 | renderTaskDetail(); | ||
| 303 | }); | ||
| 304 | |||
| 305 | els.refreshButton.addEventListener("click", () => { | ||
| 306 | void refreshData({ keepSelection: true }); | ||
| 307 | }); | ||
| 308 | |||
| 309 | void refreshData().catch((cause) => { | ||
| 310 | console.error(cause); | ||
| 311 | window.alert(`Failed to load executor dashboard: ${String(cause)}`); | ||
| 312 | }); | ||
| 313 | |||
| 314 | setInterval(() => { | ||
| 315 | void refreshData({ keepSelection: true }); | ||
| 316 | }, 5_000); | ||
| @@ -0,0 +1,539 @@ | |||
| 1 | :root { | ||
| 2 | --bg-top: #f3efe7; | ||
| 3 | --bg-bottom: #d7ddd8; | ||
| 4 | --panel: rgba(255, 253, 248, 0.86); | ||
| 5 | --panel-strong: rgba(255, 253, 248, 0.94); | ||
| 6 | --text: #1e2628; | ||
| 7 | --muted: #5f6f72; | ||
| 8 | --line: rgba(34, 53, 57, 0.16); | ||
| 9 | --line-strong: rgba(34, 53, 57, 0.28); | ||
| 10 | --ok: #2f8554; | ||
| 11 | --warn: #a96a1e; | ||
| 12 | --bad: #b03d35; | ||
| 13 | --chip: #0f5b66; | ||
| 14 | --radius: 18px; | ||
| 15 | --shadow: 0 18px 48px rgba(26, 38, 40, 0.16); | ||
| 16 | } | ||
| 17 | |||
| 18 | * { | ||
| 19 | box-sizing: border-box; | ||
| 20 | } | ||
| 21 | |||
| 22 | body { | ||
| 23 | margin: 0; | ||
| 24 | min-height: 100vh; | ||
| 25 | font-family: "Avenir Next", "IBM Plex Sans", "Segoe UI", sans-serif; | ||
| 26 | color: var(--text); | ||
| 27 | background: | ||
| 28 | linear-gradient(165deg, var(--bg-top), var(--bg-bottom)); | ||
| 29 | overflow-x: hidden; | ||
| 30 | } | ||
| 31 | |||
| 32 | .background-glow { | ||
| 33 | position: fixed; | ||
| 34 | z-index: 0; | ||
| 35 | pointer-events: none; | ||
| 36 | border-radius: 999px; | ||
| 37 | filter: blur(44px); | ||
| 38 | } | ||
| 39 | |||
| 40 | .glow-one { | ||
| 41 | top: -120px; | ||
| 42 | left: -90px; | ||
| 43 | width: 340px; | ||
| 44 | height: 340px; | ||
| 45 | background: radial-gradient(circle, rgba(241, 173, 96, 0.42), rgba(241, 173, 96, 0)); | ||
| 46 | } | ||
| 47 | |||
| 48 | .glow-two { | ||
| 49 | right: -110px; | ||
| 50 | top: 45vh; | ||
| 51 | width: 300px; | ||
| 52 | height: 300px; | ||
| 53 | background: radial-gradient(circle, rgba(81, 146, 163, 0.34), rgba(81, 146, 163, 0)); | ||
| 54 | } | ||
| 55 | |||
| 56 | .grain-overlay { | ||
| 57 | position: fixed; | ||
| 58 | inset: 0; | ||
| 59 | pointer-events: none; | ||
| 60 | z-index: 0; | ||
| 61 | opacity: 0.2; | ||
| 62 | background-image: | ||
| 63 | radial-gradient(rgba(17, 31, 34, 0.22) 0.48px, transparent 0.48px); | ||
| 64 | background-size: 3px 3px; | ||
| 65 | } | ||
| 66 | |||
| 67 | .layout { | ||
| 68 | position: relative; | ||
| 69 | z-index: 1; | ||
| 70 | width: min(1160px, calc(100% - 2rem)); | ||
| 71 | margin: 1.6rem auto 2.8rem; | ||
| 72 | display: grid; | ||
| 73 | gap: 1rem; | ||
| 74 | grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.95fr); | ||
| 75 | animation: page-enter 420ms ease-out; | ||
| 76 | } | ||
| 77 | |||
| 78 | .hero, | ||
| 79 | .panel { | ||
| 80 | border: 1px solid var(--line); | ||
| 81 | border-radius: var(--radius); | ||
| 82 | background: var(--panel); | ||
| 83 | box-shadow: var(--shadow); | ||
| 84 | backdrop-filter: blur(11px); | ||
| 85 | } | ||
| 86 | |||
| 87 | .hero { | ||
| 88 | grid-column: 1 / -1; | ||
| 89 | padding: 1.3rem 1.35rem; | ||
| 90 | } | ||
| 91 | |||
| 92 | .eyebrow { | ||
| 93 | margin: 0; | ||
| 94 | text-transform: uppercase; | ||
| 95 | letter-spacing: 0.09em; | ||
| 96 | font-weight: 700; | ||
| 97 | font-size: 0.73rem; | ||
| 98 | color: var(--warn); | ||
| 99 | } | ||
| 100 | |||
| 101 | .hero h1 { | ||
| 102 | margin: 0.2rem 0 0; | ||
| 103 | font-family: "Iowan Old Style", "Palatino Linotype", serif; | ||
| 104 | font-size: clamp(1.7rem, 3.8vw, 2.55rem); | ||
| 105 | letter-spacing: -0.02em; | ||
| 106 | line-height: 1.1; | ||
| 107 | } | ||
| 108 | |||
| 109 | .hero > p { | ||
| 110 | margin: 0.45rem 0 0; | ||
| 111 | color: var(--muted); | ||
| 112 | max-width: 74ch; | ||
| 113 | } | ||
| 114 | |||
| 115 | .hero-metrics { | ||
| 116 | margin-top: 0.95rem; | ||
| 117 | display: grid; | ||
| 118 | grid-template-columns: repeat(3, minmax(0, 1fr)); | ||
| 119 | gap: 0.6rem; | ||
| 120 | } | ||
| 121 | |||
| 122 | .metric-card { | ||
| 123 | border: 1px solid var(--line); | ||
| 124 | border-radius: 14px; | ||
| 125 | padding: 0.65rem 0.7rem; | ||
| 126 | background: linear-gradient(180deg, var(--panel-strong), rgba(255, 253, 248, 0.72)); | ||
| 127 | display: grid; | ||
| 128 | gap: 0.2rem; | ||
| 129 | } | ||
| 130 | |||
| 131 | .metric-card span { | ||
| 132 | font-size: 0.77rem; | ||
| 133 | color: var(--muted); | ||
| 134 | } | ||
| 135 | |||
| 136 | .metric-card strong { | ||
| 137 | font-size: 1.34rem; | ||
| 138 | line-height: 1; | ||
| 139 | } | ||
| 140 | |||
| 141 | .panel { | ||
| 142 | padding: 0.92rem; | ||
| 143 | } | ||
| 144 | |||
| 145 | .composer-panel { | ||
| 146 | grid-column: 1 / -1; | ||
| 147 | } | ||
| 148 | |||
| 149 | .section-title { | ||
| 150 | display: flex; | ||
| 151 | justify-content: space-between; | ||
| 152 | align-items: center; | ||
| 153 | gap: 0.55rem; | ||
| 154 | } | ||
| 155 | |||
| 156 | .section-note { | ||
| 157 | margin: 0; | ||
| 158 | font-size: 0.78rem; | ||
| 159 | color: var(--muted); | ||
| 160 | text-align: right; | ||
| 161 | } | ||
| 162 | |||
| 163 | h2 { | ||
| 164 | margin: 0; | ||
| 165 | font-family: "Iowan Old Style", "Palatino Linotype", serif; | ||
| 166 | font-size: 1.16rem; | ||
| 167 | letter-spacing: -0.01em; | ||
| 168 | } | ||
| 169 | |||
| 170 | form { | ||
| 171 | margin-top: 0.7rem; | ||
| 172 | display: grid; | ||
| 173 | gap: 0.78rem; | ||
| 174 | } | ||
| 175 | |||
| 176 | .form-top-grid { | ||
| 177 | display: grid; | ||
| 178 | grid-template-columns: 1fr 0.52fr; | ||
| 179 | gap: 0.65rem; | ||
| 180 | } | ||
| 181 | |||
| 182 | label { | ||
| 183 | display: grid; | ||
| 184 | gap: 0.35rem; | ||
| 185 | font-size: 0.85rem; | ||
| 186 | } | ||
| 187 | |||
| 188 | input, | ||
| 189 | textarea, | ||
| 190 | select, | ||
| 191 | button { | ||
| 192 | font: inherit; | ||
| 193 | } | ||
| 194 | |||
| 195 | input, | ||
| 196 | textarea, | ||
| 197 | select { | ||
| 198 | border: 1px solid var(--line); | ||
| 199 | border-radius: 12px; | ||
| 200 | background: rgba(255, 255, 255, 0.86); | ||
| 201 | color: var(--text); | ||
| 202 | padding: 0.58rem 0.66rem; | ||
| 203 | transition: border-color 120ms ease, box-shadow 120ms ease; | ||
| 204 | } | ||
| 205 | |||
| 206 | input:focus, | ||
| 207 | textarea:focus, | ||
| 208 | select:focus { | ||
| 209 | outline: none; | ||
| 210 | border-color: rgba(15, 91, 102, 0.6); | ||
| 211 | box-shadow: 0 0 0 3px rgba(15, 91, 102, 0.14); | ||
| 212 | } | ||
| 213 | |||
| 214 | textarea, | ||
| 215 | pre { | ||
| 216 | font-family: "IBM Plex Mono", "Fira Code", monospace; | ||
| 217 | font-size: 0.84rem; | ||
| 218 | } | ||
| 219 | |||
| 220 | pre { | ||
| 221 | white-space: pre-wrap; | ||
| 222 | word-break: break-word; | ||
| 223 | } | ||
| 224 | |||
| 225 | textarea { | ||
| 226 | min-height: 230px; | ||
| 227 | } | ||
| 228 | |||
| 229 | .composer-actions { | ||
| 230 | display: flex; | ||
| 231 | justify-content: flex-end; | ||
| 232 | } | ||
| 233 | |||
| 234 | button { | ||
| 235 | border: none; | ||
| 236 | border-radius: 999px; | ||
| 237 | padding: 0.5rem 0.95rem; | ||
| 238 | color: #f8fbfc; | ||
| 239 | background: linear-gradient(120deg, var(--chip), #16707b); | ||
| 240 | cursor: pointer; | ||
| 241 | transition: transform 140ms ease, filter 140ms ease; | ||
| 242 | } | ||
| 243 | |||
| 244 | button:hover { | ||
| 245 | transform: translateY(-1px); | ||
| 246 | filter: saturate(1.06); | ||
| 247 | } | ||
| 248 | |||
| 249 | button.secondary { | ||
| 250 | background: rgba(15, 91, 102, 0.14); | ||
| 251 | color: var(--text); | ||
| 252 | } | ||
| 253 | |||
| 254 | button.danger { | ||
| 255 | background: linear-gradient(120deg, #b03d35, #ca4d44); | ||
| 256 | } | ||
| 257 | |||
| 258 | .pill { | ||
| 259 | border-radius: 999px; | ||
| 260 | padding: 0.22rem 0.56rem; | ||
| 261 | font-size: 0.75rem; | ||
| 262 | background: rgba(15, 91, 102, 0.12); | ||
| 263 | border: 1px solid rgba(15, 91, 102, 0.18); | ||
| 264 | } | ||
| 265 | |||
| 266 | .approval-list, | ||
| 267 | .task-list { | ||
| 268 | margin-top: 0.72rem; | ||
| 269 | display: grid; | ||
| 270 | gap: 0.62rem; | ||
| 271 | } | ||
| 272 | |||
| 273 | .approval-card { | ||
| 274 | border: 1px solid var(--line); | ||
| 275 | border-radius: 14px; | ||
| 276 | padding: 0.68rem; | ||
| 277 | background: rgba(255, 255, 255, 0.82); | ||
| 278 | } | ||
| 279 | |||
| 280 | .approval-card header { | ||
| 281 | display: flex; | ||
| 282 | justify-content: space-between; | ||
| 283 | gap: 0.6rem; | ||
| 284 | font-size: 0.84rem; | ||
| 285 | } | ||
| 286 | |||
| 287 | .approval-card .meta { | ||
| 288 | margin: 0.24rem 0 0.5rem; | ||
| 289 | color: var(--muted); | ||
| 290 | font-size: 0.78rem; | ||
| 291 | } | ||
| 292 | |||
| 293 | .approval-card pre { | ||
| 294 | margin: 0; | ||
| 295 | max-height: 180px; | ||
| 296 | overflow: auto; | ||
| 297 | border: 1px solid var(--line); | ||
| 298 | border-radius: 10px; | ||
| 299 | padding: 0.58rem; | ||
| 300 | background: rgba(17, 40, 45, 0.92); | ||
| 301 | color: #d8ebee; | ||
| 302 | } | ||
| 303 | |||
| 304 | .actions { | ||
| 305 | margin-top: 0.52rem; | ||
| 306 | display: flex; | ||
| 307 | flex-wrap: wrap; | ||
| 308 | gap: 0.4rem; | ||
| 309 | } | ||
| 310 | |||
| 311 | .task-row { | ||
| 312 | border: 1px solid var(--line); | ||
| 313 | border-radius: 12px; | ||
| 314 | padding: 0.6rem 0.68rem; | ||
| 315 | background: rgba(255, 255, 255, 0.84); | ||
| 316 | color: inherit; | ||
| 317 | display: grid; | ||
| 318 | grid-template-columns: minmax(0, 1.6fr) minmax(0, 0.8fr) auto auto; | ||
| 319 | align-items: center; | ||
| 320 | gap: 0.52rem; | ||
| 321 | text-align: left; | ||
| 322 | } | ||
| 323 | |||
| 324 | .task-row.selected { | ||
| 325 | border-color: var(--line-strong); | ||
| 326 | box-shadow: 0 0 0 2px rgba(15, 91, 102, 0.16); | ||
| 327 | } | ||
| 328 | |||
| 329 | .task-row > span { | ||
| 330 | overflow: hidden; | ||
| 331 | text-overflow: ellipsis; | ||
| 332 | white-space: nowrap; | ||
| 333 | } | ||
| 334 | |||
| 335 | .status { | ||
| 336 | border-radius: 999px; | ||
| 337 | width: fit-content; | ||
| 338 | padding: 0.16rem 0.45rem; | ||
| 339 | font-size: 0.74rem; | ||
| 340 | text-transform: capitalize; | ||
| 341 | } | ||
| 342 | |||
| 343 | .status-completed { | ||
| 344 | background: rgba(47, 133, 84, 0.16); | ||
| 345 | color: var(--ok); | ||
| 346 | } | ||
| 347 | |||
| 348 | .status-failed, | ||
| 349 | .status-denied { | ||
| 350 | background: rgba(176, 61, 53, 0.16); | ||
| 351 | color: var(--bad); | ||
| 352 | } | ||
| 353 | |||
| 354 | .status-queued, | ||
| 355 | .status-running, | ||
| 356 | .status-timed-out { | ||
| 357 | background: rgba(169, 106, 30, 0.16); | ||
| 358 | color: var(--warn); | ||
| 359 | } | ||
| 360 | |||
| 361 | .task-detail { | ||
| 362 | margin-top: 0.7rem; | ||
| 363 | display: grid; | ||
| 364 | gap: 0.68rem; | ||
| 365 | } | ||
| 366 | |||
| 367 | .task-detail.empty, | ||
| 368 | .empty { | ||
| 369 | color: var(--muted); | ||
| 370 | } | ||
| 371 | |||
| 372 | .detail-grid { | ||
| 373 | display: grid; | ||
| 374 | grid-template-columns: repeat(4, minmax(0, 1fr)); | ||
| 375 | gap: 0.48rem; | ||
| 376 | } | ||
| 377 | |||
| 378 | .detail-grid div { | ||
| 379 | border: 1px solid var(--line); | ||
| 380 | border-radius: 10px; | ||
| 381 | background: rgba(255, 255, 255, 0.84); | ||
| 382 | padding: 0.46rem 0.52rem; | ||
| 383 | display: grid; | ||
| 384 | gap: 0.18rem; | ||
| 385 | } | ||
| 386 | |||
| 387 | .detail-grid span { | ||
| 388 | color: var(--muted); | ||
| 389 | font-size: 0.75rem; | ||
| 390 | } | ||
| 391 | |||
| 392 | .task-detail h3 { | ||
| 393 | margin: 0.1rem 0 0; | ||
| 394 | font-size: 0.84rem; | ||
| 395 | letter-spacing: 0.02em; | ||
| 396 | text-transform: uppercase; | ||
| 397 | color: var(--muted); | ||
| 398 | } | ||
| 399 | |||
| 400 | .task-detail pre { | ||
| 401 | margin: 0; | ||
| 402 | max-height: 220px; | ||
| 403 | overflow: auto; | ||
| 404 | border-radius: 12px; | ||
| 405 | border: 1px solid var(--line); | ||
| 406 | padding: 0.7rem; | ||
| 407 | background: rgba(17, 40, 45, 0.94); | ||
| 408 | color: #d8ebee; | ||
| 409 | } | ||
| 410 | |||
| 411 | code { | ||
| 412 | font-family: "IBM Plex Mono", "Fira Code", monospace; | ||
| 413 | font-size: 0.8em; | ||
| 414 | } | ||
| 415 | |||
| 416 | @keyframes page-enter { | ||
| 417 | from { | ||
| 418 | opacity: 0; | ||
| 419 | transform: translateY(8px); | ||
| 420 | } | ||
| 421 | to { | ||
| 422 | opacity: 1; | ||
| 423 | transform: translateY(0); | ||
| 424 | } | ||
| 425 | } | ||
| 426 | |||
| 427 | @media (max-width: 980px) { | ||
| 428 | .layout { | ||
| 429 | grid-template-columns: 1fr; | ||
| 430 | } | ||
| 431 | |||
| 432 | .section-title { | ||
| 433 | align-items: flex-start; | ||
| 434 | flex-direction: column; | ||
| 435 | } | ||
| 436 | |||
| 437 | .section-note { | ||
| 438 | text-align: left; | ||
| 439 | } | ||
| 440 | |||
| 441 | .detail-grid { | ||
| 442 | grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| 443 | } | ||
| 444 | } | ||
| 445 | |||
| 446 | @media (max-width: 700px) { | ||
| 447 | .layout { | ||
| 448 | width: calc(100% - 0.9rem); | ||
| 449 | margin-top: 0.62rem; | ||
| 450 | gap: 0.72rem; | ||
| 451 | } | ||
| 452 | |||
| 453 | .hero, | ||
| 454 | .panel { | ||
| 455 | padding: 0.78rem; | ||
| 456 | border-radius: 14px; | ||
| 457 | } | ||
| 458 | |||
| 459 | .hero h1 { | ||
| 460 | font-size: 1.45rem; | ||
| 461 | } | ||
| 462 | |||
| 463 | .hero > p { | ||
| 464 | font-size: 0.9rem; | ||
| 465 | } | ||
| 466 | |||
| 467 | .hero-metrics { | ||
| 468 | grid-template-columns: 1fr; | ||
| 469 | } | ||
| 470 | |||
| 471 | .metric-card strong { | ||
| 472 | font-size: 1.12rem; | ||
| 473 | } | ||
| 474 | |||
| 475 | h2 { | ||
| 476 | font-size: 1rem; | ||
| 477 | } | ||
| 478 | |||
| 479 | label { | ||
| 480 | font-size: 0.8rem; | ||
| 481 | } | ||
| 482 | |||
| 483 | .form-top-grid { | ||
| 484 | grid-template-columns: 1fr; | ||
| 485 | } | ||
| 486 | |||
| 487 | textarea { | ||
| 488 | min-height: 148px; | ||
| 489 | font-size: 0.78rem; | ||
| 490 | } | ||
| 491 | |||
| 492 | .task-row { | ||
| 493 | grid-template-columns: 1fr; | ||
| 494 | gap: 0.3rem; | ||
| 495 | } | ||
| 496 | |||
| 497 | .task-row > span { | ||
| 498 | white-space: normal; | ||
| 499 | overflow: visible; | ||
| 500 | text-overflow: clip; | ||
| 501 | word-break: break-word; | ||
| 502 | } | ||
| 503 | |||
| 504 | .detail-grid { | ||
| 505 | grid-template-columns: 1fr; | ||
| 506 | } | ||
| 507 | |||
| 508 | .task-detail pre, | ||
| 509 | .approval-card pre { | ||
| 510 | max-height: 160px; | ||
| 511 | font-size: 0.76rem; | ||
| 512 | } | ||
| 513 | } | ||
| 514 | |||
| 515 | @media (max-width: 460px) { | ||
| 516 | .layout { | ||
| 517 | width: calc(100% - 0.55rem); | ||
| 518 | margin-top: 0.45rem; | ||
| 519 | } | ||
| 520 | |||
| 521 | .hero, | ||
| 522 | .panel { | ||
| 523 | padding: 0.66rem; | ||
| 524 | } | ||
| 525 | |||
| 526 | .hero h1 { | ||
| 527 | font-size: 1.3rem; | ||
| 528 | } | ||
| 529 | |||
| 530 | .hero > p, | ||
| 531 | .section-note { | ||
| 532 | font-size: 0.82rem; | ||
| 533 | } | ||
| 534 | |||
| 535 | button { | ||
| 536 | padding: 0.42rem 0.78rem; | ||
| 537 | font-size: 0.84rem; | ||
| 538 | } | ||
| 539 | } | ||
| @@ -0,0 +1,92 @@ | |||
| 1 | { | ||
| 2 | "lockfileVersion": 1, | ||
| 3 | "configVersion": 1, | ||
| 4 | "workspaces": { | ||
| 5 | "": { | ||
| 6 | "name": "executor-monorepo", | ||
| 7 | "devDependencies": { | ||
| 8 | "@types/bun": "latest", | ||
| 9 | "typescript": "latest", | ||
| 10 | }, | ||
| 11 | }, | ||
| 12 | "apps/server": { | ||
| 13 | "name": "@executor/server", | ||
| 14 | "dependencies": { | ||
| 15 | "@vercel/sandbox": "latest", | ||
| 16 | }, | ||
| 17 | "devDependencies": { | ||
| 18 | "@types/bun": "latest", | ||
| 19 | "typescript": "latest", | ||
| 20 | }, | ||
| 21 | }, | ||
| 22 | "apps/web": { | ||
| 23 | "name": "@executor/web", | ||
| 24 | }, | ||
| 25 | "packages/client": { | ||
| 26 | "name": "@executor/client", | ||
| 27 | "dependencies": { | ||
| 28 | "@executor/contracts": "workspace:*", | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | "packages/contracts": { | ||
| 32 | "name": "@executor/contracts", | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | "packages": { | ||
| 36 | "@executor/client": ["@executor/client@workspace:packages/client"], | ||
| 37 | |||
| 38 | "@executor/contracts": ["@executor/contracts@workspace:packages/contracts"], | ||
| 39 | |||
| 40 | "@executor/server": ["@executor/server@workspace:apps/server"], | ||
| 41 | |||
| 42 | "@executor/web": ["@executor/web@workspace:apps/web"], | ||
| 43 | |||
| 44 | "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], | ||
| 45 | |||
| 46 | "@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="], | ||
| 47 | |||
| 48 | "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], | ||
| 49 | |||
| 50 | "@vercel/sandbox": ["@vercel/sandbox@1.4.1", "", { "dependencies": { "@vercel/oidc": "^3.1.0", "async-retry": "1.3.3", "jsonlines": "0.1.1", "ms": "2.1.3", "picocolors": "^1.1.1", "tar-stream": "3.1.7", "undici": "^7.16.0", "xdg-app-paths": "5.1.0", "zod": "3.24.4" } }, "sha512-5jaNLv6QJ0112ZFhCv9hQDCV3IYogpbEn5LzMcY5E8TZsf5tF0avat2tBe7McOJvgVs0SDkuzjvGjUMKtTkjrA=="], | ||
| 51 | |||
| 52 | "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], | ||
| 53 | |||
| 54 | "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], | ||
| 55 | |||
| 56 | "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], | ||
| 57 | |||
| 58 | "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], | ||
| 59 | |||
| 60 | "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], | ||
| 61 | |||
| 62 | "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], | ||
| 63 | |||
| 64 | "jsonlines": ["jsonlines@0.1.1", "", {}, "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA=="], | ||
| 65 | |||
| 66 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | ||
| 67 | |||
| 68 | "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], | ||
| 69 | |||
| 70 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], | ||
| 71 | |||
| 72 | "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], | ||
| 73 | |||
| 74 | "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], | ||
| 75 | |||
| 76 | "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], | ||
| 77 | |||
| 78 | "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], | ||
| 79 | |||
| 80 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], | ||
| 81 | |||
| 82 | "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], | ||
| 83 | |||
| 84 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | ||
| 85 | |||
| 86 | "xdg-app-paths": ["xdg-app-paths@5.1.0", "", { "dependencies": { "xdg-portable": "^7.0.0" } }, "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA=="], | ||
| 87 | |||
| 88 | "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], | ||
| 89 | |||
| 90 | "zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], | ||
| 91 | } | ||
| 92 | } | ||
| @@ -0,0 +1,19 @@ | |||
| 1 | { | ||
| 2 | "name": "executor-monorepo", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "workspaces": [ | ||
| 6 | "apps/*", | ||
| 7 | "packages/*" | ||
| 8 | ], | ||
| 9 | "scripts": { | ||
| 10 | "dev": "bun run --cwd apps/server dev", | ||
| 11 | "start": "bun run --cwd apps/server start", | ||
| 12 | "test": "bun test", | ||
| 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | ||
| 14 | }, | ||
| 15 | "devDependencies": { | ||
| 16 | "@types/bun": "latest", | ||
| 17 | "typescript": "latest" | ||
| 18 | } | ||
| 19 | } | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/client", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "dependencies": { | ||
| 6 | "@executor/contracts": "workspace:*" | ||
| 7 | }, | ||
| 8 | "exports": { | ||
| 9 | ".": "./src/index.ts" | ||
| 10 | } | ||
| 11 | } | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | import type { | ||
| 2 | ApprovalRecord, | ||
| 3 | CreateTaskRequest, | ||
| 4 | CreateTaskResponse, | ||
| 5 | PendingApprovalRecord, | ||
| 6 | ResolveApprovalRequest, | ||
| 7 | RuntimeTargetDescriptor, | ||
| 8 | TaskEventRecord, | ||
| 9 | TaskRecord, | ||
| 10 | ToolDescriptor, | ||
| 11 | } from "@executor/contracts"; | ||
| 12 | |||
| 13 | function withBase(baseUrl: string, path: string): string { | ||
| 14 | return `${baseUrl.replace(/\/$/, "")}${path}`; | ||
| 15 | } | ||
| 16 | |||
| 17 | async function parseJson<T>(response: Response): Promise<T> { | ||
| 18 | if (!response.ok) { | ||
| 19 | const text = await response.text(); | ||
| 20 | throw new Error(`Request failed: ${response.status} ${response.statusText} - ${text}`); | ||
| 21 | } | ||
| 22 | return (await response.json()) as T; | ||
| 23 | } | ||
| 24 | |||
| 25 | export async function createTask( | ||
| 26 | baseUrl: string, | ||
| 27 | request: CreateTaskRequest, | ||
| 28 | ): Promise<CreateTaskResponse> { | ||
| 29 | const response = await fetch(withBase(baseUrl, "/api/tasks"), { | ||
| 30 | method: "POST", | ||
| 31 | headers: { "content-type": "application/json" }, | ||
| 32 | body: JSON.stringify(request), | ||
| 33 | }); | ||
| 34 | return parseJson<CreateTaskResponse>(response); | ||
| 35 | } | ||
| 36 | |||
| 37 | export async function listTasks(baseUrl: string): Promise<TaskRecord[]> { | ||
| 38 | const response = await fetch(withBase(baseUrl, "/api/tasks")); | ||
| 39 | return parseJson<TaskRecord[]>(response); | ||
| 40 | } | ||
| 41 | |||
| 42 | export async function getTask(baseUrl: string, taskId: string): Promise<TaskRecord> { | ||
| 43 | const response = await fetch(withBase(baseUrl, `/api/tasks/${encodeURIComponent(taskId)}`)); | ||
| 44 | return parseJson<TaskRecord>(response); | ||
| 45 | } | ||
| 46 | |||
| 47 | export async function listApprovals( | ||
| 48 | baseUrl: string, | ||
| 49 | status?: ApprovalRecord["status"], | ||
| 50 | ): Promise<ApprovalRecord[]> { | ||
| 51 | const search = status ? `?status=${encodeURIComponent(status)}` : ""; | ||
| 52 | const response = await fetch(withBase(baseUrl, `/api/approvals${search}`)); | ||
| 53 | return parseJson<ApprovalRecord[]>(response); | ||
| 54 | } | ||
| 55 | |||
| 56 | export async function listPendingApprovals(baseUrl: string): Promise<PendingApprovalRecord[]> { | ||
| 57 | const response = await fetch(withBase(baseUrl, "/api/approvals?status=pending")); | ||
| 58 | return parseJson<PendingApprovalRecord[]>(response); | ||
| 59 | } | ||
| 60 | |||
| 61 | export async function resolveApproval( | ||
| 62 | baseUrl: string, | ||
| 63 | approvalId: string, | ||
| 64 | request: ResolveApprovalRequest, | ||
| 65 | ): Promise<{ approval: ApprovalRecord; task: TaskRecord }> { | ||
| 66 | const response = await fetch(withBase(baseUrl, `/api/approvals/${encodeURIComponent(approvalId)}`), { | ||
| 67 | method: "POST", | ||
| 68 | headers: { "content-type": "application/json" }, | ||
| 69 | body: JSON.stringify(request), | ||
| 70 | }); | ||
| 71 | return parseJson<{ approval: ApprovalRecord; task: TaskRecord }>(response); | ||
| 72 | } | ||
| 73 | |||
| 74 | export async function listRuntimeTargets(baseUrl: string): Promise<RuntimeTargetDescriptor[]> { | ||
| 75 | const response = await fetch(withBase(baseUrl, "/api/runtime-targets")); | ||
| 76 | return parseJson<RuntimeTargetDescriptor[]>(response); | ||
| 77 | } | ||
| 78 | |||
| 79 | export async function listTools(baseUrl: string): Promise<ToolDescriptor[]> { | ||
| 80 | const response = await fetch(withBase(baseUrl, "/api/tools")); | ||
| 81 | return parseJson<ToolDescriptor[]>(response); | ||
| 82 | } | ||
| 83 | |||
| 84 | export function subscribeToTaskEvents( | ||
| 85 | baseUrl: string, | ||
| 86 | taskId: string, | ||
| 87 | onEvent: (eventName: TaskEventRecord["eventName"], event: TaskEventRecord) => void, | ||
| 88 | ): EventSource { | ||
| 89 | const source = new EventSource( | ||
| 90 | withBase(baseUrl, `/api/tasks/${encodeURIComponent(taskId)}/events`), | ||
| 91 | ); | ||
| 92 | |||
| 93 | source.addEventListener("task", (event) => { | ||
| 94 | const message = event as MessageEvent<string>; | ||
| 95 | onEvent("task", JSON.parse(message.data) as TaskEventRecord); | ||
| 96 | }); | ||
| 97 | |||
| 98 | source.addEventListener("approval", (event) => { | ||
| 99 | const message = event as MessageEvent<string>; | ||
| 100 | onEvent("approval", JSON.parse(message.data) as TaskEventRecord); | ||
| 101 | }); | ||
| 102 | |||
| 103 | return source; | ||
| 104 | } | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/contracts", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | } | ||
| 8 | } | ||
| @@ -0,0 +1,97 @@ | |||
| 1 | export type TaskStatus = | ||
| 2 | | "queued" | ||
| 3 | | "running" | ||
| 4 | | "completed" | ||
| 5 | | "failed" | ||
| 6 | | "timed_out" | ||
| 7 | | "denied"; | ||
| 8 | |||
| 9 | export type ApprovalStatus = "pending" | "approved" | "denied"; | ||
| 10 | export type ApprovalDecision = "approved" | "denied"; | ||
| 11 | |||
| 12 | export interface CreateTaskRequest { | ||
| 13 | code: string; | ||
| 14 | runtimeId?: string; | ||
| 15 | timeoutMs?: number; | ||
| 16 | metadata?: Record<string, unknown>; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface CreateTaskResponse { | ||
| 20 | taskId: string; | ||
| 21 | status: TaskStatus; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface TaskRecord { | ||
| 25 | id: string; | ||
| 26 | code: string; | ||
| 27 | runtimeId: string; | ||
| 28 | timeoutMs: number; | ||
| 29 | metadata: Record<string, unknown>; | ||
| 30 | status: TaskStatus; | ||
| 31 | createdAt: number; | ||
| 32 | updatedAt: number; | ||
| 33 | startedAt?: number; | ||
| 34 | completedAt?: number; | ||
| 35 | stdout?: string; | ||
| 36 | stderr?: string; | ||
| 37 | exitCode?: number; | ||
| 38 | error?: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface ApprovalRecord { | ||
| 42 | id: string; | ||
| 43 | taskId: string; | ||
| 44 | toolPath: string; | ||
| 45 | input: unknown; | ||
| 46 | status: ApprovalStatus; | ||
| 47 | createdAt: number; | ||
| 48 | resolvedAt?: number; | ||
| 49 | reviewerId?: string; | ||
| 50 | reason?: string; | ||
| 51 | } | ||
| 52 | |||
| 53 | export interface PendingApprovalRecord extends ApprovalRecord { | ||
| 54 | task: Pick<TaskRecord, "id" | "status" | "runtimeId" | "timeoutMs" | "createdAt">; | ||
| 55 | } | ||
| 56 | |||
| 57 | export interface ResolveApprovalRequest { | ||
| 58 | decision: ApprovalDecision; | ||
| 59 | reviewerId?: string; | ||
| 60 | reason?: string; | ||
| 61 | } | ||
| 62 | |||
| 63 | export interface RuntimeTargetDescriptor { | ||
| 64 | id: string; | ||
| 65 | label: string; | ||
| 66 | description: string; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface ToolDescriptor { | ||
| 70 | path: string; | ||
| 71 | description: string; | ||
| 72 | approval: "auto" | "required"; | ||
| 73 | } | ||
| 74 | |||
| 75 | export interface TaskEventRecord { | ||
| 76 | id: number; | ||
| 77 | taskId: string; | ||
| 78 | eventName: "task" | "approval"; | ||
| 79 | type: string; | ||
| 80 | payload: Record<string, unknown>; | ||
| 81 | createdAt: number; | ||
| 82 | } | ||
| 83 | |||
| 84 | export interface SandboxExecutionRequest { | ||
| 85 | taskId: string; | ||
| 86 | code: string; | ||
| 87 | timeoutMs: number; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface SandboxExecutionResult { | ||
| 91 | status: "completed" | "failed" | "timed_out" | "denied"; | ||
| 92 | stdout: string; | ||
| 93 | stderr: string; | ||
| 94 | exitCode?: number; | ||
| 95 | error?: string; | ||
| 96 | durationMs: number; | ||
| 97 | } | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ESNext", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "types": [ | ||
| 10 | "bun" | ||
| 11 | ], | ||
| 12 | "lib": [ | ||
| 13 | "ESNext", | ||
| 14 | "DOM" | ||
| 15 | ], | ||
| 16 | "baseUrl": ".", | ||
| 17 | "paths": { | ||
| 18 | "@executor/contracts": [ | ||
| 19 | "packages/contracts/src/index.ts" | ||
| 20 | ], | ||
| 21 | "@executor/client": [ | ||
| 22 | "packages/client/src/index.ts" | ||
| 23 | ] | ||
| 24 | } | ||
| 25 | }, | ||
| 26 | "include": [ | ||
| 27 | "apps/**/*.ts", | ||
| 28 | "packages/**/*.ts" | ||
| 29 | ] | ||
| 30 | } | ||
| @@ -1,52 +1,10 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "openassistant", | 2 | "name": "proto-monorepo", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "workspaces": [ | ||
| 6 | "apps/*", | ||
| 7 | "packages/*" | ||
| 8 | ], | ||
| 9 | "scripts": { | 5 | "scripts": { |
| 10 | "with-env": "bun run --env-file=.env --", | 6 | "dev:executor": "bun run --cwd executor dev", |
| 11 | "with-env:prod": "bun run --env-file=.env.production --", | 7 | "test:executor": "bun run --cwd executor test", |
| 12 | "prepare": "effect-language-service patch", | 8 | "typecheck:executor": "bun run --cwd executor typecheck" |
| 13 | "typecheck": "bun run --filter '@openassistant/core' typecheck && bun run --filter '@openassistant/reacord' typecheck && bun run --filter '@openassistant/server' typecheck && bun run --filter '@openassistant/executor' typecheck", | ||
| 14 | "typecheck:tsc": "tsc --build", | ||
| 15 | "build": "bun run typecheck", | ||
| 16 | "test": "bun test packages/ apps/", | ||
| 17 | "test:watch": "bun test --watch packages/ apps/", | ||
| 18 | "dev": "bun run dev:server & bun run dev:executor & bun run dev:bot & wait", | ||
| 19 | "dev:server": "bun run --filter '@openassistant/server' dev", | ||
| 20 | "dev:executor": "bun run --filter '@openassistant/executor' dev", | ||
| 21 | "dev:bot": "bun run --filter '@openassistant/bot' dev" | ||
| 22 | }, | ||
| 23 | "devDependencies": { | ||
| 24 | "@effect/language-service": "latest", | ||
| 25 | "@effect/vitest": "^0.27.0", | ||
| 26 | "@types/bun": "latest", | ||
| 27 | "@typescript/native-preview": "7.0.0-dev.20260205.1", | ||
| 28 | "typescript": "latest", | ||
| 29 | "vitest": "^3.2.0" | ||
| 30 | }, | ||
| 31 | "overrides": { | ||
| 32 | "@ai-sdk/anthropic": "^2.0.0", | ||
| 33 | "@effect/platform": "latest", | ||
| 34 | "@effect/platform-bun": "latest", | ||
| 35 | "@effect/rpc": "latest", | ||
| 36 | "@effect-atom/atom": "^0.4.11", | ||
| 37 | "@effect-atom/atom-react": "^0.4.4", | ||
| 38 | "@types/react": "^19.0.0", | ||
| 39 | "@types/react-reconciler": "^0.31.0", | ||
| 40 | "ai": "^6.0.73", | ||
| 41 | "discord.js": "^14.17.3", | ||
| 42 | "effect": "latest", | ||
| 43 | "react": "^19.0.0", | ||
| 44 | "react-reconciler": "^0.31.0", | ||
| 45 | "zod": "^4.3.6" | ||
| 46 | }, | ||
| 47 | "dependencies": { | ||
| 48 | "@apidevtools/swagger-parser": "^12.1.0", | ||
| 49 | "@modelcontextprotocol/sdk": "^1.26.0", | ||
| 50 | "yaml": "^2.8.2" | ||
| 51 | } | 9 | } |
| 52 | } | 10 | } |