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

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