History

Reorganize into assistant/executor monorepos with sandbox-ready executor

Rhys Sullivan committed 9 months ago

Commit adf27f11 parent 3adab5a124 files · +3834 −71Browse files

Unified · Split

124 files
  1. README.md +12 −25
  2. assistant/README.md +6 −0
  3. assistant/legacy/openassistant/NEW_PLAN.md +0 −0
  4. assistant/legacy/openassistant/README.md +35 −0
  5. assistant/legacy/openassistant/apps/bot/package.json +0 −0
  6. assistant/legacy/openassistant/apps/bot/src/commands/ask.tsx +0 −0
  7. assistant/legacy/openassistant/apps/bot/src/index.ts +0 −0
  8. assistant/legacy/openassistant/apps/bot/src/views/task-message.tsx +0 −0
  9. assistant/legacy/openassistant/apps/bot/tsconfig.json +0 −0
  10. assistant/legacy/openassistant/apps/executor/package.json +0 −0
  11. assistant/legacy/openassistant/apps/executor/src/index.ts +0 −0
  12. assistant/legacy/openassistant/apps/executor/src/smoke.test.ts +0 −0
  13. assistant/legacy/openassistant/apps/executor/tsconfig.json +0 −0
  14. assistant/legacy/openassistant/apps/server/package.json +0 −0
  15. assistant/legacy/openassistant/apps/server/src/client.test.ts +0 −0
  16. assistant/legacy/openassistant/apps/server/src/client.ts +0 −0
  17. assistant/legacy/openassistant/apps/server/src/index.ts +0 −0
  18. assistant/legacy/openassistant/apps/server/src/routes.test.ts +0 −0
  19. assistant/legacy/openassistant/apps/server/src/routes.ts +0 −0
  20. assistant/legacy/openassistant/apps/server/src/state.ts +0 −0
  21. assistant/legacy/openassistant/apps/server/src/task-runner.ts +0 −0
  22. assistant/legacy/openassistant/apps/server/tsconfig.json +0 −0
  23. assistant/legacy/openassistant/bun.lock +0 −0
  24. assistant/legacy/openassistant/index.ts +0 −0
  25. assistant/legacy/openassistant/package.json +52 −0
  26. assistant/legacy/openassistant/packages/core/package.json +0 −0
  27. assistant/legacy/openassistant/packages/core/src/agent.test.ts +0 −0
  28. assistant/legacy/openassistant/packages/core/src/agent.ts +0 −0
  29. assistant/legacy/openassistant/packages/core/src/discovery.ts +0 −0
  30. assistant/legacy/openassistant/packages/core/src/events.ts +0 −0
  31. assistant/legacy/openassistant/packages/core/src/index.ts +0 −0
  32. assistant/legacy/openassistant/packages/core/src/loop.test.ts +0 −0
  33. assistant/legacy/openassistant/packages/core/src/pi-ai-adapter.ts +0 −0
  34. assistant/legacy/openassistant/packages/core/src/remote-runner.test.ts +0 −0
  35. assistant/legacy/openassistant/packages/core/src/remote-runner.ts +0 −0
  36. assistant/legacy/openassistant/packages/core/src/runner.test.ts +0 −0
  37. assistant/legacy/openassistant/packages/core/src/runner.ts +0 −0
  38. assistant/legacy/openassistant/packages/core/src/tools.test.ts +0 −0
  39. assistant/legacy/openassistant/packages/core/src/tools.ts +0 −0
  40. assistant/legacy/openassistant/packages/core/src/typechecker.test.ts +0 −0
  41. assistant/legacy/openassistant/packages/core/src/typechecker.ts +0 −0
  42. assistant/legacy/openassistant/packages/core/tsconfig.json +0 −0
  43. assistant/legacy/openassistant/packages/reacord/package.json +0 −0
  44. assistant/legacy/openassistant/packages/reacord/src/components/action-row.tsx +0 −0
  45. assistant/legacy/openassistant/packages/reacord/src/components/attachment.tsx +0 −0
  46. assistant/legacy/openassistant/packages/reacord/src/components/button.tsx +0 −0
  47. assistant/legacy/openassistant/packages/reacord/src/components/container.tsx +0 −0
  48. assistant/legacy/openassistant/packages/reacord/src/components/file.tsx +0 −0
  49. assistant/legacy/openassistant/packages/reacord/src/components/link.tsx +0 −0
  50. assistant/legacy/openassistant/packages/reacord/src/components/loading-select.tsx +0 −0
  51. assistant/legacy/openassistant/packages/reacord/src/components/loading.tsx +0 −0
  52. assistant/legacy/openassistant/packages/reacord/src/components/media-gallery.tsx +0 −0
  53. assistant/legacy/openassistant/packages/reacord/src/components/modal-button.tsx +0 −0
  54. assistant/legacy/openassistant/packages/reacord/src/components/option.tsx +0 −0
  55. assistant/legacy/openassistant/packages/reacord/src/components/section.tsx +0 −0
  56. assistant/legacy/openassistant/packages/reacord/src/components/select.tsx +0 −0
  57. assistant/legacy/openassistant/packages/reacord/src/components/separator.tsx +0 −0
  58. assistant/legacy/openassistant/packages/reacord/src/components/text-display.tsx +0 −0
  59. assistant/legacy/openassistant/packages/reacord/src/components/thumbnail.tsx +0 −0
  60. assistant/legacy/openassistant/packages/reacord/src/components/user-select.tsx +0 −0
  61. assistant/legacy/openassistant/packages/reacord/src/create-component.tsx +0 −0
  62. assistant/legacy/openassistant/packages/reacord/src/index.ts +0 −0
  63. assistant/legacy/openassistant/packages/reacord/src/instance-context.tsx +0 −0
  64. assistant/legacy/openassistant/packages/reacord/src/instance.ts +0 −0
  65. assistant/legacy/openassistant/packages/reacord/src/internal/container.ts +0 −0
  66. assistant/legacy/openassistant/packages/reacord/src/internal/element.ts +0 −0
  67. assistant/legacy/openassistant/packages/reacord/src/internal/errors.ts +0 −0
  68. assistant/legacy/openassistant/packages/reacord/src/internal/helpers.ts +0 −0
  69. assistant/legacy/openassistant/packages/reacord/src/internal/interaction.ts +0 −0
  70. assistant/legacy/openassistant/packages/reacord/src/internal/message.ts +0 −0
  71. assistant/legacy/openassistant/packages/reacord/src/internal/node.ts +0 −0
  72. assistant/legacy/openassistant/packages/reacord/src/internal/reconciler.ts +0 −0
  73. assistant/legacy/openassistant/packages/reacord/src/internal/renderer.ts +0 −0
  74. assistant/legacy/openassistant/packages/reacord/src/internal/text-node.ts +0 −0
  75. assistant/legacy/openassistant/packages/reacord/src/reacord.tsx +0 −0
  76. assistant/legacy/openassistant/packages/reacord/tsconfig.json +0 −0
  77. assistant/legacy/openassistant/packages/tool-gen/package.json +0 −0
  78. assistant/legacy/openassistant/packages/tool-gen/src/e2e.test.ts +0 −0
  79. assistant/legacy/openassistant/packages/tool-gen/src/index.ts +0 −0
  80. assistant/legacy/openassistant/packages/tool-gen/src/integration.test.ts +0 −0
  81. assistant/legacy/openassistant/packages/tool-gen/src/json-schema-to-ts.test.ts +0 −0
  82. assistant/legacy/openassistant/packages/tool-gen/src/json-schema-to-ts.ts +0 −0
  83. assistant/legacy/openassistant/packages/tool-gen/src/mcp.test.ts +0 −0
  84. assistant/legacy/openassistant/packages/tool-gen/src/mcp.ts +0 −0
  85. assistant/legacy/openassistant/packages/tool-gen/src/openapi-integration.test.ts +0 −0
  86. assistant/legacy/openassistant/packages/tool-gen/src/openapi.test.ts +0 −0
  87. assistant/legacy/openassistant/packages/tool-gen/src/openapi.ts +0 −0
  88. assistant/legacy/openassistant/packages/tool-gen/tsconfig.json +0 −0
  89. assistant/legacy/openassistant/tsconfig.base.json +0 −0
  90. assistant/legacy/openassistant/tsconfig.json +0 −0
  91. assistant/legacy/openassistant/vitest.config.ts +0 −0
  92. assistant/package.json +15 −0
  93. assistant/packages/agent-executor-adapter/package.json +14 −0
  94. assistant/packages/agent-executor-adapter/src/index.ts +58 −0
  95. executor/.gitignore +10 −0
  96. executor/README.md +63 −0
  97. executor/apps/server/package.json +18 −0
  98. executor/apps/server/src/adapters/http-execution-adapter.ts +76 −0
  99. executor/apps/server/src/adapters/in-process-execution-adapter.ts +56 −0
  100. executor/apps/server/src/database.ts +370 −0
  101. executor/apps/server/src/events.ts +49 −0
  102. executor/apps/server/src/execution-constants.ts +2 −0
  103. executor/apps/server/src/index.ts +315 −0
  104. executor/apps/server/src/runtimes/local-bun-runtime.ts +20 −0
  105. executor/apps/server/src/runtimes/runtime-core.ts +178 −0
  106. executor/apps/server/src/runtimes/vercel-sandbox-runtime.ts +312 −0
  107. executor/apps/server/src/service.test.ts +93 −0
  108. executor/apps/server/src/service.ts +438 −0
  109. executor/apps/server/src/tailscale-funnel.ts +116 −0
  110. executor/apps/server/src/tools.ts +65 −0
  111. executor/apps/server/src/types.ts +123 −0
  112. executor/apps/server/tsconfig.json +16 −0
  113. executor/apps/web/index.html +97 −0
  114. executor/apps/web/package.json +5 −0
  115. executor/apps/web/src/main.ts +316 −0
  116. executor/apps/web/src/styles.css +539 −0
  117. executor/bun.lock +92 −0
  118. executor/package.json +19 −0
  119. executor/packages/client/package.json +11 −0
  120. executor/packages/client/src/index.ts +104 −0
  121. executor/packages/contracts/package.json +8 −0
  122. executor/packages/contracts/src/index.ts +97 −0
  123. executor/tsconfig.json +30 −0
  124. package.json +4 −46
README.md+12−25
@@ -1,35 +1,22 @@
1# OpenAssistant1# Prototype Monorepo
2 2
3## Install3This repository is now split into two nested monorepos:
4 4
5```bash5- `assistant/`: assistant-side code and integrations.
6bun install6- `executor/`: executor infrastructure (sandbox runtime, approvals API, task history, web UI).
7```
8
9## Local MVP (remote executor mode)
10 7
11Start the executor (private code runner):8## Focus Area
12 9
13```bash10The active prototype work is in `executor/`.
14bun run --filter '@openassistant/executor' dev
15```
16
17Start the server (agent + approvals + tool broker):
18
19```bash
20OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 \
21OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 \
22bun run --filter '@openassistant/server' dev
23```
24 11
25Or run all services together:12## Quick Start
26 13
27```bash14```bash
28bun run dev15bun install --cwd executor
16bun run --cwd executor dev
29```17```
30 18
31## Notes19This 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.
assistant/README.md+6−0
@@ -0,0 +1,6 @@
1# Assistant Monorepo
2
3Assistant-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.
assistant/legacy/openassistant/NEW_PLAN.mdrenamed from NEW_PLAN.md+0−0

File renamed without changes.

assistant/legacy/openassistant/README.md+35−0
@@ -0,0 +1,35 @@
1# OpenAssistant
2
3## Install
4
5```bash
6bun install
7```
8
9## Local MVP (remote executor mode)
10
11Start the executor (private code runner):
12
13```bash
14bun run --filter '@openassistant/executor' dev
15```
16
17Start the server (agent + approvals + tool broker):
18
19```bash
20OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 \
21OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 \
22bun run --filter '@openassistant/server' dev
23```
24
25Or run all services together:
26
27```bash
28bun 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.
assistant/legacy/openassistant/apps/bot/package.jsonrenamed from apps/bot/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/bot/src/commands/ask.tsxrenamed from apps/bot/src/commands/ask.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/bot/src/index.tsrenamed from apps/bot/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/bot/src/views/task-message.tsxrenamed from apps/bot/src/views/task-message.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/bot/tsconfig.jsonrenamed from apps/bot/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/executor/package.jsonrenamed from apps/executor/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/executor/src/index.tsrenamed from apps/executor/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/executor/src/smoke.test.tsrenamed from apps/executor/src/smoke.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/executor/tsconfig.jsonrenamed from apps/executor/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/package.jsonrenamed from apps/server/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/client.test.tsrenamed from apps/server/src/client.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/client.tsrenamed from apps/server/src/client.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/index.tsrenamed from apps/server/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/routes.test.tsrenamed from apps/server/src/routes.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/routes.tsrenamed from apps/server/src/routes.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/state.tsrenamed from apps/server/src/state.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/src/task-runner.tsrenamed from apps/server/src/task-runner.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/apps/server/tsconfig.jsonrenamed from apps/server/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/bun.lockrenamed from bun.lock+0−0

File renamed without changes.

assistant/legacy/openassistant/index.tsrenamed from index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/package.json+52−0
@@ -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}
assistant/legacy/openassistant/packages/core/package.jsonrenamed from packages/core/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/agent.test.tsrenamed from packages/core/src/agent.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/agent.tsrenamed from packages/core/src/agent.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/discovery.tsrenamed from packages/core/src/discovery.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/events.tsrenamed from packages/core/src/events.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/index.tsrenamed from packages/core/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/loop.test.tsrenamed from packages/core/src/loop.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/pi-ai-adapter.tsrenamed from packages/core/src/pi-ai-adapter.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/remote-runner.test.tsrenamed from packages/core/src/remote-runner.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/remote-runner.tsrenamed from packages/core/src/remote-runner.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/runner.test.tsrenamed from packages/core/src/runner.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/runner.tsrenamed from packages/core/src/runner.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/tools.test.tsrenamed from packages/core/src/tools.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/tools.tsrenamed from packages/core/src/tools.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/typechecker.test.tsrenamed from packages/core/src/typechecker.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/src/typechecker.tsrenamed from packages/core/src/typechecker.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/core/tsconfig.jsonrenamed from packages/core/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/package.jsonrenamed from packages/reacord/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/action-row.tsxrenamed from packages/reacord/src/components/action-row.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/attachment.tsxrenamed from packages/reacord/src/components/attachment.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/button.tsxrenamed from packages/reacord/src/components/button.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/container.tsxrenamed from packages/reacord/src/components/container.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/file.tsxrenamed from packages/reacord/src/components/file.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/link.tsxrenamed from packages/reacord/src/components/link.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/loading-select.tsxrenamed from packages/reacord/src/components/loading-select.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/loading.tsxrenamed from packages/reacord/src/components/loading.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/media-gallery.tsxrenamed from packages/reacord/src/components/media-gallery.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/modal-button.tsxrenamed from packages/reacord/src/components/modal-button.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/option.tsxrenamed from packages/reacord/src/components/option.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/section.tsxrenamed from packages/reacord/src/components/section.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/select.tsxrenamed from packages/reacord/src/components/select.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/separator.tsxrenamed from packages/reacord/src/components/separator.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/text-display.tsxrenamed from packages/reacord/src/components/text-display.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/thumbnail.tsxrenamed from packages/reacord/src/components/thumbnail.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/components/user-select.tsxrenamed from packages/reacord/src/components/user-select.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/create-component.tsxrenamed from packages/reacord/src/create-component.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/index.tsrenamed from packages/reacord/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/instance-context.tsxrenamed from packages/reacord/src/instance-context.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/instance.tsrenamed from packages/reacord/src/instance.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/container.tsrenamed from packages/reacord/src/internal/container.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/element.tsrenamed from packages/reacord/src/internal/element.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/errors.tsrenamed from packages/reacord/src/internal/errors.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/helpers.tsrenamed from packages/reacord/src/internal/helpers.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/interaction.tsrenamed from packages/reacord/src/internal/interaction.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/message.tsrenamed from packages/reacord/src/internal/message.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/node.tsrenamed from packages/reacord/src/internal/node.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/reconciler.tsrenamed from packages/reacord/src/internal/reconciler.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/renderer.tsrenamed from packages/reacord/src/internal/renderer.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/internal/text-node.tsrenamed from packages/reacord/src/internal/text-node.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/src/reacord.tsxrenamed from packages/reacord/src/reacord.tsx+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/reacord/tsconfig.jsonrenamed from packages/reacord/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/package.jsonrenamed from packages/tool-gen/package.json+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/e2e.test.tsrenamed from packages/tool-gen/src/e2e.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/index.tsrenamed from packages/tool-gen/src/index.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/integration.test.tsrenamed from packages/tool-gen/src/integration.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/json-schema-to-ts.test.tsrenamed from packages/tool-gen/src/json-schema-to-ts.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/json-schema-to-ts.tsrenamed from packages/tool-gen/src/json-schema-to-ts.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/mcp.test.tsrenamed from packages/tool-gen/src/mcp.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/mcp.tsrenamed from packages/tool-gen/src/mcp.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/openapi-integration.test.tsrenamed from packages/tool-gen/src/openapi-integration.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/openapi.test.tsrenamed from packages/tool-gen/src/openapi.test.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/src/openapi.tsrenamed from packages/tool-gen/src/openapi.ts+0−0

File renamed without changes.

assistant/legacy/openassistant/packages/tool-gen/tsconfig.jsonrenamed from packages/tool-gen/tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/tsconfig.base.jsonrenamed from tsconfig.base.json+0−0

File renamed without changes.

assistant/legacy/openassistant/tsconfig.jsonrenamed from tsconfig.json+0−0

File renamed without changes.

assistant/legacy/openassistant/vitest.config.tsrenamed from vitest.config.ts+0−0

File renamed without changes.

assistant/package.json+15−0
@@ -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}
assistant/packages/agent-executor-adapter/package.json+14−0
@@ -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}
assistant/packages/agent-executor-adapter/src/index.ts+58−0
@@ -0,0 +1,58 @@
1export interface RunCodeRequest {
2 code: string;
3 timeoutMs?: number;
4 runtimeId?: string;
5}
6
7export interface RunCodeResponse {
8 taskId: string;
9}
10
11export 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
29export 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}
executor/.gitignore+10−0
@@ -0,0 +1,10 @@
1node_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
executor/README.md+63−0
@@ -0,0 +1,63 @@
1# Executor Monorepo
2
3Prototype 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
33This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow.
34
35Local setup:
36
37```bash
38vercel project add <project-name>
39vercel link --yes --project <project-name>
40vercel env pull .env.local --yes
41```
42
43Required 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
48Optional:
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
54When creating tasks, set `runtimeId` to `vercel-sandbox` to use this backend.
55
56## Run
57
58```bash
59bun install
60bun run dev
61```
62
63Server defaults to `http://localhost:4001`.
executor/apps/server/package.json+18−0
@@ -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}
executor/apps/server/src/adapters/http-execution-adapter.ts+76−0
@@ -0,0 +1,76 @@
1import type {
2 ExecutionAdapter,
3 RuntimeOutputEvent,
4 ToolCallRequest,
5 ToolCallResult,
6} from "../types";
7
8interface HttpExecutionAdapterOptions {
9 controlPlaneBaseUrl: string;
10 runId: string;
11 token?: string;
12}
13
14function withBase(baseUrl: string, path: string): string {
15 return `${baseUrl.replace(/\/$/, "")}${path}`;
16}
17
18export 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}
executor/apps/server/src/adapters/in-process-execution-adapter.ts+56−0
@@ -0,0 +1,56 @@
1import { APPROVAL_DENIED_PREFIX } from "../execution-constants";
2import type {
3 ExecutionAdapter,
4 RuntimeOutputEvent,
5 ToolCallRequest,
6 ToolCallResult,
7} from "../types";
8
9interface InProcessExecutionAdapterOptions {
10 runId: string;
11 invokeTool: (call: ToolCallRequest) => Promise<unknown>;
12 emitOutput: (event: RuntimeOutputEvent) => void;
13}
14
15function describeError(error: unknown): string {
16 return error instanceof Error ? error.message : String(error);
17}
18
19export 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}
executor/apps/server/src/database.ts+370−0
@@ -0,0 +1,370 @@
1import { Database } from "bun:sqlite";
2import type {
3 ApprovalRecord,
4 ApprovalStatus,
5 PendingApprovalRecord,
6 TaskEventRecord,
7 TaskRecord,
8 TaskStatus,
9} from "./types";
10
11const DEFAULT_TIMEOUT_MS = 15_000;
12
13function 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
25function parseMetadata(value: unknown): Record<string, unknown> {
26 const parsed = parseJson(value);
27 return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
28}
29
30function optionalNumber(value: unknown): number | undefined {
31 return typeof value === "number" ? value : undefined;
32}
33
34function optionalString(value: unknown): string | undefined {
35 return typeof value === "string" ? value : undefined;
36}
37
38function 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
57function 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
71function 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
83export 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}
executor/apps/server/src/events.ts+49−0
@@ -0,0 +1,49 @@
1export interface LiveTaskEvent {
2 id: number;
3 eventName: string;
4 payload: unknown;
5 createdAt: number;
6}
7
8type Subscriber = (event: LiveTaskEvent) => void;
9
10export 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}
executor/apps/server/src/execution-constants.ts+2−0
@@ -0,0 +1,2 @@
1export const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
2export const TASK_TIMEOUT_MARKER = "TASK_TIMEOUT";
executor/apps/server/src/index.ts+315−0
@@ -0,0 +1,315 @@
1import webApp from "../../web/index.html";
2import { ExecutorDatabase } from "./database";
3import { TaskEventHub } from "./events";
4import { LocalBunRuntime } from "./runtimes/local-bun-runtime";
5import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime";
6import { ExecutorService, getTaskTerminalState } from "./service";
7import { ensureTailscaleFunnel } from "./tailscale-funnel";
8import { DEFAULT_TOOLS } from "./tools";
9import type {
10 ApprovalStatus,
11 CreateTaskInput,
12 PendingApprovalRecord,
13 TaskStatus,
14 ToolCallRequest,
15 RuntimeOutputEvent,
16} from "./types";
17
18const port = Number(Bun.env.PORT ?? "4001");
19const autoFunnelEnabled = Bun.env.EXECUTOR_AUTO_TAILSCALE_FUNNEL !== "0";
20const explicitInternalBaseUrl = Bun.env.EXECUTOR_INTERNAL_BASE_URL ?? Bun.env.EXECUTOR_PUBLIC_BASE_URL;
21const generatedInternalToken = Bun.env.EXECUTOR_INTERNAL_TOKEN ?? `executor_internal_${crypto.randomUUID()}`;
22const internalToken = generatedInternalToken;
23
24let internalBaseUrl = explicitInternalBaseUrl ?? `http://127.0.0.1:${port}`;
25let internalBaseSource = explicitInternalBaseUrl ? "env" : "localhost-default";
26
27if (!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
38const 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
47const 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
54function json(data: unknown, status = 200): Response {
55 return new Response(JSON.stringify(data), {
56 status,
57 headers: jsonHeaders,
58 });
59}
60
61function error(status: number, message: string): Response {
62 return json({ error: message }, status);
63}
64
65function 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
78async 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
86function 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
155const 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
313console.log(`executor server listening on http://localhost:${server.port}`);
314console.log(`[executor] internal callback base: ${internalBaseUrl} (${internalBaseSource})`);
315console.log(`[executor] internal callback auth token enabled: yes`);
executor/apps/server/src/runtimes/local-bun-runtime.ts+20−0
@@ -0,0 +1,20 @@
1import { runCodeWithAdapter } from "./runtime-core";
2import type {
3 ExecutionAdapter,
4 SandboxExecutionRequest,
5 SandboxExecutionResult,
6 SandboxRuntime,
7} from "../types";
8
9export 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}
executor/apps/server/src/runtimes/runtime-core.ts+178−0
@@ -0,0 +1,178 @@
1import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";
2import type {
3 ExecutionAdapter,
4 SandboxExecutionRequest,
5 SandboxExecutionResult,
6} from "../types";
7
8function 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
21function 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
27function 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
66export 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}
executor/apps/server/src/runtimes/vercel-sandbox-runtime.ts+312−0
@@ -0,0 +1,312 @@
1import { Sandbox } from "@vercel/sandbox";
2import type {
3 ExecutionAdapter,
4 SandboxExecutionRequest,
5 SandboxExecutionResult,
6 SandboxRuntime,
7} from "../types";
8
9const RESULT_MARKER = "__EXECUTOR_RESULT__";
10
11interface VercelSandboxRuntimeOptions {
12 controlPlaneBaseUrl: string;
13 internalToken?: string;
14 runtime?: "node24" | "node22";
15}
16
17function stripTrailingSlash(value: string): string {
18 return value.replace(/\/$/, "");
19}
20
21function buildRunnerScript(codeFilePath: string): string {
22 return `
23import { readFile } from "node:fs/promises";
24import { randomUUID } from "node:crypto";
25
26const RESULT_MARKER = ${JSON.stringify(RESULT_MARKER)};
27const runId = process.env.EXECUTOR_RUN_ID;
28const baseUrl = process.env.EXECUTOR_INTERNAL_BASE_URL;
29const token = process.env.EXECUTOR_INTERNAL_TOKEN || "";
30const requestTimeoutMs = Number(process.env.EXECUTOR_REQUEST_TIMEOUT_MS || "15000");
31
32if (!runId) {
33 throw new Error("Missing EXECUTOR_RUN_ID");
34}
35
36if (!baseUrl) {
37 throw new Error("Missing EXECUTOR_INTERNAL_BASE_URL");
38}
39
40const userCode = await readFile(${JSON.stringify(codeFilePath)}, "utf8");
41const startedAt = Date.now();
42const stdoutLines = [];
43const stderrLines = [];
44
45function 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
56async 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
86function emitOutput(stream, line) {
87 return callInternal("/internal/runs/" + encodeURIComponent(runId) + "/output", {
88 stream,
89 line,
90 timestamp: Date.now(),
91 });
92}
93
94function appendStdout(line) {
95 stdoutLines.push(line);
96 void emitOutput("stdout", line);
97}
98
99function appendStderr(line) {
100 stderrLines.push(line);
101 void emitOutput("stderr", line);
102}
103
104function 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
140const tools = createToolsProxy();
141const 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
148const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
149const runner = new AsyncFunction("tools", "console", '"use strict";\\n' + userCode);
150
151const timeoutPromise = new Promise((_, reject) => {
152 setTimeout(() => reject(new Error("TASK_TIMEOUT")), requestTimeoutMs);
153});
154
155let result;
156try {
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
204process.stdout.write(RESULT_MARKER + JSON.stringify(result) + "\\n");
205`;
206}
207
208function 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
222export 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}
executor/apps/server/src/service.test.ts+93−0
@@ -0,0 +1,93 @@
1import { expect, test } from "bun:test";
2import { ExecutorDatabase } from "./database";
3import { TaskEventHub } from "./events";
4import { ExecutorService } from "./service";
5import type {
6 ExecutionAdapter,
7 SandboxExecutionRequest,
8 SandboxExecutionResult,
9 SandboxRuntime,
10 ToolDefinition,
11} from "./types";
12
13class 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
57const 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
66test("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});
executor/apps/server/src/service.ts+438−0
@@ -0,0 +1,438 @@
1import { ExecutorDatabase } from "./database";
2import { TaskEventHub, type LiveTaskEvent } from "./events";
3import { InProcessExecutionAdapter } from "./adapters/in-process-execution-adapter";
4import { APPROVAL_DENIED_PREFIX } from "./execution-constants";
5import 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
21interface ApprovalWaiter {
22 resolve: (decision: Exclude<ApprovalStatus, "pending">) => void;
23}
24
25function createTaskId(): string {
26 return `task_${crypto.randomUUID()}`;
27}
28
29function createApprovalId(): string {
30 return `approval_${crypto.randomUUID()}`;
31}
32
33function 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
40function describeError(error: unknown): string {
41 return error instanceof Error ? error.message : String(error);
42}
43
44export 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
436export function getTaskTerminalState(status: TaskStatus): boolean {
437 return status === "completed" || status === "failed" || status === "timed_out" || status === "denied";
438}
executor/apps/server/src/tailscale-funnel.ts+116−0
@@ -0,0 +1,116 @@
1interface CommandResult {
2 ok: boolean;
3 exitCode: number;
4 stdout: string;
5 stderr: string;
6}
7
8interface FunnelStatusEntry {
9 Handlers?: Record<string, { Proxy?: string }>;
10}
11
12interface FunnelStatus {
13 Web?: Record<string, FunnelStatusEntry>;
14 AllowFunnel?: Record<string, boolean>;
15}
16
17function 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
35function normalizeHostname(hostnamePort: string): string {
36 return hostnamePort.endsWith(":443") ? hostnamePort.slice(0, -4) : hostnamePort;
37}
38
39function 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
72function 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
86export interface EnsureFunnelResult {
87 url: string;
88 created: boolean;
89}
90
91export 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}
executor/apps/server/src/tools.ts+65−0
@@ -0,0 +1,65 @@
1import type { ToolDefinition } from "./types";
2
3function asObject(value: unknown): Record<string, unknown> {
4 return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
5}
6
7export 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];
executor/apps/server/src/types.ts+123−0
@@ -0,0 +1,123 @@
1export type TaskStatus =
2 | "queued"
3 | "running"
4 | "completed"
5 | "failed"
6 | "timed_out"
7 | "denied";
8
9export type ApprovalStatus = "pending" | "approved" | "denied";
10
11export 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
28export 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
40export interface PendingApprovalRecord extends ApprovalRecord {
41 task: Pick<TaskRecord, "id" | "status" | "runtimeId" | "timeoutMs" | "createdAt">;
42}
43
44export interface TaskEventRecord {
45 id: number;
46 taskId: string;
47 eventName: string;
48 type: string;
49 payload: unknown;
50 createdAt: number;
51}
52
53export interface CreateTaskInput {
54 code: string;
55 timeoutMs?: number;
56 runtimeId?: string;
57 metadata?: Record<string, unknown>;
58}
59
60export interface SandboxExecutionRequest {
61 taskId: string;
62 code: string;
63 timeoutMs: number;
64}
65
66export 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
75export interface ToolCallRequest {
76 runId: string;
77 callId: string;
78 toolPath: string;
79 input: unknown;
80}
81
82export type ToolCallResult =
83 | { ok: true; value: unknown }
84 | { ok: false; error: string; denied?: boolean };
85
86export type RuntimeOutputStream = "stdout" | "stderr";
87
88export interface RuntimeOutputEvent {
89 runId: string;
90 stream: RuntimeOutputStream;
91 line: string;
92 timestamp: number;
93}
94
95export interface ExecutionAdapter {
96 invokeTool(call: ToolCallRequest): Promise<ToolCallResult>;
97 emitOutput(event: RuntimeOutputEvent): void | Promise<void>;
98}
99
100export interface SandboxRuntime {
101 id: string;
102 label: string;
103 description: string;
104 run(
105 request: SandboxExecutionRequest,
106 adapter: ExecutionAdapter,
107 ): Promise<SandboxExecutionResult>;
108}
109
110export type ToolApprovalMode = "auto" | "required";
111
112export interface ToolDefinition {
113 path: string;
114 description: string;
115 approval: ToolApprovalMode;
116 run(input: unknown): Promise<unknown>;
117}
118
119export interface ToolDescriptor {
120 path: string;
121 description: string;
122 approval: ToolApprovalMode;
123}
executor/apps/server/tsconfig.json+16−0
@@ -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}
executor/apps/web/index.html+97−0
@@ -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({});
57console.log("time", now.iso);
58
59await tools.admin.send_announcement({
60 channel: "ops",
61 message: "Deploy candidate ready for review"
62});
63
64const sum = await tools.math.add({ a: 2, b: 5 });
65console.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>
executor/apps/web/package.json+5−0
@@ -0,0 +1,5 @@
1{
2 "name": "@executor/web",
3 "private": true,
4 "type": "module"
5}
executor/apps/web/src/main.ts+316−0
@@ -0,0 +1,316 @@
1type TaskStatus =
2 | "queued"
3 | "running"
4 | "completed"
5 | "failed"
6 | "timed_out"
7 | "denied";
8
9interface 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
24interface 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
39interface RuntimeTarget {
40 id: string;
41 label: string;
42 description: string;
43}
44
45const state = {
46 tasks: [] as TaskRecord[],
47 approvals: [] as PendingApproval[],
48 runtimes: [] as RuntimeTarget[],
49 selectedTaskId: null as string | null,
50};
51
52const 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
67let selectedTaskStream: EventSource | null = null;
68
69async 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
78function escapeHtml(value: string): string {
79 return value
80 .replaceAll("&", "&amp;")
81 .replaceAll("<", "&lt;")
82 .replaceAll(">", "&gt;")
83 .replaceAll('"', "&quot;")
84 .replaceAll("'", "&#39;");
85}
86
87function prettyJson(value: unknown): string {
88 try {
89 return JSON.stringify(value, null, 2);
90 } catch {
91 return String(value);
92 }
93}
94
95function fmtTime(timestamp?: number): string {
96 if (!timestamp) return "-";
97 return new Date(timestamp).toLocaleTimeString();
98}
99
100function statusClass(status: TaskStatus): string {
101 return `status status-${status.replaceAll("_", "-")}`;
102}
103
104function labelStatus(status: TaskStatus): string {
105 return status.replaceAll("_", " ");
106}
107
108function 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
126function 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
141function renderRuntimeOptions(): void {
142 els.runtimeSelect.innerHTML = state.runtimes
143 .map((runtime) => `<option value="${runtime.id}">${escapeHtml(runtime.label)}</option>`)
144 .join("");
145}
146
147function 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
175function 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
196function 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
222function renderAll(): void {
223 renderMetrics();
224 renderRuntimeOptions();
225 renderApprovals();
226 renderTasks();
227 renderTaskDetail();
228}
229
230async 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
250async 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
264async 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
273els.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
281els.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
296els.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
305els.refreshButton.addEventListener("click", () => {
306 void refreshData({ keepSelection: true });
307});
308
309void refreshData().catch((cause) => {
310 console.error(cause);
311 window.alert(`Failed to load executor dashboard: ${String(cause)}`);
312});
313
314setInterval(() => {
315 void refreshData({ keepSelection: true });
316}, 5_000);
executor/apps/web/src/styles.css+539−0
@@ -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
22body {
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
163h2 {
164 margin: 0;
165 font-family: "Iowan Old Style", "Palatino Linotype", serif;
166 font-size: 1.16rem;
167 letter-spacing: -0.01em;
168}
169
170form {
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
182label {
183 display: grid;
184 gap: 0.35rem;
185 font-size: 0.85rem;
186}
187
188input,
189textarea,
190select,
191button {
192 font: inherit;
193}
194
195input,
196textarea,
197select {
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
206input:focus,
207textarea:focus,
208select: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
214textarea,
215pre {
216 font-family: "IBM Plex Mono", "Fira Code", monospace;
217 font-size: 0.84rem;
218}
219
220pre {
221 white-space: pre-wrap;
222 word-break: break-word;
223}
224
225textarea {
226 min-height: 230px;
227}
228
229.composer-actions {
230 display: flex;
231 justify-content: flex-end;
232}
233
234button {
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
244button:hover {
245 transform: translateY(-1px);
246 filter: saturate(1.06);
247}
248
249button.secondary {
250 background: rgba(15, 91, 102, 0.14);
251 color: var(--text);
252}
253
254button.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
411code {
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}
executor/bun.lock+92−0
@@ -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}
executor/package.json+19−0
@@ -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}
executor/packages/client/package.json+11−0
@@ -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}
executor/packages/client/src/index.ts+104−0
@@ -0,0 +1,104 @@
1import type {
2 ApprovalRecord,
3 CreateTaskRequest,
4 CreateTaskResponse,
5 PendingApprovalRecord,
6 ResolveApprovalRequest,
7 RuntimeTargetDescriptor,
8 TaskEventRecord,
9 TaskRecord,
10 ToolDescriptor,
11} from "@executor/contracts";
12
13function withBase(baseUrl: string, path: string): string {
14 return `${baseUrl.replace(/\/$/, "")}${path}`;
15}
16
17async 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
25export 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
37export async function listTasks(baseUrl: string): Promise<TaskRecord[]> {
38 const response = await fetch(withBase(baseUrl, "/api/tasks"));
39 return parseJson<TaskRecord[]>(response);
40}
41
42export 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
47export 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
56export 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
61export 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
74export async function listRuntimeTargets(baseUrl: string): Promise<RuntimeTargetDescriptor[]> {
75 const response = await fetch(withBase(baseUrl, "/api/runtime-targets"));
76 return parseJson<RuntimeTargetDescriptor[]>(response);
77}
78
79export async function listTools(baseUrl: string): Promise<ToolDescriptor[]> {
80 const response = await fetch(withBase(baseUrl, "/api/tools"));
81 return parseJson<ToolDescriptor[]>(response);
82}
83
84export 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}
executor/packages/contracts/package.json+8−0
@@ -0,0 +1,8 @@
1{
2 "name": "@executor/contracts",
3 "private": true,
4 "type": "module",
5 "exports": {
6 ".": "./src/index.ts"
7 }
8}
executor/packages/contracts/src/index.ts+97−0
@@ -0,0 +1,97 @@
1export type TaskStatus =
2 | "queued"
3 | "running"
4 | "completed"
5 | "failed"
6 | "timed_out"
7 | "denied";
8
9export type ApprovalStatus = "pending" | "approved" | "denied";
10export type ApprovalDecision = "approved" | "denied";
11
12export interface CreateTaskRequest {
13 code: string;
14 runtimeId?: string;
15 timeoutMs?: number;
16 metadata?: Record<string, unknown>;
17}
18
19export interface CreateTaskResponse {
20 taskId: string;
21 status: TaskStatus;
22}
23
24export 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
41export 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
53export interface PendingApprovalRecord extends ApprovalRecord {
54 task: Pick<TaskRecord, "id" | "status" | "runtimeId" | "timeoutMs" | "createdAt">;
55}
56
57export interface ResolveApprovalRequest {
58 decision: ApprovalDecision;
59 reviewerId?: string;
60 reason?: string;
61}
62
63export interface RuntimeTargetDescriptor {
64 id: string;
65 label: string;
66 description: string;
67}
68
69export interface ToolDescriptor {
70 path: string;
71 description: string;
72 approval: "auto" | "required";
73}
74
75export 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
84export interface SandboxExecutionRequest {
85 taskId: string;
86 code: string;
87 timeoutMs: number;
88}
89
90export 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}
executor/tsconfig.json+30−0
@@ -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}
package.json+4−46
@@ -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}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t