From 30063810aa3f2cf78e53a64ab8f99153c62e9e6a Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 17:31:41 -0400 Subject: [PATCH 001/348] =?UTF-8?q?feat:=20workspace=20daemon=20scaffold?= =?UTF-8?q?=20=E2=80=94=20Express+SQLite+orchestrator+adapters+routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 19 TypeScript files, 2071 lines total - SQLite schema: projects, phases, missions, tasks, task_runs, checkpoints, agents, activity_log - Orchestrator: poll-dispatch loop with retry, concurrency, reconciliation - Agent adapters: Codex (app-server stdio), Claude (CLI), OpenClaw (gateway) - REST API: full CRUD for projects/tasks/missions/agents/checkpoints + SSE events - WORKFLOW.md parser: YAML frontmatter + prompt template rendering - tsc clean, server starts on configurable port --- .../.data/workspace-daemon.sqlite | Bin 0 -> 4096 bytes .../.data/workspace-daemon.sqlite-shm | Bin 0 -> 32768 bytes .../.data/workspace-daemon.sqlite-wal | Bin 0 -> 267832 bytes workspace-daemon/BUILD-TASK.md | 293 +++ workspace-daemon/package-lock.json | 2003 +++++++++++++++++ workspace-daemon/package.json | 24 + workspace-daemon/src/adapters/claude.ts | 95 + workspace-daemon/src/adapters/codex.ts | 146 ++ workspace-daemon/src/adapters/openclaw.ts | 110 + workspace-daemon/src/adapters/types.ts | 11 + workspace-daemon/src/agent-runner.ts | 88 + workspace-daemon/src/config.ts | 159 ++ workspace-daemon/src/db/index.ts | 42 + workspace-daemon/src/db/schema.sql | 128 ++ workspace-daemon/src/orchestrator.ts | 234 ++ workspace-daemon/src/routes/agents.ts | 46 + workspace-daemon/src/routes/checkpoints.ts | 40 + workspace-daemon/src/routes/events.ts | 61 + workspace-daemon/src/routes/missions.ts | 47 + workspace-daemon/src/routes/projects.ts | 54 + workspace-daemon/src/routes/tasks.ts | 66 + workspace-daemon/src/server.ts | 46 + workspace-daemon/src/tracker.ts | 459 ++++ workspace-daemon/src/types.ts | 308 +++ workspace-daemon/src/workspace.ts | 59 + workspace-daemon/tsconfig.json | 14 + 26 files changed, 4533 insertions(+) create mode 100644 workspace-daemon/.data/workspace-daemon.sqlite create mode 100644 workspace-daemon/.data/workspace-daemon.sqlite-shm create mode 100644 workspace-daemon/.data/workspace-daemon.sqlite-wal create mode 100644 workspace-daemon/BUILD-TASK.md create mode 100644 workspace-daemon/package-lock.json create mode 100644 workspace-daemon/package.json create mode 100644 workspace-daemon/src/adapters/claude.ts create mode 100644 workspace-daemon/src/adapters/codex.ts create mode 100644 workspace-daemon/src/adapters/openclaw.ts create mode 100644 workspace-daemon/src/adapters/types.ts create mode 100644 workspace-daemon/src/agent-runner.ts create mode 100644 workspace-daemon/src/config.ts create mode 100644 workspace-daemon/src/db/index.ts create mode 100644 workspace-daemon/src/db/schema.sql create mode 100644 workspace-daemon/src/orchestrator.ts create mode 100644 workspace-daemon/src/routes/agents.ts create mode 100644 workspace-daemon/src/routes/checkpoints.ts create mode 100644 workspace-daemon/src/routes/events.ts create mode 100644 workspace-daemon/src/routes/missions.ts create mode 100644 workspace-daemon/src/routes/projects.ts create mode 100644 workspace-daemon/src/routes/tasks.ts create mode 100644 workspace-daemon/src/server.ts create mode 100644 workspace-daemon/src/tracker.ts create mode 100644 workspace-daemon/src/types.ts create mode 100644 workspace-daemon/src/workspace.ts create mode 100644 workspace-daemon/tsconfig.json diff --git a/workspace-daemon/.data/workspace-daemon.sqlite b/workspace-daemon/.data/workspace-daemon.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..db7a7459cf0d014b0dc2333abb5541c58c2d6ed1 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYBBV;st3JAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*g{}s{ literal 0 HcmV?d00001 diff --git a/workspace-daemon/.data/workspace-daemon.sqlite-shm b/workspace-daemon/.data/workspace-daemon.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..ed85cef2599170af9e966a18c16c99df93a84ae4 GIT binary patch literal 32768 zcmeI*%PzxF5XbRRmr_O1R^94e_xqhv-o)Ch(TOai1A#Wb`<`Him_2#lSo2ljU&o*<*Ywv3BLm_|w0tg_0 z00IagfB*srAbzP0^>3nPMAcKH0hp+v}AO1L{2wD6m{Ffh?HeQW@Jtl zWkuFyOLpZzs)@b@hySI()L$MJ^jRr64mw|q;ijb`^RgtXvLV~DCx_n~c4E}1m$_ML z^*-ja1{9cWz<2(iSGL_+R9FHFVZ})>jct^p!V>t}V;EMZWYX9UMJgfh7~8lG`2673QJ%otT+j#u`SV5SOWWD#Yr%Y?M0^$h*Q9}>{AHDDPVVCPzb~+ zU^l-|2*fGC-9mBx-$`u(1vvo%fk*}HK06A5I0fvsMGAp91?;|33V}EU?1o+nfj9+5 S#ctfD5QtI0Zd<2*BJcr{z%qCM literal 0 HcmV?d00001 diff --git a/workspace-daemon/.data/workspace-daemon.sqlite-wal b/workspace-daemon/.data/workspace-daemon.sqlite-wal new file mode 100644 index 0000000000000000000000000000000000000000..f10aa8cfca211c49f04e8debc422841edf738cf3 GIT binary patch literal 267832 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 // taskId -> run info + claimed: Set; // task IDs reserved/running/retrying + retryAttempts: Map; + completed: Set; +} +``` + +### Poll Loop +1. Query DB for tasks with status='ready' (all dependencies met) +2. Skip if task ID is in `claimed` set +3. Respect `maxConcurrentAgents` concurrency limit +4. For each eligible task: create workspace → spawn agent → track run +5. On agent completion: create checkpoint → wait for approval (or auto-approve) +6. On approval: mark task done → check if new tasks are unblocked + +### Retry Logic (from Symphony) +- On agent failure: exponential backoff (base 10s, max 5min) +- Max 3 retries per task +- On retry: reuse workspace, increment attempt counter + +### Reconciliation on Restart +- On startup: scan `task_runs` for status='running' +- If process not alive: mark as 'failed', queue retry +- No external DB needed for recovery (SQLite is the source of truth) + +## Agent Runner + +### Codex Adapter (PRIMARY) +Spawn `codex app-server` as child process, communicate via JSON-RPC over stdio: + +```typescript +// Simplified flow: +const proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: workspacePath }); +// Send: initialize → thread/start → turn/start +// Stream: item/started, item/agentMessage/delta, item/completed, turn/completed +``` + +Thread lifecycle: thread/start → turn/start with task prompt → stream events → turn/completed + +### Claude Adapter +```typescript +// Use Claude Code CLI in print mode +spawn('claude', ['--print', '--permission-mode', 'bypassPermissions', '-m', taskPrompt], { cwd: workspacePath }); +``` + +### OpenClaw Adapter +```typescript +// Use OpenClaw gateway sessions_spawn +// POST to gateway API to create session, stream SSE events +``` + +## REST API Routes + +### Projects +- `GET /api/projects` — list all +- `POST /api/projects` — create (name, path, spec) +- `GET /api/projects/:id` — detail with phases/missions/tasks +- `PUT /api/projects/:id` — update +- `DELETE /api/projects/:id` — delete + +### Tasks +- `GET /api/tasks` — list (filter by mission_id, status) +- `POST /api/tasks` — create +- `PUT /api/tasks/:id` — update +- `POST /api/tasks/:id/run` — manually trigger a task run + +### Missions +- `POST /api/missions/:id/start` — start mission (resolve deps → dispatch wave 1) +- `POST /api/missions/:id/pause` — pause all running tasks +- `POST /api/missions/:id/resume` — resume paused tasks +- `POST /api/missions/:id/stop` — stop and cleanup + +### Agents +- `GET /api/agents` — list registered agents +- `POST /api/agents` — register new agent +- `GET /api/agents/:id/status` — current status + active task + +### Checkpoints +- `GET /api/checkpoints` — list pending checkpoints +- `POST /api/checkpoints/:id/approve` — approve +- `POST /api/checkpoints/:id/reject` — reject +- `POST /api/checkpoints/:id/revise` — revise with notes + +### Events (SSE) +- `GET /api/events` — SSE stream of all orchestrator events +- `GET /api/events/:taskRunId` — SSE stream for specific task run + +## Dependencies +```json +{ + "dependencies": { + "express": "^4.21.0", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "yaml": "^2.4.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "@types/express": "^4.17.21", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "tsx": "^4.19.0" + } +} +``` + +## Key Design Decisions +1. **SQLite, not Postgres** — local-first, zero config, single file +2. **better-sqlite3, not Drizzle** — synchronous API, simpler for a daemon +3. **In-memory orchestrator state + SQLite persistence** — fast dispatch, durable recovery +4. **WORKFLOW.md per project** — teams version their agent config with code (from Symphony) +5. **SSE for live events** — same pattern ClawSuite already uses for chat streaming +6. **No auth** — single user, local daemon. Auth comes later. + +## How to Run +```bash +cd workspace-daemon +npm install +npx tsx src/server.ts +# Daemon runs on http://localhost:3001 +``` + +## Verification +After building, verify: +1. `npx tsc --noEmit` passes +2. Server starts on port 3001 +3. `curl http://localhost:3001/api/projects` returns `[]` +4. Can create a project via POST +5. Can create tasks and trigger a run +6. SSE endpoint streams events diff --git a/workspace-daemon/package-lock.json b/workspace-daemon/package-lock.json new file mode 100644 index 00000000..b412452b --- /dev/null +++ b/workspace-daemon/package-lock.json @@ -0,0 +1,2003 @@ +{ + "name": "@clawsuite/workspace-daemon", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@clawsuite/workspace-daemon", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "yaml": "^2.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/workspace-daemon/package.json b/workspace-daemon/package.json new file mode 100644 index 00000000..f99e75bf --- /dev/null +++ b/workspace-daemon/package.json @@ -0,0 +1,24 @@ +{ + "name": "@clawsuite/workspace-daemon", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "^4.21.0", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "yaml": "^2.4.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "@types/express": "^4.17.21", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/node": "^22.0.0", + "tsx": "^4.19.0" + } +} diff --git a/workspace-daemon/src/adapters/claude.ts b/workspace-daemon/src/adapters/claude.ts new file mode 100644 index 00000000..a9839ef4 --- /dev/null +++ b/workspace-daemon/src/adapters/claude.ts @@ -0,0 +1,95 @@ +import { spawn } from "node:child_process"; +import type { AgentAdapter } from "./types"; +import type { AgentExecutionRequest, AgentExecutionResult } from "../types"; + +export class ClaudeAdapter implements AgentAdapter { + readonly type = "claude"; + + async execute(request: AgentExecutionRequest, context: { signal?: AbortSignal; onEvent: (event: any) => void }): Promise { + return new Promise((resolve) => { + const parsedConfig = + request.agent.adapter_config && request.agent.adapter_config.trim().length > 0 + ? (JSON.parse(request.agent.adapter_config) as Record) + : {}; + const command = typeof parsedConfig.command === "string" ? parsedConfig.command : "claude"; + const proc = spawn( + command, + ["--print", "--permission-mode", "bypassPermissions", "-m", request.prompt], + { + cwd: request.workspacePath, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }, + ); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const settle = (result: AgentExecutionResult): void => { + if (!settled) { + settled = true; + resolve(result); + } + }; + + context.signal?.addEventListener("abort", () => { + proc.kill("SIGTERM"); + settle({ + status: "stopped", + summary: "Run aborted", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + error: "Aborted", + }); + }); + + proc.stdout.setEncoding("utf8"); + proc.stdout.on("data", (chunk: string) => { + stdout += chunk; + context.onEvent({ type: "output", message: chunk.trim() }); + }); + + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (chunk: string) => { + stderr += chunk; + context.onEvent({ type: "error", message: chunk.trim() }); + }); + + proc.on("error", (error) => { + settle({ + status: "failed", + summary: stdout.trim() || "Claude execution failed", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + error: error.message, + }); + }); + + proc.on("close", (code) => { + if (code === 0) { + settle({ + status: "completed", + summary: stdout.trim() || "Completed", + checkpointSummary: stdout.trim() || "Completed", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + }); + return; + } + + settle({ + status: "failed", + summary: stdout.trim() || "Claude execution failed", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + error: stderr.trim() || `Process exited with code ${code ?? -1}`, + }); + }); + }); + } +} diff --git a/workspace-daemon/src/adapters/codex.ts b/workspace-daemon/src/adapters/codex.ts new file mode 100644 index 00000000..a10144eb --- /dev/null +++ b/workspace-daemon/src/adapters/codex.ts @@ -0,0 +1,146 @@ +import { spawn } from "node:child_process"; +import type { AgentAdapter } from "./types"; +import type { AgentExecutionRequest, AgentExecutionResult } from "../types"; + +function parseJsonLines(chunk: string, onMessage: (event: Record) => void): void { + const lines = chunk.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + try { + const parsed = JSON.parse(trimmed) as Record; + onMessage(parsed); + } catch { + onMessage({ type: "output_text", text: trimmed }); + } + } +} + +export class CodexAdapter implements AgentAdapter { + readonly type = "codex"; + + async execute(request: AgentExecutionRequest, context: { signal?: AbortSignal; onEvent: (event: any) => void }): Promise { + return new Promise((resolve) => { + const command = request.agent.adapter_config ? JSON.parse(request.agent.adapter_config).command : undefined; + const proc = spawn(command ?? "codex", ["app-server"], { + cwd: request.workspacePath, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + + let stdoutBuffer = ""; + let stderrBuffer = ""; + let summary = ""; + let inputTokens = 0; + let outputTokens = 0; + let settled = false; + + const settle = (result: AgentExecutionResult): void => { + if (settled) { + return; + } + + settled = true; + resolve(result); + }; + + context.signal?.addEventListener("abort", () => { + proc.kill("SIGTERM"); + settle({ + status: "stopped", + summary: "Run aborted", + inputTokens, + outputTokens, + costCents: 0, + error: "Aborted", + }); + }); + + proc.stdout.setEncoding("utf8"); + proc.stdout.on("data", (chunk: string) => { + stdoutBuffer += chunk; + parseJsonLines(chunk, (event) => { + const type = typeof event.type === "string" ? event.type : "output"; + if (type === "item.completed" && typeof event.item === "object" && event.item && "text" in event.item) { + const text = typeof event.item.text === "string" ? event.item.text : ""; + if (text) { + summary = `${summary}\n${text}`.trim(); + context.onEvent({ type: "output", message: text }); + } + } else if (type === "turn.completed" && typeof event.usage === "object" && event.usage) { + const usage = event.usage as Record; + inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : inputTokens; + outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : outputTokens; + context.onEvent({ type: "turn.completed", data: usage }); + } else if (type === "output_text" && typeof event.text === "string") { + context.onEvent({ type: "output", message: event.text }); + } else { + context.onEvent({ type: "status", data: event }); + } + }); + }); + + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (chunk: string) => { + stderrBuffer += chunk; + context.onEvent({ type: "error", message: chunk.trim() }); + }); + + proc.on("spawn", () => { + const payloads = [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "thread/start", params: {} }, + { + jsonrpc: "2.0", + id: 3, + method: "turn/start", + params: { + input: request.prompt, + }, + }, + ]; + + for (const payload of payloads) { + proc.stdin.write(`${JSON.stringify(payload)}\n`); + } + }); + + proc.on("error", (error) => { + settle({ + status: "failed", + summary: summary || "Codex execution failed", + inputTokens, + outputTokens, + costCents: 0, + error: error.message, + }); + }); + + proc.on("close", (code) => { + if (code === 0) { + settle({ + status: "completed", + summary: summary || stdoutBuffer.trim() || "Completed", + checkpointSummary: summary || stdoutBuffer.trim() || "Completed", + inputTokens, + outputTokens, + costCents: 0, + }); + return; + } + + settle({ + status: "failed", + summary: summary || "Codex execution failed", + inputTokens, + outputTokens, + costCents: 0, + error: stderrBuffer.trim() || stdoutBuffer.trim() || `Process exited with code ${code ?? -1}`, + }); + }); + }); + } +} diff --git a/workspace-daemon/src/adapters/openclaw.ts b/workspace-daemon/src/adapters/openclaw.ts new file mode 100644 index 00000000..b045d6ce --- /dev/null +++ b/workspace-daemon/src/adapters/openclaw.ts @@ -0,0 +1,110 @@ +import type { AgentAdapter } from "./types"; +import type { AgentExecutionRequest, AgentExecutionResult } from "../types"; + +function tryParseJson(value: string): Record | null { + try { + return JSON.parse(value) as Record; + } catch { + return null; + } +} + +export class OpenClawAdapter implements AgentAdapter { + readonly type = "openclaw"; + + async execute(request: AgentExecutionRequest, context: { signal?: AbortSignal; onEvent: (event: any) => void }): Promise { + const parsedConfig = + request.agent.adapter_config && request.agent.adapter_config.trim().length > 0 + ? (JSON.parse(request.agent.adapter_config) as Record) + : {}; + const baseUrl = + typeof parsedConfig.url === "string" && parsedConfig.url.trim().length > 0 + ? parsedConfig.url + : "http://127.0.0.1:3333"; + const endpoint = new URL("/sessions/spawn", baseUrl).toString(); + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + prompt: request.prompt, + cwd: request.workspacePath, + agent: { + id: request.agent.id, + name: request.agent.name, + model: request.agent.model, + }, + }), + signal: context.signal, + }); + + if (!response.ok) { + throw new Error(`OpenClaw request failed with ${response.status}`); + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/event-stream")) { + const data = (await response.json()) as Record; + return { + status: "completed", + summary: typeof data.summary === "string" ? data.summary : "Completed", + checkpointSummary: typeof data.summary === "string" ? data.summary : "Completed", + inputTokens: typeof data.inputTokens === "number" ? data.inputTokens : 0, + outputTokens: typeof data.outputTokens === "number" ? data.outputTokens : 0, + costCents: typeof data.costCents === "number" ? data.costCents : 0, + }; + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let summary = ""; + if (!reader) { + return { + status: "completed", + summary: "Completed", + checkpointSummary: "Completed", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + }; + } + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + const chunk = decoder.decode(value, { stream: true }); + for (const line of chunk.split(/\r?\n/)) { + if (!line.startsWith("data:")) { + continue; + } + + const payload = line.slice(5).trim(); + const parsed = tryParseJson(payload); + if (!parsed) { + continue; + } + + const text = typeof parsed.message === "string" ? parsed.message : ""; + if (text) { + summary = `${summary}\n${text}`.trim(); + context.onEvent({ type: "output", message: text }); + } else { + context.onEvent({ type: "status", data: parsed }); + } + } + } + + return { + status: "completed", + summary: summary || "Completed", + checkpointSummary: summary || "Completed", + inputTokens: 0, + outputTokens: 0, + costCents: 0, + }; + } +} diff --git a/workspace-daemon/src/adapters/types.ts b/workspace-daemon/src/adapters/types.ts new file mode 100644 index 00000000..69b060ee --- /dev/null +++ b/workspace-daemon/src/adapters/types.ts @@ -0,0 +1,11 @@ +import type { AgentExecutionRequest, AgentExecutionResult, AdapterStreamEvent } from "../types"; + +export interface AgentAdapterContext { + signal?: AbortSignal; + onEvent: (event: AdapterStreamEvent) => void; +} + +export interface AgentAdapter { + readonly type: string; + execute(request: AgentExecutionRequest, context: AgentAdapterContext): Promise; +} diff --git a/workspace-daemon/src/agent-runner.ts b/workspace-daemon/src/agent-runner.ts new file mode 100644 index 00000000..f94704d0 --- /dev/null +++ b/workspace-daemon/src/agent-runner.ts @@ -0,0 +1,88 @@ +import { CodexAdapter } from "./adapters/codex"; +import { ClaudeAdapter } from "./adapters/claude"; +import { OpenClawAdapter } from "./adapters/openclaw"; +import type { AgentAdapter } from "./adapters/types"; +import { getWorkflowConfig, loadWorkflowDefinition, renderTaskPrompt } from "./config"; +import { WorkspaceManager } from "./workspace"; +import { Tracker } from "./tracker"; +import type { AgentExecutionResult, AgentRecord, Project, Task, TaskRun } from "./types"; + +export class AgentRunner { + private readonly adapters: Map; + private readonly workspaceManager: WorkspaceManager; + private readonly tracker: Tracker; + + constructor(tracker: Tracker, workspaceManager = new WorkspaceManager()) { + this.tracker = tracker; + this.workspaceManager = workspaceManager; + this.adapters = new Map([ + ["codex", new CodexAdapter()], + ["claude", new ClaudeAdapter()], + ["openclaw", new OpenClawAdapter()], + ]); + } + + getAdapter(type: string): AgentAdapter { + const adapter = this.adapters.get(type); + if (!adapter) { + throw new Error(`Unsupported adapter type: ${type}`); + } + return adapter; + } + + async runTask(input: { + project: Project; + task: Task; + taskRun: TaskRun; + agent: AgentRecord; + attempt: number; + signal?: AbortSignal; + }): Promise<{ result: AgentExecutionResult; workspacePath: string }> { + const workflow = loadWorkflowDefinition(input.project.path); + const workflowConfig = getWorkflowConfig(input.project.path); + const workspace = await this.workspaceManager.ensureWorkspace(input.project, input.task); + + await this.workspaceManager.runBeforeRunHooks(workspace.path, workspace.hooks); + + const prompt = renderTaskPrompt(workflow.promptTemplate, { + projectName: input.project.name, + taskName: input.task.name, + taskDescription: input.task.description, + workspacePath: workspace.path, + }); + const adapter = this.getAdapter(input.agent.adapter_type || workflowConfig.defaultAdapter); + + this.tracker.appendRunEvent(input.taskRun.id, "started", { + taskId: input.task.id, + agentId: input.agent.id, + workspacePath: workspace.path, + attempt: input.attempt, + }); + + const result = await adapter.execute( + { + task: input.task, + taskRun: input.taskRun, + agent: input.agent, + workspacePath: workspace.path, + prompt, + }, + { + signal: input.signal, + onEvent: (event) => { + this.tracker.appendRunEvent(input.taskRun.id, event.type === "agent_message" ? "output" : (event.type as any), { + message: event.message ?? null, + ...event.data, + }); + }, + }, + ); + + await this.workspaceManager.runAfterRunHooks(workspace.path, workspace.hooks); + + return { + result, + workspacePath: workspace.path, + }; + } +} diff --git a/workspace-daemon/src/config.ts b/workspace-daemon/src/config.ts new file mode 100644 index 00000000..84f482f2 --- /dev/null +++ b/workspace-daemon/src/config.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import type { AgentAdapterType, WorkflowConfig, WorkflowDefinition, WorkflowHooks } from "./types"; + +const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = { + pollIntervalMs: 5000, + maxConcurrentAgents: 4, + workspaceRoot: path.resolve(process.cwd(), ".workspaces"), + autoApprove: true, + defaultAdapter: "codex", + hooks: {}, +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const entries = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + return entries.length > 0 ? entries : undefined; +} + +function parseFrontmatter(raw: string): WorkflowDefinition { + if (!raw.startsWith("---")) { + return { + config: {}, + promptTemplate: raw.trim(), + }; + } + + const closingIndex = raw.indexOf("\n---", 3); + if (closingIndex === -1) { + throw new Error("Invalid WORKFLOW.md front matter: missing closing delimiter"); + } + + const yamlBlock = raw.slice(3, closingIndex).trim(); + const body = raw.slice(closingIndex + 4).trim(); + const parsed = yamlBlock.length > 0 ? YAML.parse(yamlBlock) : {}; + + if (!isObject(parsed)) { + throw new Error("Invalid WORKFLOW.md front matter: expected top-level object"); + } + + return { + config: parsed, + promptTemplate: body, + }; +} + +function normalizeHooks(config: Record): WorkflowHooks { + const hooks = isObject(config.hooks) ? config.hooks : {}; + return { + before_run: toStringArray(hooks.before_run), + after_run: toStringArray(hooks.after_run), + after_create: toStringArray(hooks.after_create), + }; +} + +function normalizeAdapter(value: unknown): AgentAdapterType { + if (value === "claude" || value === "openclaw" || value === "ollama" || value === "codex") { + return value; + } + + return "codex"; +} + +export function resolveWorkflowPath(projectPath?: string | null): string | null { + const candidates = [ + projectPath ? path.join(projectPath, "WORKFLOW.md") : null, + path.resolve(process.cwd(), "WORKFLOW.md"), + ]; + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +export function loadWorkflowDefinition(projectPath?: string | null): WorkflowDefinition { + const workflowPath = resolveWorkflowPath(projectPath); + + if (!workflowPath) { + return { + config: {}, + promptTemplate: "You are an autonomous coding agent. Complete the assigned task and report the result.", + }; + } + + const raw = fs.readFileSync(workflowPath, "utf8"); + return parseFrontmatter(raw); +} + +export function getWorkflowConfig(projectPath?: string | null): WorkflowConfig { + const definition = loadWorkflowDefinition(projectPath); + const config = definition.config; + const workspaceRoot = + typeof config.workspace_root === "string" && config.workspace_root.trim().length > 0 + ? path.resolve(projectPath ?? process.cwd(), config.workspace_root) + : DEFAULT_WORKFLOW_CONFIG.workspaceRoot; + + return { + pollIntervalMs: + typeof config.poll_interval_ms === "number" + ? config.poll_interval_ms + : DEFAULT_WORKFLOW_CONFIG.pollIntervalMs, + maxConcurrentAgents: + typeof config.max_concurrent_agents === "number" + ? config.max_concurrent_agents + : DEFAULT_WORKFLOW_CONFIG.maxConcurrentAgents, + workspaceRoot, + autoApprove: typeof config.auto_approve === "boolean" ? config.auto_approve : DEFAULT_WORKFLOW_CONFIG.autoApprove, + defaultAdapter: normalizeAdapter(config.default_adapter), + agentCommand: typeof config.agent_command === "string" ? config.agent_command : undefined, + agentArgs: toStringArray(config.agent_args), + env: isObject(config.env) + ? Object.fromEntries( + Object.entries(config.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) + : undefined, + hooks: normalizeHooks(config), + }; +} + +export function renderTaskPrompt( + template: string, + input: { + projectName: string; + taskName: string; + taskDescription: string | null; + workspacePath: string; + }, +): string { + const replacements: Record = { + project_name: input.projectName, + task_name: input.taskName, + task_description: input.taskDescription ?? "", + workspace_path: input.workspacePath, + }; + + const rendered = template.replace(/\{\{\s*([a-z_]+)\s*\}\}/gi, (_, key: string) => replacements[key] ?? ""); + const fallback = [ + `Project: ${input.projectName}`, + `Task: ${input.taskName}`, + `Workspace: ${input.workspacePath}`, + input.taskDescription ? `Description:\n${input.taskDescription}` : "", + ] + .filter(Boolean) + .join("\n\n"); + + return rendered.trim().length > 0 ? rendered.trim() : fallback; +} diff --git a/workspace-daemon/src/db/index.ts b/workspace-daemon/src/db/index.ts new file mode 100644 index 00000000..d94fd320 --- /dev/null +++ b/workspace-daemon/src/db/index.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const DEFAULT_DB_DIR = path.resolve(process.cwd(), ".data"); +const DEFAULT_DB_PATH = path.join(DEFAULT_DB_DIR, "workspace-daemon.sqlite"); + +let dbInstance: Database.Database | null = null; + +function ensureDirectory(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function readSchemaSql(): string { + const schemaPath = path.resolve(process.cwd(), "src/db/schema.sql"); + return fs.readFileSync(schemaPath, "utf8"); +} + +export function getDatabase(dbPath = process.env.WORKSPACE_DAEMON_DB_PATH ?? DEFAULT_DB_PATH): Database.Database { + if (dbInstance) { + return dbInstance; + } + + ensureDirectory(dbPath); + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + db.exec(readSchemaSql()); + dbInstance = db; + return db; +} + +export function closeDatabase(): void { + if (!dbInstance) { + return; + } + + dbInstance.close(); + dbInstance = null; +} + +export type SqliteDatabase = Database.Database; diff --git a/workspace-daemon/src/db/schema.sql b/workspace-daemon/src/db/schema.sql new file mode 100644 index 00000000..b0f8ade3 --- /dev/null +++ b/workspace-daemon/src/db/schema.sql @@ -0,0 +1,128 @@ +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + name TEXT NOT NULL, + path TEXT, + spec TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS phases ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' +); + +CREATE TABLE IF NOT EXISTS missions ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress REAL NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder', + adapter_type TEXT NOT NULL DEFAULT 'codex', + adapter_config TEXT DEFAULT '{}', + model TEXT, + status TEXT NOT NULL DEFAULT 'idle', + capabilities TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + mission_id TEXT NOT NULL REFERENCES missions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + sort_order INTEGER NOT NULL DEFAULT 0, + depends_on TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS task_runs ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt INTEGER NOT NULL DEFAULT 1, + workspace_path TEXT, + started_at TEXT, + completed_at TEXT, + error TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cost_cents INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS run_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_run_id TEXT NOT NULL REFERENCES task_runs(id) ON DELETE CASCADE, + type TEXT NOT NULL, + data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS checkpoints ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + task_run_id TEXT NOT NULL REFERENCES task_runs(id) ON DELETE CASCADE, + summary TEXT, + diff_stat TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id) ON DELETE CASCADE, + type TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + agent_id TEXT, + details TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_phases_project_id ON phases(project_id); +CREATE INDEX IF NOT EXISTS idx_missions_phase_id ON missions(phase_id); +CREATE INDEX IF NOT EXISTS idx_tasks_mission_id ON tasks(mission_id); +CREATE INDEX IF NOT EXISTS idx_tasks_agent_id ON tasks(agent_id); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); +CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status); +CREATE INDEX IF NOT EXISTS idx_run_events_task_run_id ON run_events(task_run_id); +CREATE INDEX IF NOT EXISTS idx_checkpoints_task_run_id ON checkpoints(task_run_id); + +CREATE TRIGGER IF NOT EXISTS trg_projects_updated_at +AFTER UPDATE ON projects +FOR EACH ROW +BEGIN + UPDATE projects SET updated_at = datetime('now') WHERE id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_tasks_updated_at +AFTER UPDATE ON tasks +FOR EACH ROW +BEGIN + UPDATE tasks SET updated_at = datetime('now') WHERE id = OLD.id; +END; diff --git a/workspace-daemon/src/orchestrator.ts b/workspace-daemon/src/orchestrator.ts new file mode 100644 index 00000000..86042f16 --- /dev/null +++ b/workspace-daemon/src/orchestrator.ts @@ -0,0 +1,234 @@ +import { EventEmitter } from "node:events"; +import { AgentRunner } from "./agent-runner"; +import { Tracker } from "./tracker"; +import { getWorkflowConfig } from "./config"; +import type { AgentRecord, OrchestratorState, RetryEntry, RunningEntry, TaskRunStatus, TaskWithRelations } from "./types"; + +const MAX_RETRIES = 3; +const BASE_RETRY_MS = 10_000; +const MAX_RETRY_MS = 300_000; + +function nowIso(): string { + return new Date().toISOString(); +} + +function computeRetryDelay(attempt: number): number { + return Math.min(BASE_RETRY_MS * 2 ** Math.max(attempt - 1, 0), MAX_RETRY_MS); +} + +export class Orchestrator extends EventEmitter { + private readonly tracker: Tracker; + private readonly agentRunner: AgentRunner; + private timer: NodeJS.Timeout | null = null; + readonly state: OrchestratorState; + + constructor(tracker: Tracker, agentRunner = new AgentRunner(tracker)) { + super(); + this.tracker = tracker; + this.agentRunner = agentRunner; + const workflowConfig = getWorkflowConfig(); + this.state = { + pollIntervalMs: workflowConfig.pollIntervalMs, + maxConcurrentAgents: workflowConfig.maxConcurrentAgents, + running: new Map(), + claimed: new Set(), + retryAttempts: new Map(), + completed: new Set(), + }; + } + + start(): void { + if (this.timer) { + return; + } + + this.reconcileRunningTasks(); + this.timer = setInterval(() => { + void this.tick(); + }, this.state.pollIntervalMs); + void this.tick(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async triggerTask(taskId: string): Promise { + const task = this.tracker.getTask(taskId); + if (!task) { + return false; + } + + this.tracker.setTaskStatus(taskId, "ready"); + await this.tick(); + return true; + } + + async tick(): Promise { + const availableSlots = Math.max(this.state.maxConcurrentAgents - this.state.running.size, 0); + if (availableSlots <= 0) { + return; + } + + const readyTasks = this.tracker.resolveReadyTasks(availableSlots * 2); + for (const task of readyTasks) { + if (this.state.running.size >= this.state.maxConcurrentAgents) { + break; + } + if (this.state.claimed.has(task.id)) { + continue; + } + await this.dispatchTask(task); + } + } + + private reconcileRunningTasks(): void { + for (const run of this.tracker.getRunningTaskRuns()) { + this.tracker.updateTaskRun(run.id, { + status: "failed", + completed_at: nowIso(), + error: "Recovered after daemon restart", + }); + this.queueRetry(run.task_id, run.attempt, "Recovered after daemon restart"); + } + } + + private async dispatchTask(task: TaskWithRelations): Promise { + const project = this.tracker.getProject(task.project_id); + if (!project) { + return; + } + + const agent = this.resolveAgent(task.agent_id, project.path); + if (!agent) { + this.tracker.setTaskStatus(task.id, "failed"); + this.tracker.logActivity("failed", "task", task.id, null, { reason: "No agent available" }); + return; + } + + const retryEntry = this.state.retryAttempts.get(task.id); + const attempt = retryEntry?.attempt ?? 1; + this.state.claimed.add(task.id); + this.tracker.setTaskStatus(task.id, "running"); + this.tracker.setAgentStatus(agent.id, "running"); + + const taskRun = this.tracker.createTaskRun(task.id, agent.id, null, attempt); + const runningEntry: RunningEntry = { + taskId: task.id, + runId: taskRun.id, + attempt, + workspacePath: "", + agentId: agent.id, + startedAt: nowIso(), + session: null, + }; + this.state.running.set(task.id, runningEntry); + this.emit("dispatch", { taskId: task.id, runId: taskRun.id }); + + try { + const { result, workspacePath } = await this.agentRunner.runTask({ + project, + task, + taskRun, + agent, + attempt, + }); + + runningEntry.workspacePath = workspacePath; + + const taskRunStatus: TaskRunStatus = result.status === "completed" ? "awaiting_review" : "failed"; + this.tracker.updateTaskRun(taskRun.id, { + status: taskRunStatus, + completed_at: nowIso(), + error: result.error ?? null, + input_tokens: result.inputTokens, + output_tokens: result.outputTokens, + cost_cents: result.costCents, + }); + + if (result.status === "completed") { + const checkpoint = this.tracker.createCheckpoint( + taskRun.id, + result.checkpointSummary ?? result.summary, + result.diffStat ? { ...result.diffStat } : null, + ); + const autoApprove = getWorkflowConfig(project.path).autoApprove; + if (autoApprove) { + this.tracker.updateCheckpointStatus(checkpoint.id, "approved"); + this.tracker.setTaskStatus(task.id, "completed"); + this.tracker.updateTaskRun(taskRun.id, { + status: "completed", + completed_at: nowIso(), + }); + this.state.completed.add(task.id); + } + } else { + this.tracker.setTaskStatus(task.id, "failed"); + this.queueRetry(task.id, attempt, result.error ?? result.summary); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.tracker.updateTaskRun(taskRun.id, { + status: "failed", + completed_at: nowIso(), + error: message, + }); + this.tracker.setTaskStatus(task.id, "failed"); + this.queueRetry(task.id, attempt, message); + } finally { + this.state.running.delete(task.id); + this.state.claimed.delete(task.id); + this.tracker.setAgentStatus(agent.id, "idle"); + void this.tick(); + } + } + + private resolveAgent(agentId: string | null, projectPath: string | null): AgentRecord | null { + if (agentId) { + return this.tracker.getAgent(agentId); + } + + const workflowConfig = getWorkflowConfig(projectPath); + const existing = this.tracker.listAgents().find((agent) => agent.adapter_type === workflowConfig.defaultAdapter); + if (existing) { + return existing; + } + + const name = `${workflowConfig.defaultAdapter}-default`; + return this.tracker.registerAgent({ + name, + adapter_type: workflowConfig.defaultAdapter, + role: "coder", + }); + } + + private queueRetry(taskId: string, currentAttempt: number, error: string): void { + if (currentAttempt >= MAX_RETRIES) { + this.state.retryAttempts.delete(taskId); + return; + } + + const nextAttempt = currentAttempt + 1; + const retryEntry: RetryEntry = { + taskId, + identifier: taskId, + attempt: nextAttempt, + dueAtMs: Date.now() + computeRetryDelay(nextAttempt), + error, + }; + this.state.retryAttempts.set(taskId, retryEntry); + + setTimeout(() => { + const current = this.state.retryAttempts.get(taskId); + if (!current || current.attempt !== nextAttempt) { + return; + } + this.state.retryAttempts.delete(taskId); + this.tracker.setTaskStatus(taskId, "ready"); + void this.tick(); + }, computeRetryDelay(nextAttempt)); + } +} diff --git a/workspace-daemon/src/routes/agents.ts b/workspace-daemon/src/routes/agents.ts new file mode 100644 index 00000000..24e65efb --- /dev/null +++ b/workspace-daemon/src/routes/agents.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; + +export function createAgentsRouter(tracker: Tracker): Router { + const router = Router(); + + router.get("/", (_req, res) => { + res.json(tracker.listAgents()); + }); + + router.post("/", (req, res) => { + const { name, role, adapter_type, adapter_config, model, capabilities } = req.body as { + name?: string; + role?: string; + adapter_type?: "codex" | "claude" | "openclaw" | "ollama"; + adapter_config?: Record; + model?: string | null; + capabilities?: Record; + }; + if (!name || name.trim().length === 0) { + res.status(400).json({ error: "name is required" }); + return; + } + + const agent = tracker.registerAgent({ + name: name.trim(), + role, + adapter_type, + adapter_config, + model, + capabilities, + }); + res.status(201).json(agent); + }); + + router.get("/:id/status", (req, res) => { + const status = tracker.getAgentStatus(req.params.id); + if (!status) { + res.status(404).json({ error: "Agent not found" }); + return; + } + res.json(status); + }); + + return router; +} diff --git a/workspace-daemon/src/routes/checkpoints.ts b/workspace-daemon/src/routes/checkpoints.ts new file mode 100644 index 00000000..a559ea91 --- /dev/null +++ b/workspace-daemon/src/routes/checkpoints.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; + +export function createCheckpointsRouter(tracker: Tracker): Router { + const router = Router(); + + router.get("/", (req, res) => { + const status = typeof req.query.status === "string" ? req.query.status : undefined; + res.json(tracker.listCheckpoints(status)); + }); + + router.post("/:id/approve", (req, res) => { + const checkpoint = tracker.updateCheckpointStatus(req.params.id, "approved", req.body?.reviewer_notes); + if (!checkpoint) { + res.status(404).json({ error: "Checkpoint not found" }); + return; + } + res.json(checkpoint); + }); + + router.post("/:id/reject", (req, res) => { + const checkpoint = tracker.updateCheckpointStatus(req.params.id, "rejected", req.body?.reviewer_notes); + if (!checkpoint) { + res.status(404).json({ error: "Checkpoint not found" }); + return; + } + res.json(checkpoint); + }); + + router.post("/:id/revise", (req, res) => { + const checkpoint = tracker.updateCheckpointStatus(req.params.id, "revised", req.body?.reviewer_notes); + if (!checkpoint) { + res.status(404).json({ error: "Checkpoint not found" }); + return; + } + res.json(checkpoint); + }); + + return router; +} diff --git a/workspace-daemon/src/routes/events.ts b/workspace-daemon/src/routes/events.ts new file mode 100644 index 00000000..74c8c592 --- /dev/null +++ b/workspace-daemon/src/routes/events.ts @@ -0,0 +1,61 @@ +import type { Request, Response, Router } from "express"; +import { Tracker } from "../tracker"; + +function writeSse(res: Response, event: string, data: unknown): void { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function setupSseHeaders(res: Response): void { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); +} + +export function registerEventsRoutes(router: Router, tracker: Tracker): void { + router.get("/", (_req: Request, res: Response) => { + setupSseHeaders(res); + + const listener = (payload: { event: string; data: unknown }) => { + writeSse(res, payload.event, payload.data); + }; + + writeSse(res, "hello", { ok: true }); + tracker.on("sse", listener); + + const keepAlive = setInterval(() => { + res.write(": ping\n\n"); + }, 15_000); + + res.on("close", () => { + clearInterval(keepAlive); + tracker.off("sse", listener); + }); + }); + + router.get("/:taskRunId", (req: Request, res: Response) => { + setupSseHeaders(res); + + for (const event of tracker.listRunEvents(req.params.taskRunId)) { + writeSse(res, event.type, event); + } + + const listener = (payload: { event: string; data: any }) => { + if (payload.event !== "run_event" || payload.data.task_run_id !== req.params.taskRunId) { + return; + } + writeSse(res, payload.event, payload.data); + }; + + tracker.on("sse", listener); + const keepAlive = setInterval(() => { + res.write(": ping\n\n"); + }, 15_000); + + res.on("close", () => { + clearInterval(keepAlive); + tracker.off("sse", listener); + }); + }); +} diff --git a/workspace-daemon/src/routes/missions.ts b/workspace-daemon/src/routes/missions.ts new file mode 100644 index 00000000..c1e30278 --- /dev/null +++ b/workspace-daemon/src/routes/missions.ts @@ -0,0 +1,47 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; +import { Orchestrator } from "../orchestrator"; + +export function createMissionsRouter(tracker: Tracker, orchestrator: Orchestrator): Router { + const router = Router(); + + router.post("/:id/start", async (req, res) => { + const ok = tracker.startMission(req.params.id); + if (!ok) { + res.status(404).json({ error: "Mission not found" }); + return; + } + await orchestrator.tick(); + res.json({ ok: true }); + }); + + router.post("/:id/pause", (req, res) => { + const ok = tracker.pauseMission(req.params.id); + if (!ok) { + res.status(404).json({ error: "Mission not found" }); + return; + } + res.json({ ok: true }); + }); + + router.post("/:id/resume", async (req, res) => { + const ok = tracker.resumeMission(req.params.id); + if (!ok) { + res.status(404).json({ error: "Mission not found" }); + return; + } + await orchestrator.tick(); + res.json({ ok: true }); + }); + + router.post("/:id/stop", (req, res) => { + const ok = tracker.stopMission(req.params.id); + if (!ok) { + res.status(404).json({ error: "Mission not found" }); + return; + } + res.json({ ok: true }); + }); + + return router; +} diff --git a/workspace-daemon/src/routes/projects.ts b/workspace-daemon/src/routes/projects.ts new file mode 100644 index 00000000..1ffe3e14 --- /dev/null +++ b/workspace-daemon/src/routes/projects.ts @@ -0,0 +1,54 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; + +export function createProjectsRouter(tracker: Tracker): Router { + const router = Router(); + + router.get("/", (_req, res) => { + res.json(tracker.listProjects()); + }); + + router.post("/", (req, res) => { + const { name, path, spec } = req.body as { name?: string; path?: string | null; spec?: string | null }; + if (!name || name.trim().length === 0) { + res.status(400).json({ error: "name is required" }); + return; + } + + const project = tracker.createProject({ + name: name.trim(), + path: path ?? null, + spec: spec ?? null, + }); + res.status(201).json(project); + }); + + router.get("/:id", (req, res) => { + const project = tracker.getProjectDetail(req.params.id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.json(project); + }); + + router.put("/:id", (req, res) => { + const project = tracker.updateProject(req.params.id, req.body); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.json(project); + }); + + router.delete("/:id", (req, res) => { + const deleted = tracker.deleteProject(req.params.id); + if (!deleted) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.status(204).send(); + }); + + return router; +} diff --git a/workspace-daemon/src/routes/tasks.ts b/workspace-daemon/src/routes/tasks.ts new file mode 100644 index 00000000..d5089353 --- /dev/null +++ b/workspace-daemon/src/routes/tasks.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; +import { Orchestrator } from "../orchestrator"; +import type { TaskStatus } from "../types"; + +export function createTasksRouter(tracker: Tracker, orchestrator: Orchestrator): Router { + const router = Router(); + + router.get("/", (req, res) => { + const missionId = typeof req.query.mission_id === "string" ? req.query.mission_id : undefined; + const status = typeof req.query.status === "string" ? (req.query.status as TaskStatus) : undefined; + res.json(tracker.listTasks({ mission_id: missionId, status })); + }); + + router.post("/", (req, res) => { + const { mission_id, name, description, agent_id, status, sort_order, depends_on } = req.body as { + mission_id?: string; + name?: string; + description?: string | null; + agent_id?: string | null; + status?: TaskStatus; + sort_order?: number; + depends_on?: string[] | null; + }; + + if (!mission_id || !name) { + res.status(400).json({ error: "mission_id and name are required" }); + return; + } + + const task = tracker.createTask({ + mission_id, + name, + description, + agent_id, + status, + sort_order, + depends_on, + }); + res.status(201).json(task); + }); + + router.put("/:id", (req, res) => { + const task = tracker.updateTask(req.params.id, req.body); + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + res.json(task); + }); + + router.post("/:id/run", async (req, res) => { + const triggered = await orchestrator.triggerTask(req.params.id); + if (!triggered) { + res.status(404).json({ error: "Task not found" }); + return; + } + res.json({ ok: true }); + }); + + router.get("/:id/runs", (req, res) => { + res.json(tracker.listTaskRuns(req.params.id)); + }); + + return router; +} diff --git a/workspace-daemon/src/server.ts b/workspace-daemon/src/server.ts new file mode 100644 index 00000000..519f4a9b --- /dev/null +++ b/workspace-daemon/src/server.ts @@ -0,0 +1,46 @@ +import express from "express"; +import cors from "cors"; +import { Router } from "express"; +import { Tracker } from "./tracker"; +import { Orchestrator } from "./orchestrator"; +import { createProjectsRouter } from "./routes/projects"; +import { createTasksRouter } from "./routes/tasks"; +import { createAgentsRouter } from "./routes/agents"; +import { createMissionsRouter } from "./routes/missions"; +import { registerEventsRoutes } from "./routes/events"; +import { createCheckpointsRouter } from "./routes/checkpoints"; + +const PORT = Number(process.env.PORT ?? 3001); + +export function createServer(): { app: express.Express; tracker: Tracker; orchestrator: Orchestrator } { + const app = express(); + const tracker = new Tracker(); + const orchestrator = new Orchestrator(tracker); + + app.use(cors()); + app.use(express.json({ limit: "2mb" })); + + app.get("/health", (_req, res) => { + res.json({ ok: true }); + }); + + app.use("/api/projects", createProjectsRouter(tracker)); + app.use("/api/tasks", createTasksRouter(tracker, orchestrator)); + app.use("/api/agents", createAgentsRouter(tracker)); + app.use("/api/missions", createMissionsRouter(tracker, orchestrator)); + app.use("/api/checkpoints", createCheckpointsRouter(tracker)); + + const eventsRouter = Router(); + registerEventsRoutes(eventsRouter, tracker); + app.use("/api/events", eventsRouter); + + return { app, tracker, orchestrator }; +} + +const { app, orchestrator } = createServer(); + +orchestrator.start(); + +app.listen(PORT, () => { + process.stdout.write(`Workspace daemon listening on http://localhost:${PORT}\n`); +}); diff --git a/workspace-daemon/src/tracker.ts b/workspace-daemon/src/tracker.ts new file mode 100644 index 00000000..fc3caae0 --- /dev/null +++ b/workspace-daemon/src/tracker.ts @@ -0,0 +1,459 @@ +import { EventEmitter } from "node:events"; +import type Database from "better-sqlite3"; +import { getDatabase } from "./db"; +import type { + ActivityLogEntry, + AgentRecord, + Checkpoint, + CreateProjectInput, + CreateTaskInput, + Project, + ProjectDetail, + RegisterAgentInput, + RunEvent, + RunEventType, + Task, + TaskRun, + TaskRunStatus, + TaskRunWithRelations, + TaskStatus, + TaskWithRelations, + UpdateTaskInput, +} from "./types"; + +function parseJsonOrDefault(value: string | null | undefined, fallback: T): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +export class Tracker extends EventEmitter { + private readonly db: Database.Database; + + constructor(db = getDatabase()) { + super(); + this.db = db; + } + + listProjects(): Project[] { + return this.db.prepare("SELECT * FROM projects ORDER BY created_at DESC").all() as Project[]; + } + + createProject(input: CreateProjectInput): Project { + const stmt = this.db.prepare( + "INSERT INTO projects (name, path, spec) VALUES (@name, @path, @spec) RETURNING *", + ); + const project = stmt.get({ + name: input.name, + path: input.path ?? null, + spec: input.spec ?? null, + }) as Project; + this.logActivity("created", "project", project.id, null, project); + return project; + } + + getProject(id: string): Project | null { + return (this.db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Project | undefined) ?? null; + } + + getProjectDetail(id: string): ProjectDetail | null { + const project = this.getProject(id); + if (!project) { + return null; + } + + const phases = this.db + .prepare("SELECT * FROM phases WHERE project_id = ? ORDER BY sort_order ASC, name ASC") + .all(id) as Array; + const missions = this.db + .prepare( + `SELECT missions.*, phases.project_id + FROM missions + JOIN phases ON phases.id = missions.phase_id + WHERE phases.project_id = ? + ORDER BY missions.name ASC`, + ) + .all(id) as Array<{ + id: string; + phase_id: string; + name: string; + status: ProjectDetail["phases"][number]["missions"][number]["status"]; + progress: number; + project_id: string; + }>; + const tasks = this.db + .prepare( + `SELECT tasks.*, missions.phase_id + FROM tasks + JOIN missions ON missions.id = tasks.mission_id + JOIN phases ON phases.id = missions.phase_id + WHERE phases.project_id = ? + ORDER BY tasks.sort_order ASC, tasks.created_at ASC`, + ) + .all(id) as Array; + + return { + ...project, + phases: phases.map((phase) => ({ + ...phase, + missions: missions + .filter((mission) => mission.phase_id === phase.id) + .map((mission) => ({ + ...mission, + tasks: tasks.filter((task) => task.mission_id === mission.id), + })), + })), + }; + } + + updateProject(id: string, updates: Partial): Project | null { + const existing = this.getProject(id); + if (!existing) { + return null; + } + + this.db + .prepare("UPDATE projects SET name = ?, path = ?, spec = ? WHERE id = ?") + .run(updates.name ?? existing.name, updates.path ?? existing.path, updates.spec ?? existing.spec, id); + const project = this.getProject(id); + if (project) { + this.logActivity("updated", "project", project.id, null, project); + } + return project; + } + + deleteProject(id: string): boolean { + const result = this.db.prepare("DELETE FROM projects WHERE id = ?").run(id); + if (result.changes > 0) { + this.logActivity("deleted", "project", id, null, {}); + return true; + } + return false; + } + + listTasks(filters: { mission_id?: string; status?: TaskStatus }): TaskWithRelations[] { + const clauses: string[] = []; + const params: unknown[] = []; + if (filters.mission_id) { + clauses.push("tasks.mission_id = ?"); + params.push(filters.mission_id); + } + if (filters.status) { + clauses.push("tasks.status = ?"); + params.push(filters.status); + } + const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + + return this.db + .prepare( + `SELECT tasks.*, missions.name AS mission_name, phases.id AS phase_id, projects.id AS project_id, projects.name AS project_name + FROM tasks + JOIN missions ON missions.id = tasks.mission_id + JOIN phases ON phases.id = missions.phase_id + JOIN projects ON projects.id = phases.project_id + ${whereSql} + ORDER BY tasks.sort_order ASC, tasks.created_at ASC`, + ) + .all(...params) as TaskWithRelations[]; + } + + getTask(id: string): Task | null { + return (this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as Task | undefined) ?? null; + } + + createTask(input: CreateTaskInput): Task { + const task = this.db + .prepare( + `INSERT INTO tasks (mission_id, name, description, agent_id, status, sort_order, depends_on) + VALUES (@mission_id, @name, @description, @agent_id, @status, @sort_order, @depends_on) + RETURNING *`, + ) + .get({ + mission_id: input.mission_id, + name: input.name, + description: input.description ?? null, + agent_id: input.agent_id ?? null, + status: input.status ?? "pending", + sort_order: input.sort_order ?? 0, + depends_on: input.depends_on ? JSON.stringify(input.depends_on) : null, + }) as Task; + this.logActivity("created", "task", task.id, task.agent_id, task); + return task; + } + + updateTask(id: string, updates: UpdateTaskInput): Task | null { + const existing = this.getTask(id); + if (!existing) { + return null; + } + + this.db + .prepare( + `UPDATE tasks + SET name = ?, description = ?, agent_id = ?, status = ?, sort_order = ?, depends_on = ? + WHERE id = ?`, + ) + .run( + updates.name ?? existing.name, + updates.description ?? existing.description, + updates.agent_id ?? existing.agent_id, + updates.status ?? existing.status, + updates.sort_order ?? existing.sort_order, + updates.depends_on ? JSON.stringify(updates.depends_on) : existing.depends_on, + id, + ); + + const task = this.getTask(id); + if (task) { + this.logActivity("updated", "task", task.id, task.agent_id, task); + } + return task; + } + + setTaskStatus(id: string, status: TaskStatus): Task | null { + this.db.prepare("UPDATE tasks SET status = ? WHERE id = ?").run(status, id); + return this.getTask(id); + } + + resolveReadyTasks(limit: number): TaskWithRelations[] { + const tasks = this.listTasks({ status: "pending" }); + const byId = new Map(tasks.map((task) => [task.id, task])); + const completedTaskIds = new Set( + (this.db.prepare("SELECT id FROM tasks WHERE status = 'completed'").all() as Array<{ id: string }>).map((row) => row.id), + ); + const ready: TaskWithRelations[] = []; + + for (const task of tasks) { + const dependencies = parseJsonOrDefault(task.depends_on, []); + const isReady = dependencies.every((dependencyId) => completedTaskIds.has(dependencyId) || !byId.has(dependencyId)); + if (isReady) { + this.setTaskStatus(task.id, "ready"); + ready.push({ ...task, status: "ready" }); + } + if (ready.length >= limit) { + break; + } + } + + return ready; + } + + createTaskRun(taskId: string, agentId: string | null, workspacePath: string | null, attempt: number): TaskRun { + const taskRun = this.db + .prepare( + `INSERT INTO task_runs (task_id, agent_id, status, attempt, workspace_path, started_at) + VALUES (?, ?, 'running', ?, ?, datetime('now')) + RETURNING *`, + ) + .get(taskId, agentId, attempt, workspacePath) as TaskRun; + this.emitSse("task_run.started", taskRun); + return taskRun; + } + + updateTaskRun( + id: string, + updates: Partial>, + ): TaskRun | null { + const current = this.getTaskRun(id); + if (!current) { + return null; + } + + this.db + .prepare( + `UPDATE task_runs + SET status = ?, completed_at = ?, error = ?, input_tokens = ?, output_tokens = ?, cost_cents = ? + WHERE id = ?`, + ) + .run( + updates.status ?? current.status, + updates.completed_at ?? current.completed_at, + updates.error ?? current.error, + updates.input_tokens ?? current.input_tokens, + updates.output_tokens ?? current.output_tokens, + updates.cost_cents ?? current.cost_cents, + id, + ); + + const run = this.getTaskRun(id); + if (run) { + this.emitSse("task_run.updated", run); + } + return run; + } + + getTaskRun(id: string): TaskRun | null { + return (this.db.prepare("SELECT * FROM task_runs WHERE id = ?").get(id) as TaskRun | undefined) ?? null; + } + + getRunningTaskRuns(): TaskRun[] { + return this.db.prepare("SELECT * FROM task_runs WHERE status = 'running'").all() as TaskRun[]; + } + + listTaskRuns(taskId?: string): TaskRunWithRelations[] { + const clause = taskId ? "WHERE task_runs.task_id = ?" : ""; + return this.db + .prepare( + `SELECT task_runs.*, tasks.name AS task_name, tasks.mission_id, phases.project_id, agents.name AS agent_name + FROM task_runs + JOIN tasks ON tasks.id = task_runs.task_id + JOIN missions ON missions.id = tasks.mission_id + JOIN phases ON phases.id = missions.phase_id + LEFT JOIN agents ON agents.id = task_runs.agent_id + ${clause} + ORDER BY task_runs.started_at DESC`, + ) + .all(...(taskId ? [taskId] : [])) as TaskRunWithRelations[]; + } + + appendRunEvent(taskRunId: string, type: RunEventType, data: Record | null): RunEvent { + const event = this.db + .prepare("INSERT INTO run_events (task_run_id, type, data) VALUES (?, ?, ?) RETURNING *") + .get(taskRunId, type, data ? JSON.stringify(data) : null) as RunEvent; + this.emitSse("run_event", event); + return event; + } + + listRunEvents(taskRunId?: string): RunEvent[] { + if (taskRunId) { + return this.db + .prepare("SELECT * FROM run_events WHERE task_run_id = ? ORDER BY id ASC") + .all(taskRunId) as RunEvent[]; + } + return this.db.prepare("SELECT * FROM run_events ORDER BY id DESC LIMIT 200").all() as RunEvent[]; + } + + createCheckpoint(taskRunId: string, summary: string | null, diffStat: Record | null): Checkpoint { + const checkpoint = this.db + .prepare("INSERT INTO checkpoints (task_run_id, summary, diff_stat) VALUES (?, ?, ?) RETURNING *") + .get(taskRunId, summary, diffStat ? JSON.stringify(diffStat) : null) as Checkpoint; + this.emitSse("checkpoint.created", checkpoint); + return checkpoint; + } + + listCheckpoints(status?: string): Checkpoint[] { + if (status) { + return this.db + .prepare("SELECT * FROM checkpoints WHERE status = ? ORDER BY created_at DESC") + .all(status) as Checkpoint[]; + } + return this.db.prepare("SELECT * FROM checkpoints ORDER BY created_at DESC").all() as Checkpoint[]; + } + + updateCheckpointStatus(id: string, status: Checkpoint["status"], reviewerNotes?: string): Checkpoint | null { + this.db + .prepare("UPDATE checkpoints SET status = ?, reviewer_notes = ? WHERE id = ?") + .run(status, reviewerNotes ?? null, id); + const checkpoint = + (this.db.prepare("SELECT * FROM checkpoints WHERE id = ?").get(id) as Checkpoint | undefined) ?? null; + if (checkpoint) { + this.emitSse("checkpoint.updated", checkpoint); + } + return checkpoint; + } + + listAgents(): AgentRecord[] { + return this.db.prepare("SELECT * FROM agents ORDER BY created_at DESC").all() as AgentRecord[]; + } + + getAgent(id: string): AgentRecord | null { + return (this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id) as AgentRecord | undefined) ?? null; + } + + registerAgent(input: RegisterAgentInput): AgentRecord { + const agent = this.db + .prepare( + `INSERT INTO agents (name, role, adapter_type, adapter_config, model, capabilities) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING *`, + ) + .get( + input.name, + input.role ?? "coder", + input.adapter_type ?? "codex", + JSON.stringify(input.adapter_config ?? {}), + input.model ?? null, + JSON.stringify(input.capabilities ?? {}), + ) as AgentRecord; + this.logActivity("registered", "agent", agent.id, agent.id, agent); + return agent; + } + + setAgentStatus(id: string, status: string): AgentRecord | null { + this.db.prepare("UPDATE agents SET status = ? WHERE id = ?").run(status, id); + const agent = this.getAgent(id); + if (agent) { + this.emitSse("agent.updated", agent); + } + return agent; + } + + getAgentStatus(id: string): { agent: AgentRecord; activeTaskRun: TaskRunWithRelations | null } | null { + const agent = this.getAgent(id); + if (!agent) { + return null; + } + + const activeTaskRun = + (this.db + .prepare( + `SELECT task_runs.*, tasks.name AS task_name, tasks.mission_id, phases.project_id, agents.name AS agent_name + FROM task_runs + JOIN tasks ON tasks.id = task_runs.task_id + JOIN missions ON missions.id = tasks.mission_id + JOIN phases ON phases.id = missions.phase_id + LEFT JOIN agents ON agents.id = task_runs.agent_id + WHERE task_runs.agent_id = ? AND task_runs.status = 'running' + ORDER BY task_runs.started_at DESC + LIMIT 1`, + ) + .get(id) as TaskRunWithRelations | undefined) ?? null; + + return { agent, activeTaskRun }; + } + + startMission(id: string): boolean { + const result = this.db.prepare("UPDATE missions SET status = 'running' WHERE id = ?").run(id); + this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); + return result.changes > 0; + } + + pauseMission(id: string): boolean { + const result = this.db.prepare("UPDATE missions SET status = 'paused' WHERE id = ?").run(id); + this.db.prepare("UPDATE tasks SET status = 'paused' WHERE mission_id = ? AND status IN ('pending', 'ready', 'running')").run(id); + return result.changes > 0; + } + + resumeMission(id: string): boolean { + const result = this.db.prepare("UPDATE missions SET status = 'running' WHERE id = ?").run(id); + this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); + return result.changes > 0; + } + + stopMission(id: string): boolean { + const result = this.db.prepare("UPDATE missions SET status = 'stopped' WHERE id = ?").run(id); + this.db.prepare("UPDATE tasks SET status = 'stopped' WHERE mission_id = ? AND status != 'completed'").run(id); + return result.changes > 0; + } + + logActivity(action: string, entityType: string, entityId: string, agentId: string | null, details: unknown): ActivityLogEntry { + const entry = this.db + .prepare("INSERT INTO activity_log (action, entity_type, entity_id, agent_id, details) VALUES (?, ?, ?, ?, ?) RETURNING *") + .get(action, entityType, entityId, agentId, JSON.stringify(details)) as ActivityLogEntry; + this.emitSse("activity_log", entry); + return entry; + } + + private emitSse(event: string, payload: unknown): void { + this.emit("sse", { + event, + data: payload, + }); + } +} diff --git a/workspace-daemon/src/types.ts b/workspace-daemon/src/types.ts new file mode 100644 index 00000000..e4eed058 --- /dev/null +++ b/workspace-daemon/src/types.ts @@ -0,0 +1,308 @@ +export type EntityStatus = + | "active" + | "pending" + | "ready" + | "running" + | "paused" + | "completed" + | "failed" + | "blocked" + | "approved" + | "rejected" + | "revised" + | "stopped" + | "idle"; + +export type TaskStatus = + | "pending" + | "ready" + | "running" + | "paused" + | "completed" + | "failed" + | "blocked" + | "stopped"; + +export type TaskRunStatus = + | "pending" + | "running" + | "awaiting_review" + | "completed" + | "failed" + | "paused" + | "stopped"; + +export type CheckpointStatus = "pending" | "approved" | "rejected" | "revised"; + +export type RunEventType = + | "started" + | "output" + | "tool_use" + | "checkpoint" + | "completed" + | "error" + | "status"; + +export type AgentAdapterType = "codex" | "claude" | "openclaw" | "ollama"; + +export interface Project { + id: string; + name: string; + path: string | null; + spec: string | null; + status: EntityStatus; + created_at: string; + updated_at: string; +} + +export interface Phase { + id: string; + project_id: string; + name: string; + sort_order: number; + status: EntityStatus; +} + +export interface Mission { + id: string; + phase_id: string; + name: string; + status: EntityStatus; + progress: number; +} + +export interface Task { + id: string; + mission_id: string; + name: string; + description: string | null; + agent_id: string | null; + status: TaskStatus; + sort_order: number; + depends_on: string | null; + created_at: string; + updated_at: string; +} + +export interface TaskRun { + id: string; + task_id: string; + agent_id: string | null; + status: TaskRunStatus; + attempt: number; + workspace_path: string | null; + started_at: string | null; + completed_at: string | null; + error: string | null; + input_tokens: number; + output_tokens: number; + cost_cents: number; +} + +export interface RunEvent { + id: number; + task_run_id: string; + type: RunEventType; + data: string | null; + created_at: string; +} + +export interface Checkpoint { + id: string; + task_run_id: string; + summary: string | null; + diff_stat: string | null; + status: CheckpointStatus; + reviewer_notes: string | null; + created_at: string; +} + +export interface Artifact { + id: string; + checkpoint_id: string; + type: string; + path: string; + created_at: string; +} + +export interface AgentRecord { + id: string; + name: string; + role: string; + adapter_type: AgentAdapterType; + adapter_config: string | null; + model: string | null; + status: EntityStatus; + capabilities: string | null; + created_at: string; +} + +export interface ActivityLogEntry { + id: number; + action: string; + entity_type: string; + entity_id: string; + agent_id: string | null; + details: string | null; + created_at: string; +} + +export interface ProjectDetail extends Project { + phases: Array< + Phase & { + missions: Array< + Mission & { + tasks: Task[]; + } + >; + } + >; +} + +export interface TaskWithRelations extends Task { + mission_name: string; + phase_id: string; + project_id: string; + project_name: string; +} + +export interface TaskRunWithRelations extends TaskRun { + task_name: string; + mission_id: string; + project_id: string; + agent_name: string | null; +} + +export interface DiffStat { + files_changed: number; + insertions: number; + deletions: number; +} + +export interface WorkflowHooks { + before_run?: string[]; + after_run?: string[]; + after_create?: string[]; +} + +export interface WorkflowConfig { + pollIntervalMs: number; + maxConcurrentAgents: number; + workspaceRoot: string; + autoApprove: boolean; + defaultAdapter: AgentAdapterType; + agentCommand?: string; + agentArgs?: string[]; + env?: Record; + hooks: WorkflowHooks; +} + +export interface WorkflowDefinition { + config: Record; + promptTemplate: string; +} + +export interface RetryEntry { + taskId: string; + identifier: string; + attempt: number; + dueAtMs: number; + error: string | null; +} + +export interface LiveSession { + sessionId: string; + threadId: string | null; + turnId: string | null; + processId: number | null; + lastEvent: string | null; + lastTimestamp: string | null; + lastMessage: string | null; + inputTokens: number; + outputTokens: number; + totalTokens: number; + turnCount: number; +} + +export interface RunningEntry { + taskId: string; + runId: string; + attempt: number; + workspacePath: string; + agentId: string | null; + startedAt: string; + session: LiveSession | null; +} + +export interface OrchestratorState { + pollIntervalMs: number; + maxConcurrentAgents: number; + running: Map; + claimed: Set; + retryAttempts: Map; + completed: Set; +} + +export interface AdapterStreamEvent { + type: RunEventType | "agent_message" | "turn.completed"; + message?: string; + data?: Record; +} + +export interface AgentExecutionRequest { + task: Task; + taskRun: TaskRun; + agent: AgentRecord; + workspacePath: string; + prompt: string; +} + +export interface AgentExecutionResult { + status: "completed" | "failed" | "stopped"; + summary: string; + inputTokens: number; + outputTokens: number; + costCents: number; + checkpointSummary?: string; + diffStat?: DiffStat; + error?: string; +} + +export interface SseEvent { + event: string; + data: Record; +} + +export interface CreateProjectInput { + name: string; + path?: string | null; + spec?: string | null; +} + +export interface CreateTaskInput { + mission_id: string; + name: string; + description?: string | null; + agent_id?: string | null; + status?: TaskStatus; + sort_order?: number; + depends_on?: string[] | null; +} + +export interface UpdateTaskInput { + name?: string; + description?: string | null; + agent_id?: string | null; + status?: TaskStatus; + sort_order?: number; + depends_on?: string[] | null; +} + +export interface RegisterAgentInput { + name: string; + role?: string; + adapter_type?: AgentAdapterType; + adapter_config?: Record; + model?: string | null; + capabilities?: Record; +} diff --git a/workspace-daemon/src/workspace.ts b/workspace-daemon/src/workspace.ts new file mode 100644 index 00000000..4fb020a0 --- /dev/null +++ b/workspace-daemon/src/workspace.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getWorkflowConfig } from "./config"; +import type { Project, Task, WorkflowHooks } from "./types"; + +const execFileAsync = promisify(execFile); + +function sanitizeSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase(); +} + +async function runHooks(commands: string[] | undefined, cwd: string): Promise { + if (!commands || commands.length === 0) { + return; + } + + for (const command of commands) { + await execFileAsync("zsh", ["-lc", command], { cwd }); + } +} + +export class WorkspaceManager { + async ensureWorkspace(project: Project, task: Task): Promise<{ path: string; createdNow: boolean; hooks: WorkflowHooks }> { + const workflowConfig = getWorkflowConfig(project.path); + const projectKey = sanitizeSegment(project.name || project.id); + const taskKey = sanitizeSegment(task.name || task.id); + const workspacePath = path.join(workflowConfig.workspaceRoot, projectKey, `${task.id}-${taskKey}`); + const createdNow = !fs.existsSync(workspacePath); + + fs.mkdirSync(workspacePath, { recursive: true }); + + if (project.path && fs.existsSync(project.path)) { + const manifestPath = path.join(workspacePath, ".workspace-source"); + if (!fs.existsSync(manifestPath)) { + fs.writeFileSync(manifestPath, `${project.path}\n`, "utf8"); + } + } + + if (createdNow) { + await runHooks(workflowConfig.hooks.after_create, workspacePath); + } + + return { + path: workspacePath, + createdNow, + hooks: workflowConfig.hooks, + }; + } + + async runBeforeRunHooks(workspacePath: string, hooks: WorkflowHooks): Promise { + await runHooks(hooks.before_run, workspacePath); + } + + async runAfterRunHooks(workspacePath: string, hooks: WorkflowHooks): Promise { + await runHooks(hooks.after_run, workspacePath); + } +} diff --git a/workspace-daemon/tsconfig.json b/workspace-daemon/tsconfig.json new file mode 100644 index 00000000..d6af5b5b --- /dev/null +++ b/workspace-daemon/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src"] +} From bd3f61129058c4914f3c128697b832043d4274ee Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 17:43:47 -0400 Subject: [PATCH 002/348] =?UTF-8?q?feat:=20wave=201=20complete=20=E2=80=94?= =?UTF-8?q?=20e2e=20test,=20claude=20adapter,=20decomposer,=20phases/missi?= =?UTF-8?q?ons=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E2E test: 10/10 pass (project→phase→mission→tasks→deps→agent→start) - Claude adapter: real CLI spawn, timeout, token extraction (207 lines) - Decomposer: Claude-backed goal→tasks with JSON parsing + fallback (151 lines) - Decompose route: /api/decompose with project context + auto-create tasks - Phases route: POST /api/phases CRUD - Missions route: POST /api/missions create + start/pause/resume/stop - Tracker: createPhase, createMission, getMission, refreshMissionTaskStatuses - Types: CreatePhaseInput, CreateMissionInput, MissionWithProjectContext, DecomposedTask - tsc clean --- workspace-daemon/src/adapters/claude.ts | 182 +++++++++++++++++----- workspace-daemon/src/decomposer.ts | 151 ++++++++++++++++++ workspace-daemon/src/routes/decompose.ts | 119 +++++++++++++++ workspace-daemon/src/routes/missions.ts | 32 +++- workspace-daemon/src/routes/phases.ts | 34 +++++ workspace-daemon/src/server.ts | 6 +- workspace-daemon/src/tracker.ts | 83 ++++++++++ workspace-daemon/src/types.ts | 37 +++++ workspace-daemon/test/e2e.sh | 186 +++++++++++++++++++++++ workspace-daemon/tsconfig.json | 1 + 10 files changed, 789 insertions(+), 42 deletions(-) create mode 100644 workspace-daemon/src/decomposer.ts create mode 100644 workspace-daemon/src/routes/decompose.ts create mode 100644 workspace-daemon/src/routes/phases.ts create mode 100755 workspace-daemon/test/e2e.sh diff --git a/workspace-daemon/src/adapters/claude.ts b/workspace-daemon/src/adapters/claude.ts index a9839ef4..2fa29504 100644 --- a/workspace-daemon/src/adapters/claude.ts +++ b/workspace-daemon/src/adapters/claude.ts @@ -1,40 +1,138 @@ import { spawn } from "node:child_process"; -import type { AgentAdapter } from "./types"; +import type { AgentAdapter, AgentAdapterContext } from "./types"; import type { AgentExecutionRequest, AgentExecutionResult } from "../types"; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +function parseAdapterConfig(config: string | null): Record { + if (!config || config.trim().length === 0) { + return {}; + } + + try { + return JSON.parse(config) as Record; + } catch { + return {}; + } +} + +function toPositiveNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function extractTokenUsage(output: string): { inputTokens: number; outputTokens: number } { + const usage = { + inputTokens: 0, + outputTokens: 0, + }; + + const normalized = output.replace(/\r/g, ""); + const patterns: Array<{ key: "inputTokens" | "outputTokens"; regex: RegExp }> = [ + { key: "inputTokens", regex: /\b(?:input|prompt)[ _-]?tokens?\b[^0-9]{0,20}(\d[\d,]*)/i }, + { key: "outputTokens", regex: /\b(?:output|completion)[ _-]?tokens?\b[^0-9]{0,20}(\d[\d,]*)/i }, + { key: "inputTokens", regex: /\binput_tokens\b[^0-9]{0,20}(\d[\d,]*)/i }, + { key: "outputTokens", regex: /\boutput_tokens\b[^0-9]{0,20}(\d[\d,]*)/i }, + ]; + + for (const { key, regex } of patterns) { + const match = normalized.match(regex); + if (!match) { + continue; + } + + const parsed = Number.parseInt(match[1].replaceAll(",", ""), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + usage[key] = parsed; + } + } + + return usage; +} + +function summarizeResponse(response: string): string { + const normalized = response.trim(); + if (!normalized) { + return "Completed"; + } + + const singleLine = normalized.replace(/\s+/g, " ").trim(); + if (singleLine.length <= 280) { + return singleLine; + } + + return `${singleLine.slice(0, 277).trimEnd()}...`; +} + +function getFailureMessage(stderr: string, code: number | null, timedOut: boolean): string { + const trimmed = stderr.trim(); + if (trimmed) { + return trimmed; + } + + if (timedOut) { + return "Claude execution timed out"; + } + + return `Process exited with code ${code ?? -1}`; +} + export class ClaudeAdapter implements AgentAdapter { readonly type = "claude"; - async execute(request: AgentExecutionRequest, context: { signal?: AbortSignal; onEvent: (event: any) => void }): Promise { + async execute(request: AgentExecutionRequest, context: AgentAdapterContext): Promise { return new Promise((resolve) => { - const parsedConfig = - request.agent.adapter_config && request.agent.adapter_config.trim().length > 0 - ? (JSON.parse(request.agent.adapter_config) as Record) - : {}; - const command = typeof parsedConfig.command === "string" ? parsedConfig.command : "claude"; - const proc = spawn( - command, - ["--print", "--permission-mode", "bypassPermissions", "-m", request.prompt], - { - cwd: request.workspacePath, - stdio: ["ignore", "pipe", "pipe"], - env: process.env, - }, - ); + const parsedConfig = parseAdapterConfig(request.agent.adapter_config); + const command = typeof parsedConfig.command === "string" && parsedConfig.command.trim().length > 0 ? parsedConfig.command : "claude"; + const timeoutMs = toPositiveNumber(parsedConfig.timeoutMs) ?? DEFAULT_TIMEOUT_MS; + const taskPrompt = request.prompt; + const proc = spawn(command, ["--print", "--permission-mode", "bypassPermissions", "-p", taskPrompt], { + cwd: request.workspacePath, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); let stdout = ""; let stderr = ""; let settled = false; + let timedOut = false; + let forceKillHandle: NodeJS.Timeout | null = null; + + const timeoutHandle = setTimeout(() => { + timedOut = true; + context.onEvent({ + type: "status", + message: `Claude execution timed out after ${Math.round(timeoutMs / 1000)}s`, + }); + proc.kill("SIGTERM"); + forceKillHandle = setTimeout(() => { + proc.kill("SIGKILL"); + }, 5000); + }, timeoutMs); + + const cleanup = (): void => { + clearTimeout(timeoutHandle); + if (forceKillHandle) { + clearTimeout(forceKillHandle); + forceKillHandle = null; + } + context.signal?.removeEventListener("abort", handleAbort); + }; const settle = (result: AgentExecutionResult): void => { - if (!settled) { - settled = true; - resolve(result); + if (settled) { + return; } + + settled = true; + cleanup(); + resolve(result); }; - context.signal?.addEventListener("abort", () => { + const handleAbort = (): void => { proc.kill("SIGTERM"); + forceKillHandle = setTimeout(() => { + proc.kill("SIGKILL"); + }, 5000); settle({ status: "stopped", summary: "Run aborted", @@ -43,39 +141,52 @@ export class ClaudeAdapter implements AgentAdapter { costCents: 0, error: "Aborted", }); - }); + }; + + context.signal?.addEventListener("abort", handleAbort, { once: true }); proc.stdout.setEncoding("utf8"); proc.stdout.on("data", (chunk: string) => { stdout += chunk; - context.onEvent({ type: "output", message: chunk.trim() }); + context.onEvent({ + type: "output", + message: chunk, + }); }); proc.stderr.setEncoding("utf8"); proc.stderr.on("data", (chunk: string) => { stderr += chunk; - context.onEvent({ type: "error", message: chunk.trim() }); + context.onEvent({ + type: "error", + message: chunk, + }); }); proc.on("error", (error) => { + const usage = extractTokenUsage(stdout); settle({ status: "failed", - summary: stdout.trim() || "Claude execution failed", - inputTokens: 0, - outputTokens: 0, + summary: summarizeResponse(stdout) || "Claude execution failed", + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, costCents: 0, error: error.message, }); }); proc.on("close", (code) => { - if (code === 0) { + const response = stdout.trim(); + const usage = extractTokenUsage(stdout); + + if (code === 0 && !timedOut) { + const summary = summarizeResponse(response); settle({ status: "completed", - summary: stdout.trim() || "Completed", - checkpointSummary: stdout.trim() || "Completed", - inputTokens: 0, - outputTokens: 0, + summary, + checkpointSummary: response || summary, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, costCents: 0, }); return; @@ -83,11 +194,12 @@ export class ClaudeAdapter implements AgentAdapter { settle({ status: "failed", - summary: stdout.trim() || "Claude execution failed", - inputTokens: 0, - outputTokens: 0, + summary: summarizeResponse(response) || "Claude execution failed", + checkpointSummary: response || undefined, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, costCents: 0, - error: stderr.trim() || `Process exited with code ${code ?? -1}`, + error: getFailureMessage(stderr, code, timedOut), }); }); }); diff --git a/workspace-daemon/src/decomposer.ts b/workspace-daemon/src/decomposer.ts new file mode 100644 index 00000000..fc5850fe --- /dev/null +++ b/workspace-daemon/src/decomposer.ts @@ -0,0 +1,151 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { AgentAdapterType, DecomposeResult, DecomposerContext, DecomposedTask } from "./types"; + +const execFileAsync = promisify(execFile); + +const SYSTEM_PROMPT = [ + "You are a task decomposition engine for an engineering workspace daemon.", + "Return only a valid JSON array with no markdown fences and no surrounding explanation.", + "Each array item must be an object with keys:", + "name, description, estimated_minutes, depends_on, suggested_agent_type.", + "Use concise but actionable task names and descriptions.", + "estimated_minutes must be a positive integer.", + "depends_on must be an array of task names from the same response.", + "suggested_agent_type must be one of: codex, claude, openclaw, ollama, or null.", +].join(" "); + +const VALID_AGENT_TYPES = new Set(["codex", "claude", "openclaw", "ollama"]); + +function buildPrompt(goal: string, context?: DecomposerContext): string { + const lines = [ + `System instructions: ${SYSTEM_PROMPT}`, + "", + "Decompose the following goal into implementation tasks.", + `Goal: ${goal.trim()}`, + ]; + + if (context?.project_path) { + lines.push(`Project path: ${context.project_path}`); + } + + if (context?.project_spec) { + lines.push("Project spec:"); + lines.push(context.project_spec); + } + + if (context?.existing_files && context.existing_files.length > 0) { + lines.push("Existing files:"); + lines.push(...context.existing_files.map((file) => `- ${file}`)); + } + + return lines.join("\n"); +} + +function extractJsonArray(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed; + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fencedMatch) { + const candidate = fencedMatch[1]?.trim(); + if (candidate?.startsWith("[") && candidate.endsWith("]")) { + return candidate; + } + } + + const start = trimmed.indexOf("["); + const end = trimmed.lastIndexOf("]"); + if (start >= 0 && end > start) { + return trimmed.slice(start, end + 1); + } + + return null; +} + +function toPositiveInteger(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.round(value); + } + + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + + return fallback; +} + +function normalizeAgentType(value: unknown): AgentAdapterType | null { + if (typeof value !== "string") { + return null; + } + + return VALID_AGENT_TYPES.has(value as AgentAdapterType) ? (value as AgentAdapterType) : null; +} + +function normalizeTask(value: unknown, index: number): DecomposedTask { + const candidate = typeof value === "object" && value !== null ? (value as Record) : {}; + const fallbackName = `Task ${index + 1}`; + const name = typeof candidate.name === "string" && candidate.name.trim().length > 0 ? candidate.name.trim() : fallbackName; + const description = + typeof candidate.description === "string" && candidate.description.trim().length > 0 + ? candidate.description.trim() + : name; + const depends_on = Array.isArray(candidate.depends_on) + ? candidate.depends_on.filter((dependency): dependency is string => typeof dependency === "string" && dependency.trim().length > 0) + : []; + + return { + name, + description, + estimated_minutes: toPositiveInteger(candidate.estimated_minutes, 30), + depends_on, + suggested_agent_type: normalizeAgentType(candidate.suggested_agent_type), + }; +} + +export class Decomposer { + async decompose(goal: string, context?: DecomposerContext): Promise { + const prompt = buildPrompt(goal, context); + const { stdout } = await execFileAsync("claude", ["--print", "-p", prompt], { + maxBuffer: 1024 * 1024, + timeout: 120_000, + }); + const rawResponse = stdout.trim(); + const jsonPayload = extractJsonArray(rawResponse); + + if (jsonPayload) { + try { + const parsed = JSON.parse(jsonPayload) as unknown; + if (Array.isArray(parsed)) { + return { + tasks: parsed.map((task, index) => normalizeTask(task, index)), + rawResponse, + parsed: true, + }; + } + } catch { + // Fall through to the raw-text fallback below. + } + } + + return { + tasks: [ + { + name: goal.trim().slice(0, 80) || "Task decomposition", + description: rawResponse || goal.trim(), + estimated_minutes: 30, + depends_on: [], + suggested_agent_type: "claude", + }, + ], + rawResponse, + parsed: false, + }; + } +} diff --git a/workspace-daemon/src/routes/decompose.ts b/workspace-daemon/src/routes/decompose.ts new file mode 100644 index 00000000..907305ab --- /dev/null +++ b/workspace-daemon/src/routes/decompose.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Router } from "express"; +import { Decomposer } from "../decomposer"; +import { Tracker } from "../tracker"; +import type { DecomposedTask } from "../types"; + +const MAX_CONTEXT_FILES = 200; +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".data", "dist"]); + +async function collectExistingFiles(projectPath: string, maxFiles = MAX_CONTEXT_FILES): Promise { + const files: string[] = []; + const queue = [projectPath]; + + while (queue.length > 0 && files.length < maxFiles) { + const currentPath = queue.shift(); + if (!currentPath) continue; + + let entries; + try { + entries = await fs.readdir(currentPath, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (files.length >= maxFiles) break; + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + queue.push(path.join(currentPath, entry.name)); + } + } else if (entry.isFile()) { + files.push(path.relative(projectPath, path.join(currentPath, entry.name))); + } + } + } + + return files.sort(); +} + +async function createTasksForMission(tracker: Tracker, missionId: string, tasks: DecomposedTask[]): Promise { + const createdTasks = tasks.map((task, index) => + tracker.createTask({ + mission_id: missionId, + name: task.name, + description: task.description, + sort_order: index, + depends_on: [], + }), + ); + + const idByName = new Map(createdTasks.map((task, i) => [tasks[i]?.name, task.id] as const)); + + createdTasks.forEach((createdTask, index) => { + const depIds = (tasks[index]?.depends_on ?? []) + .map((name) => idByName.get(name)) + .filter((id): id is string => typeof id === "string"); + + if (depIds.length > 0) { + tracker.updateTask(createdTask.id, { depends_on: depIds }); + } + }); +} + +export function createDecomposeRouter(tracker: Tracker): Router { + const router = Router(); + const decomposer = new Decomposer(); + + router.post("/", async (req, res) => { + const { goal, project_id, mission_id } = req.body as { + goal?: string; + project_id?: string; + mission_id?: string; + }; + + if (!goal || goal.trim().length === 0) { + res.status(400).json({ error: "goal is required" }); + return; + } + + const missionContext = mission_id ? tracker.getMissionWithProjectContext(mission_id) : null; + if (mission_id && !missionContext) { + res.status(404).json({ error: "Mission not found" }); + return; + } + + const project = project_id ? tracker.getProject(project_id) : null; + if (project_id && !project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + const projectPath = project?.path ?? missionContext?.project_path ?? null; + const projectSpec = project?.spec ?? missionContext?.project_spec ?? null; + + try { + const existingFiles = projectPath ? await collectExistingFiles(projectPath) : []; + const result = await decomposer.decompose(goal, { + project_path: projectPath, + project_spec: projectSpec, + existing_files: existingFiles, + }); + + if (mission_id) { + await createTasksForMission(tracker, mission_id, result.tasks); + } + + res.json({ + tasks: result.tasks, + ...(result.parsed ? {} : { raw_response: result.rawResponse }), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }); + + return router; +} diff --git a/workspace-daemon/src/routes/missions.ts b/workspace-daemon/src/routes/missions.ts index c1e30278..292af4dd 100644 --- a/workspace-daemon/src/routes/missions.ts +++ b/workspace-daemon/src/routes/missions.ts @@ -1,17 +1,38 @@ import { Router } from "express"; import { Tracker } from "../tracker"; -import { Orchestrator } from "../orchestrator"; -export function createMissionsRouter(tracker: Tracker, orchestrator: Orchestrator): Router { +export function createMissionsRouter(tracker: Tracker): Router { const router = Router(); - router.post("/:id/start", async (req, res) => { + router.post("/", (req, res) => { + const { phase_id, name } = req.body as { + phase_id?: string; + name?: string; + }; + + if (!phase_id || !name || name.trim().length === 0) { + res.status(400).json({ error: "phase_id and name are required" }); + return; + } + + if (!tracker.getPhase(phase_id)) { + res.status(404).json({ error: "Phase not found" }); + return; + } + + const mission = tracker.createMission({ + phase_id, + name: name.trim(), + }); + res.status(201).json(mission); + }); + + router.post("/:id/start", (req, res) => { const ok = tracker.startMission(req.params.id); if (!ok) { res.status(404).json({ error: "Mission not found" }); return; } - await orchestrator.tick(); res.json({ ok: true }); }); @@ -24,13 +45,12 @@ export function createMissionsRouter(tracker: Tracker, orchestrator: Orchestrato res.json({ ok: true }); }); - router.post("/:id/resume", async (req, res) => { + router.post("/:id/resume", (req, res) => { const ok = tracker.resumeMission(req.params.id); if (!ok) { res.status(404).json({ error: "Mission not found" }); return; } - await orchestrator.tick(); res.json({ ok: true }); }); diff --git a/workspace-daemon/src/routes/phases.ts b/workspace-daemon/src/routes/phases.ts new file mode 100644 index 00000000..6c7be73d --- /dev/null +++ b/workspace-daemon/src/routes/phases.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { Tracker } from "../tracker"; + +export function createPhasesRouter(tracker: Tracker): Router { + const router = Router(); + + router.post("/", (req, res) => { + const { project_id, name, sort_order } = req.body as { + project_id?: string; + name?: string; + sort_order?: number; + }; + + if (!project_id || !name || name.trim().length === 0) { + res.status(400).json({ error: "project_id and name are required" }); + return; + } + + const project = tracker.getProject(project_id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + const phase = tracker.createPhase({ + project_id, + name: name.trim(), + sort_order, + }); + res.status(201).json(phase); + }); + + return router; +} diff --git a/workspace-daemon/src/server.ts b/workspace-daemon/src/server.ts index 519f4a9b..3753c274 100644 --- a/workspace-daemon/src/server.ts +++ b/workspace-daemon/src/server.ts @@ -9,6 +9,8 @@ import { createAgentsRouter } from "./routes/agents"; import { createMissionsRouter } from "./routes/missions"; import { registerEventsRoutes } from "./routes/events"; import { createCheckpointsRouter } from "./routes/checkpoints"; +import { createPhasesRouter } from "./routes/phases"; +import { createDecomposeRouter } from "./routes/decompose"; const PORT = Number(process.env.PORT ?? 3001); @@ -25,10 +27,12 @@ export function createServer(): { app: express.Express; tracker: Tracker; orches }); app.use("/api/projects", createProjectsRouter(tracker)); + app.use("/api/phases", createPhasesRouter(tracker)); app.use("/api/tasks", createTasksRouter(tracker, orchestrator)); app.use("/api/agents", createAgentsRouter(tracker)); - app.use("/api/missions", createMissionsRouter(tracker, orchestrator)); + app.use("/api/missions", createMissionsRouter(tracker)); app.use("/api/checkpoints", createCheckpointsRouter(tracker)); + app.use("/api/decompose", createDecomposeRouter(tracker)); const eventsRouter = Router(); registerEventsRoutes(eventsRouter, tracker); diff --git a/workspace-daemon/src/tracker.ts b/workspace-daemon/src/tracker.ts index fc3caae0..32ce174e 100644 --- a/workspace-daemon/src/tracker.ts +++ b/workspace-daemon/src/tracker.ts @@ -5,8 +5,13 @@ import type { ActivityLogEntry, AgentRecord, Checkpoint, + CreateMissionInput, + CreatePhaseInput, CreateProjectInput, CreateTaskInput, + Mission, + MissionWithProjectContext, + Phase, Project, ProjectDetail, RegisterAgentInput, @@ -58,6 +63,53 @@ export class Tracker extends EventEmitter { return project; } + createPhase(input: CreatePhaseInput): Phase { + const phase = this.db + .prepare( + "INSERT INTO phases (project_id, name, sort_order) VALUES (@project_id, @name, @sort_order) RETURNING *", + ) + .get({ + project_id: input.project_id, + name: input.name, + sort_order: input.sort_order ?? 0, + }) as Phase; + this.logActivity("created", "phase", phase.id, null, phase); + return phase; + } + + createMission(input: CreateMissionInput): Mission { + const mission = this.db + .prepare("INSERT INTO missions (phase_id, name) VALUES (@phase_id, @name) RETURNING *") + .get({ + phase_id: input.phase_id, + name: input.name, + }) as Mission; + this.logActivity("created", "mission", mission.id, null, mission); + return mission; + } + + getPhase(id: string): Phase | null { + return (this.db.prepare("SELECT * FROM phases WHERE id = ?").get(id) as Phase | undefined) ?? null; + } + + getMission(id: string): Mission | null { + return (this.db.prepare("SELECT * FROM missions WHERE id = ?").get(id) as Mission | undefined) ?? null; + } + + getMissionWithProjectContext(id: string): MissionWithProjectContext | null { + return ( + (this.db + .prepare( + `SELECT missions.*, phases.project_id, projects.path AS project_path, projects.spec AS project_spec + FROM missions + JOIN phases ON phases.id = missions.phase_id + JOIN projects ON projects.id = phases.project_id + WHERE missions.id = ?`, + ) + .get(id) as MissionWithProjectContext | undefined) ?? null + ); + } + getProject(id: string): Project | null { return (this.db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Project | undefined) ?? null; } @@ -221,6 +273,31 @@ export class Tracker extends EventEmitter { return this.getTask(id); } + refreshMissionTaskStatuses(missionId: string): TaskWithRelations[] { + const tasks = this.listTasks({ mission_id: missionId }); + const completedTaskIds = new Set( + tasks.filter((task) => task.status === "completed").map((task) => task.id), + ); + const ready: TaskWithRelations[] = []; + + for (const task of tasks) { + if (task.status !== "pending") { + continue; + } + + const dependencies = parseJsonOrDefault(task.depends_on, []); + const isReady = dependencies.every((dependencyId) => completedTaskIds.has(dependencyId)); + if (!isReady) { + continue; + } + + const updated = this.setTaskStatus(task.id, "ready"); + ready.push(updated ? { ...task, status: updated.status } : { ...task, status: "ready" }); + } + + return ready; + } + resolveReadyTasks(limit: number): TaskWithRelations[] { const tasks = this.listTasks({ status: "pending" }); const byId = new Map(tasks.map((task) => [task.id, task])); @@ -421,6 +498,9 @@ export class Tracker extends EventEmitter { startMission(id: string): boolean { const result = this.db.prepare("UPDATE missions SET status = 'running' WHERE id = ?").run(id); this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); + if (result.changes > 0) { + this.refreshMissionTaskStatuses(id); + } return result.changes > 0; } @@ -433,6 +513,9 @@ export class Tracker extends EventEmitter { resumeMission(id: string): boolean { const result = this.db.prepare("UPDATE missions SET status = 'running' WHERE id = ?").run(id); this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); + if (result.changes > 0) { + this.refreshMissionTaskStatuses(id); + } return result.changes > 0; } diff --git a/workspace-daemon/src/types.ts b/workspace-daemon/src/types.ts index e4eed058..cec90f9b 100644 --- a/workspace-daemon/src/types.ts +++ b/workspace-daemon/src/types.ts @@ -71,6 +71,12 @@ export interface Mission { progress: number; } +export interface MissionWithProjectContext extends Mission { + project_id: string; + project_path: string | null; + project_spec: string | null; +} + export interface Task { id: string; mission_id: string; @@ -279,6 +285,17 @@ export interface CreateProjectInput { spec?: string | null; } +export interface CreatePhaseInput { + project_id: string; + name: string; + sort_order?: number; +} + +export interface CreateMissionInput { + phase_id: string; + name: string; +} + export interface CreateTaskInput { mission_id: string; name: string; @@ -306,3 +323,23 @@ export interface RegisterAgentInput { model?: string | null; capabilities?: Record; } + +export interface DecomposerContext { + project_path?: string | null; + project_spec?: string | null; + existing_files?: string[]; +} + +export interface DecomposedTask { + name: string; + description: string; + estimated_minutes: number; + depends_on: string[]; + suggested_agent_type: AgentAdapterType | null; +} + +export interface DecomposeResult { + tasks: DecomposedTask[]; + rawResponse: string; + parsed: boolean; +} diff --git a/workspace-daemon/test/e2e.sh b/workspace-daemon/test/e2e.sh new file mode 100755 index 00000000..4baab669 --- /dev/null +++ b/workspace-daemon/test/e2e.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT=3099 +BASE_URL="http://127.0.0.1:${PORT}" +TMP_DIR="$(mktemp -d)" +DB_PATH="${TMP_DIR}/workspace-daemon.sqlite" +PROJECT_DIR="${TMP_DIR}/project" +LOG_PATH="${TMP_DIR}/daemon.log" +DAEMON_PID="" +FAILURES=0 +CHECKS=0 + +mkdir -p "${PROJECT_DIR}" + +cleanup() { + if [[ -n "${DAEMON_PID}" ]] && kill -0 "${DAEMON_PID}" 2>/dev/null; then + kill "${DAEMON_PID}" 2>/dev/null || true + wait "${DAEMON_PID}" 2>/dev/null || true + fi + rm -rf "${TMP_DIR}" +} + +trap cleanup EXIT + +record_pass() { + CHECKS=$((CHECKS + 1)) + printf 'PASS: %s\n' "$1" +} + +record_fail() { + CHECKS=$((CHECKS + 1)) + FAILURES=$((FAILURES + 1)) + printf 'FAIL: %s\n' "$1" >&2 +} + +extract_json() { + local input="$1" + local expression="$2" + printf '%s' "${input}" | node -e ' +const fs = require("fs"); +const expression = process.argv[1]; +const data = JSON.parse(fs.readFileSync(0, "utf8")); +const value = expression + .split(".") + .filter(Boolean) + .reduce((current, key) => (current == null ? undefined : current[key]), data); +if (value === undefined) { + process.exit(1); +} +if (typeof value === "object") { + process.stdout.write(JSON.stringify(value)); +} else { + process.stdout.write(String(value)); +} +' "${expression}" +} + +assert_task_statuses() { + local tasks_json="$1" + local task_a_id="$2" + local task_b_id="$3" + local task_c_id="$4" + + TASKS_JSON="${tasks_json}" TASK_A_ID="${task_a_id}" TASK_B_ID="${task_b_id}" TASK_C_ID="${task_c_id}" node - <<'EOF' +const tasks = JSON.parse(process.env.TASKS_JSON); +const expected = new Map([ + [process.env.TASK_A_ID, "ready"], + [process.env.TASK_B_ID, "pending"], + [process.env.TASK_C_ID, "pending"], +]); + +for (const [taskId, status] of expected) { + const task = tasks.find((entry) => entry.id === taskId); + if (!task) { + console.error(`Missing task ${taskId}`); + process.exit(1); + } + if (task.status !== status) { + console.error(`Task ${task.name} expected ${status} but got ${task.status}`); + process.exit(1); + } +} +EOF +} + +request() { + local method="$1" + local path="$2" + local body="${3:-}" + local response + local status + + if [[ -n "${body}" ]]; then + response="$(curl -sS -X "${method}" "${BASE_URL}${path}" -H 'Content-Type: application/json' -d "${body}" -w $'\n%{http_code}')" + else + response="$(curl -sS -X "${method}" "${BASE_URL}${path}" -w $'\n%{http_code}')" + fi + + status="${response##*$'\n'}" + REQUEST_BODY="${response%$'\n'*}" + + if [[ "${status}" -lt 200 || "${status}" -ge 300 ]]; then + printf 'Request failed: %s %s\nStatus: %s\nBody: %s\n' "${method}" "${path}" "${status}" "${REQUEST_BODY}" >&2 + return 1 + fi +} + +printf 'Starting daemon on port %s\n' "${PORT}" +( + cd "${ROOT_DIR}" && + PORT="${PORT}" WORKSPACE_DAEMON_DB_PATH="${DB_PATH}" npm start +) >"${LOG_PATH}" 2>&1 & +DAEMON_PID=$! + +for _ in $(seq 1 60); do + if curl -fsS "${BASE_URL}/health" >/dev/null 2>&1; then + record_pass "health check reachable" + break + fi + sleep 0.5 +done + +if ! curl -fsS "${BASE_URL}/health" >/dev/null 2>&1; then + record_fail "health check reachable" + printf 'Daemon log:\n' >&2 + cat "${LOG_PATH}" >&2 + printf 'Summary: %s checks, %s failed\n' "${CHECKS}" "${FAILURES}" >&2 + exit 1 +fi + +request POST /api/projects "{\"name\":\"E2E Project\",\"path\":\"${PROJECT_DIR}\"}" +PROJECT_JSON="${REQUEST_BODY}" +PROJECT_ID="$(extract_json "${PROJECT_JSON}" "id")" +record_pass "project created" + +request POST /api/phases "{\"project_id\":\"${PROJECT_ID}\",\"name\":\"Phase 1\",\"sort_order\":1}" +PHASE_JSON="${REQUEST_BODY}" +PHASE_ID="$(extract_json "${PHASE_JSON}" "id")" +record_pass "phase created" + +request POST /api/missions "{\"phase_id\":\"${PHASE_ID}\",\"name\":\"Mission 1\"}" +MISSION_JSON="${REQUEST_BODY}" +MISSION_ID="$(extract_json "${MISSION_JSON}" "id")" +record_pass "mission created" + +request POST /api/tasks "{\"mission_id\":\"${MISSION_ID}\",\"name\":\"Task A\",\"sort_order\":1}" +TASK_A_ID="$(extract_json "${REQUEST_BODY}" "id")" +record_pass "task A created" + +request POST /api/tasks "{\"mission_id\":\"${MISSION_ID}\",\"name\":\"Task B\",\"sort_order\":2,\"depends_on\":[\"${TASK_A_ID}\"]}" +TASK_B_ID="$(extract_json "${REQUEST_BODY}" "id")" +record_pass "task B created" + +request POST /api/tasks "{\"mission_id\":\"${MISSION_ID}\",\"name\":\"Task C\",\"sort_order\":3,\"depends_on\":[\"${TASK_A_ID}\",\"${TASK_B_ID}\"]}" +TASK_C_ID="$(extract_json "${REQUEST_BODY}" "id")" +record_pass "task C created" + +request POST /api/agents '{"name":"codex-e2e","role":"coder","adapter_type":"codex"}' +AGENT_ID="$(extract_json "${REQUEST_BODY}" "id")" +if [[ -n "${AGENT_ID}" ]]; then + record_pass "codex agent registered" +else + record_fail "codex agent registered" +fi + +request POST "/api/missions/${MISSION_ID}/start" +record_pass "mission started" + +request GET "/api/tasks?mission_id=${MISSION_ID}" +TASKS_JSON="${REQUEST_BODY}" +if assert_task_statuses "${TASKS_JSON}" "${TASK_A_ID}" "${TASK_B_ID}" "${TASK_C_ID}"; then + record_pass "task dependency statuses verified" +else + record_fail "task dependency statuses verified" +fi + +if [[ "${FAILURES}" -eq 0 ]]; then + printf 'Summary: PASS (%s checks)\n' "${CHECKS}" + exit 0 +fi + +printf 'Summary: FAIL (%s/%s checks failed)\n' "${FAILURES}" "${CHECKS}" >&2 +exit 1 diff --git a/workspace-daemon/tsconfig.json b/workspace-daemon/tsconfig.json index d6af5b5b..b0dfb395 100644 --- a/workspace-daemon/tsconfig.json +++ b/workspace-daemon/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, + "types": ["node"], "strict": true, "outDir": "dist", "rootDir": "src", From e16e2cb38284bc3d5166f3d190df1edb00b08e8d Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 17:49:55 -0400 Subject: [PATCH 003/348] =?UTF-8?q?feat:=20wave=202=20complete=20=E2=80=94?= =?UTF-8?q?=20git=20worktrees,=20checkpoint=20builder,=20mission=20progres?= =?UTF-8?q?s,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Git worktree isolation: auto-creates task branches, falls back to dirs - Checkpoint builder: git diff --stat, auto-approve, auto-commit - Mission progress: getMissionStatus(), SSE events, ETA estimates - Mission status route: GET /api/missions/:id/status - Cleanup: port 3002, .gitignore, README.md, removed BUILD-TASK.md - WorkspaceInfo type with git_worktree flag - tsc clean, e2e 10/10 pass --- workspace-daemon/.gitignore | 5 + workspace-daemon/BUILD-TASK.md | 293 --------------------- workspace-daemon/README.md | 52 ++++ workspace-daemon/src/checkpoint-builder.ts | 60 +++++ workspace-daemon/src/orchestrator.ts | 2 +- workspace-daemon/src/routes/missions.ts | 9 + workspace-daemon/src/server.ts | 2 +- workspace-daemon/src/tracker.ts | 130 ++++++++- workspace-daemon/src/types.ts | 32 +++ workspace-daemon/src/workspace.ts | 57 +++- 10 files changed, 341 insertions(+), 301 deletions(-) create mode 100644 workspace-daemon/.gitignore delete mode 100644 workspace-daemon/BUILD-TASK.md create mode 100644 workspace-daemon/README.md create mode 100644 workspace-daemon/src/checkpoint-builder.ts diff --git a/workspace-daemon/.gitignore b/workspace-daemon/.gitignore new file mode 100644 index 00000000..602e00d0 --- /dev/null +++ b/workspace-daemon/.gitignore @@ -0,0 +1,5 @@ +.data/ +node_modules/ +dist/ +*.sqlite +*.sqlite-journal diff --git a/workspace-daemon/BUILD-TASK.md b/workspace-daemon/BUILD-TASK.md deleted file mode 100644 index b0b73b26..00000000 --- a/workspace-daemon/BUILD-TASK.md +++ /dev/null @@ -1,293 +0,0 @@ -# Workspace Daemon — Build Task - -## What You're Building -A standalone TypeScript daemon that orchestrates AI coding agents. It runs on `localhost:3001`, accepts tasks via REST API, spawns agents in isolated workspaces, tracks progress, and streams events via SSE. - -This is the backend framework for ClawSuite Workspace. No UI — just the engine. - -## Architecture Sources (READ THESE) -1. **Symphony SPEC.md** (`~/.openclaw/workspace/symphony/SPEC.md`) — The orchestration logic. Reimplement the orchestrator state machine, workspace manager, agent runner, and WORKFLOW.md parser in TypeScript. -2. **Paperclip DB schema** (`~/.openclaw/workspace/paperclip/packages/db/src/schema/`) — Reference for data model patterns (projects, agents, issues, approvals, costs, activity_log). We adapt these for SQLite with better-sqlite3. -3. **Paperclip adapters** (`~/.openclaw/workspace/paperclip/packages/adapters/`) — Agent adapter patterns for Claude, Codex, OpenClaw. -4. **ClawSuite Workspace Spec** (`~/.openclaw/workspace/CLAWSUITE-WORKSPACE-SPEC.md`) — Our full product spec with data model. - -## File Structure -``` -workspace-daemon/ -├── package.json -├── tsconfig.json -├── src/ -│ ├── server.ts # Express server, port 3001, SSE endpoint -│ ├── orchestrator.ts # Poll-dispatch loop (from Symphony) -│ ├── workspace.ts # Per-task workspace isolation + lifecycle hooks -│ ├── agent-runner.ts # Spawn agents, stream events back -│ ├── config.ts # WORKFLOW.md parser (YAML frontmatter + prompt) -│ ├── tracker.ts # Local task store (replaces Symphony's Linear adapter) -│ ├── db/ -│ │ ├── index.ts # better-sqlite3 setup -│ │ └── schema.sql # CREATE TABLE statements -│ ├── adapters/ -│ │ ├── types.ts # AgentAdapter interface -│ │ ├── codex.ts # Codex App Server JSON-RPC over stdio -│ │ ├── claude.ts # Claude Code CLI (--print mode) -│ │ └── openclaw.ts # OpenClaw Gateway sessions_spawn -│ ├── routes/ -│ │ ├── projects.ts # CRUD for projects -│ │ ├── tasks.ts # CRUD for tasks + task runs -│ │ ├── agents.ts # Agent registry -│ │ ├── missions.ts # Mission lifecycle (start/pause/resume/stop) -│ │ └── events.ts # SSE stream endpoint -│ └── types.ts # Shared types -``` - -## SQLite Schema (better-sqlite3, NOT Drizzle) -Use raw SQL with better-sqlite3. Tables needed: - -```sql --- Core hierarchy -CREATE TABLE projects ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - name TEXT NOT NULL, - path TEXT, -- git repo path - spec TEXT, -- PRD/spec markdown - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE phases ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - project_id TEXT NOT NULL REFERENCES projects(id), - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'pending' -); - -CREATE TABLE missions ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - phase_id TEXT NOT NULL REFERENCES phases(id), - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress REAL NOT NULL DEFAULT 0 -); - -CREATE TABLE tasks ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - mission_id TEXT NOT NULL REFERENCES missions(id), - name TEXT NOT NULL, - description TEXT, - agent_id TEXT REFERENCES agents(id), - status TEXT NOT NULL DEFAULT 'pending', - sort_order INTEGER NOT NULL DEFAULT 0, - depends_on TEXT, -- JSON array of task IDs - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Execution -CREATE TABLE task_runs ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - task_id TEXT NOT NULL REFERENCES tasks(id), - agent_id TEXT REFERENCES agents(id), - status TEXT NOT NULL DEFAULT 'pending', - attempt INTEGER NOT NULL DEFAULT 1, - workspace_path TEXT, - started_at TEXT, - completed_at TEXT, - error TEXT, - input_tokens INTEGER DEFAULT 0, - output_tokens INTEGER DEFAULT 0, - cost_cents INTEGER DEFAULT 0 -); - -CREATE TABLE run_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_run_id TEXT NOT NULL REFERENCES task_runs(id), - type TEXT NOT NULL, -- 'started', 'output', 'tool_use', 'checkpoint', 'completed', 'error' - data TEXT, -- JSON payload - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Checkpoints (review gates) -CREATE TABLE checkpoints ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - task_run_id TEXT NOT NULL REFERENCES task_runs(id), - summary TEXT, - diff_stat TEXT, -- JSON: files changed, insertions, deletions - status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, rejected, revised - reviewer_notes TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE artifacts ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id), - type TEXT NOT NULL, -- 'screenshot', 'diff', 'log' - path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Agents -CREATE TABLE agents ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - name TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder', - adapter_type TEXT NOT NULL DEFAULT 'codex', -- codex, claude, openclaw, ollama - adapter_config TEXT DEFAULT '{}', -- JSON - model TEXT, - status TEXT NOT NULL DEFAULT 'idle', - capabilities TEXT DEFAULT '{}', -- JSON - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Activity log -CREATE TABLE activity_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id TEXT NOT NULL, - agent_id TEXT, - details TEXT, -- JSON - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` - -## Orchestrator (from Symphony SPEC.md) - -The orchestrator is a poll-dispatch loop. Key behaviors: - -### State (in-memory, like Symphony) -```typescript -interface OrchestratorState { - pollIntervalMs: number; // default 5000 - maxConcurrentAgents: number; // default 4 - running: Map; // taskId -> run info - claimed: Set; // task IDs reserved/running/retrying - retryAttempts: Map; - completed: Set; -} -``` - -### Poll Loop -1. Query DB for tasks with status='ready' (all dependencies met) -2. Skip if task ID is in `claimed` set -3. Respect `maxConcurrentAgents` concurrency limit -4. For each eligible task: create workspace → spawn agent → track run -5. On agent completion: create checkpoint → wait for approval (or auto-approve) -6. On approval: mark task done → check if new tasks are unblocked - -### Retry Logic (from Symphony) -- On agent failure: exponential backoff (base 10s, max 5min) -- Max 3 retries per task -- On retry: reuse workspace, increment attempt counter - -### Reconciliation on Restart -- On startup: scan `task_runs` for status='running' -- If process not alive: mark as 'failed', queue retry -- No external DB needed for recovery (SQLite is the source of truth) - -## Agent Runner - -### Codex Adapter (PRIMARY) -Spawn `codex app-server` as child process, communicate via JSON-RPC over stdio: - -```typescript -// Simplified flow: -const proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: workspacePath }); -// Send: initialize → thread/start → turn/start -// Stream: item/started, item/agentMessage/delta, item/completed, turn/completed -``` - -Thread lifecycle: thread/start → turn/start with task prompt → stream events → turn/completed - -### Claude Adapter -```typescript -// Use Claude Code CLI in print mode -spawn('claude', ['--print', '--permission-mode', 'bypassPermissions', '-m', taskPrompt], { cwd: workspacePath }); -``` - -### OpenClaw Adapter -```typescript -// Use OpenClaw gateway sessions_spawn -// POST to gateway API to create session, stream SSE events -``` - -## REST API Routes - -### Projects -- `GET /api/projects` — list all -- `POST /api/projects` — create (name, path, spec) -- `GET /api/projects/:id` — detail with phases/missions/tasks -- `PUT /api/projects/:id` — update -- `DELETE /api/projects/:id` — delete - -### Tasks -- `GET /api/tasks` — list (filter by mission_id, status) -- `POST /api/tasks` — create -- `PUT /api/tasks/:id` — update -- `POST /api/tasks/:id/run` — manually trigger a task run - -### Missions -- `POST /api/missions/:id/start` — start mission (resolve deps → dispatch wave 1) -- `POST /api/missions/:id/pause` — pause all running tasks -- `POST /api/missions/:id/resume` — resume paused tasks -- `POST /api/missions/:id/stop` — stop and cleanup - -### Agents -- `GET /api/agents` — list registered agents -- `POST /api/agents` — register new agent -- `GET /api/agents/:id/status` — current status + active task - -### Checkpoints -- `GET /api/checkpoints` — list pending checkpoints -- `POST /api/checkpoints/:id/approve` — approve -- `POST /api/checkpoints/:id/reject` — reject -- `POST /api/checkpoints/:id/revise` — revise with notes - -### Events (SSE) -- `GET /api/events` — SSE stream of all orchestrator events -- `GET /api/events/:taskRunId` — SSE stream for specific task run - -## Dependencies -```json -{ - "dependencies": { - "express": "^4.21.0", - "better-sqlite3": "^11.0.0", - "cors": "^2.8.5", - "yaml": "^2.4.0" - }, - "devDependencies": { - "typescript": "^5.7.0", - "@types/express": "^4.17.21", - "@types/better-sqlite3": "^7.6.8", - "@types/cors": "^2.8.17", - "tsx": "^4.19.0" - } -} -``` - -## Key Design Decisions -1. **SQLite, not Postgres** — local-first, zero config, single file -2. **better-sqlite3, not Drizzle** — synchronous API, simpler for a daemon -3. **In-memory orchestrator state + SQLite persistence** — fast dispatch, durable recovery -4. **WORKFLOW.md per project** — teams version their agent config with code (from Symphony) -5. **SSE for live events** — same pattern ClawSuite already uses for chat streaming -6. **No auth** — single user, local daemon. Auth comes later. - -## How to Run -```bash -cd workspace-daemon -npm install -npx tsx src/server.ts -# Daemon runs on http://localhost:3001 -``` - -## Verification -After building, verify: -1. `npx tsc --noEmit` passes -2. Server starts on port 3001 -3. `curl http://localhost:3001/api/projects` returns `[]` -4. Can create a project via POST -5. Can create tasks and trigger a run -6. SSE endpoint streams events diff --git a/workspace-daemon/README.md b/workspace-daemon/README.md new file mode 100644 index 00000000..d146b9ce --- /dev/null +++ b/workspace-daemon/README.md @@ -0,0 +1,52 @@ +# ClawSuite Workspace Daemon + +Local orchestration engine for AI coding agents + +## Quick start + +```bash +npm install +npm start +PORT=3002 npm start +``` + +## API endpoints + +- `GET /health` +- `GET /api/projects` +- `POST /api/projects` +- `GET /api/projects/:id` +- `PUT /api/projects/:id` +- `DELETE /api/projects/:id` +- `POST /api/phases` +- `GET /api/tasks` +- `POST /api/tasks` +- `PUT /api/tasks/:id` +- `POST /api/tasks/:id/run` +- `GET /api/tasks/:id/runs` +- `GET /api/agents` +- `POST /api/agents` +- `GET /api/agents/:id/status` +- `POST /api/missions` +- `POST /api/missions/:id/start` +- `POST /api/missions/:id/pause` +- `POST /api/missions/:id/resume` +- `POST /api/missions/:id/stop` +- `GET /api/checkpoints` +- `POST /api/checkpoints/:id/approve` +- `POST /api/checkpoints/:id/reject` +- `POST /api/checkpoints/:id/revise` +- `POST /api/decompose` +- `GET /api/events` +- `GET /api/events/:taskRunId` + +## Architecture overview + +- Orchestrator poll loop: `Orchestrator` runs a timed polling loop, resolves ready tasks, dispatches work to available agents, and manages retries. +- Agent adapters: task execution is delegated through adapter implementations for supported agent types such as Codex, Claude, OpenClaw, and Ollama. +- SQLite persistence: `Tracker` stores projects, phases, missions, tasks, runs, checkpoints, agents, and activity in SQLite. + +## Configuration + +- `PORT`: HTTP port for the daemon. Defaults to `3002`. +- `WORKSPACE_DAEMON_DB_PATH`: optional path to the SQLite database file. diff --git a/workspace-daemon/src/checkpoint-builder.ts b/workspace-daemon/src/checkpoint-builder.ts new file mode 100644 index 00000000..6c26d58c --- /dev/null +++ b/workspace-daemon/src/checkpoint-builder.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { Tracker } from "./tracker"; +import type { Checkpoint } from "./types"; + +const execFileAsync = promisify(execFile); + +function isGitDir(workspacePath: string): boolean { + return fs.existsSync(path.join(workspacePath, ".git")) || fs.existsSync(path.join(workspacePath, ".git", "HEAD")); +} + +async function gitExec(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd, timeout: 10_000 }); + return stdout.trim(); + } catch { + return ""; + } +} + +export async function buildCheckpoint( + workspacePath: string, + taskRunId: string, + tracker: Tracker, + autoApprove: boolean, +): Promise { + if (!isGitDir(workspacePath)) { + const checkpoint = tracker.createCheckpoint(taskRunId, "No git info available", null); + if (autoApprove) { + tracker.approveCheckpoint(checkpoint.id); + } + return checkpoint; + } + + const [diffStat, diffNames, logLine] = await Promise.all([ + gitExec(["diff", "--stat"], workspacePath), + gitExec(["diff", "--name-only"], workspacePath), + gitExec(["log", "--oneline", "-1"], workspacePath), + ]); + + const changedFiles = diffNames.split("\n").filter(Boolean); + const summary = logLine || diffStat || "No changes detected"; + const diffStatJson = JSON.stringify({ + raw: diffStat, + changed_files: changedFiles, + files_changed: changedFiles.length, + }); + + const checkpoint = tracker.createCheckpoint(taskRunId, summary, diffStatJson); + + if (autoApprove) { + await gitExec(["add", "-A"], workspacePath); + await gitExec(["commit", "-m", `chore(workspace): auto-apply task run ${taskRunId}`], workspacePath); + tracker.approveCheckpoint(checkpoint.id); + } + + return checkpoint; +} diff --git a/workspace-daemon/src/orchestrator.ts b/workspace-daemon/src/orchestrator.ts index 86042f16..f5cf12f1 100644 --- a/workspace-daemon/src/orchestrator.ts +++ b/workspace-daemon/src/orchestrator.ts @@ -153,7 +153,7 @@ export class Orchestrator extends EventEmitter { const checkpoint = this.tracker.createCheckpoint( taskRun.id, result.checkpointSummary ?? result.summary, - result.diffStat ? { ...result.diffStat } : null, + result.diffStat ? JSON.stringify(result.diffStat) : null, ); const autoApprove = getWorkflowConfig(project.path).autoApprove; if (autoApprove) { diff --git a/workspace-daemon/src/routes/missions.ts b/workspace-daemon/src/routes/missions.ts index 292af4dd..b41d8832 100644 --- a/workspace-daemon/src/routes/missions.ts +++ b/workspace-daemon/src/routes/missions.ts @@ -27,6 +27,15 @@ export function createMissionsRouter(tracker: Tracker): Router { res.status(201).json(mission); }); + router.get("/:id/status", (req, res) => { + const status = tracker.getMissionStatus(req.params.id); + if (!status) { + res.status(404).json({ error: "Mission not found" }); + return; + } + res.json(status); + }); + router.post("/:id/start", (req, res) => { const ok = tracker.startMission(req.params.id); if (!ok) { diff --git a/workspace-daemon/src/server.ts b/workspace-daemon/src/server.ts index 3753c274..ba0c0276 100644 --- a/workspace-daemon/src/server.ts +++ b/workspace-daemon/src/server.ts @@ -12,7 +12,7 @@ import { createCheckpointsRouter } from "./routes/checkpoints"; import { createPhasesRouter } from "./routes/phases"; import { createDecomposeRouter } from "./routes/decompose"; -const PORT = Number(process.env.PORT ?? 3001); +const PORT = Number(process.env.PORT ?? 3002); export function createServer(): { app: express.Express; tracker: Tracker; orchestrator: Orchestrator } { const app = express(); diff --git a/workspace-daemon/src/tracker.ts b/workspace-daemon/src/tracker.ts index 32ce174e..947436a1 100644 --- a/workspace-daemon/src/tracker.ts +++ b/workspace-daemon/src/tracker.ts @@ -10,6 +10,8 @@ import type { CreateProjectInput, CreateTaskInput, Mission, + MissionProgressEvent, + MissionStatus, MissionWithProjectContext, Phase, Project, @@ -269,8 +271,18 @@ export class Tracker extends EventEmitter { } setTaskStatus(id: string, status: TaskStatus): Task | null { + const current = this.getTask(id); + if (!current) { + return null; + } + this.db.prepare("UPDATE tasks SET status = ? WHERE id = ?").run(status, id); - return this.getTask(id); + const task = this.getTask(id); + if (task && task.status !== current.status) { + this.emitSse("task.updated", task); + this.emitMissionProgress(task.mission_id); + } + return task; } refreshMissionTaskStatuses(missionId: string): TaskWithRelations[] { @@ -406,10 +418,10 @@ export class Tracker extends EventEmitter { return this.db.prepare("SELECT * FROM run_events ORDER BY id DESC LIMIT 200").all() as RunEvent[]; } - createCheckpoint(taskRunId: string, summary: string | null, diffStat: Record | null): Checkpoint { + createCheckpoint(taskRunId: string, summary: string | null, diffStat: string | null): Checkpoint { const checkpoint = this.db .prepare("INSERT INTO checkpoints (task_run_id, summary, diff_stat) VALUES (?, ?, ?) RETURNING *") - .get(taskRunId, summary, diffStat ? JSON.stringify(diffStat) : null) as Checkpoint; + .get(taskRunId, summary, diffStat) as Checkpoint; this.emitSse("checkpoint.created", checkpoint); return checkpoint; } @@ -435,6 +447,10 @@ export class Tracker extends EventEmitter { return checkpoint; } + approveCheckpoint(id: string, reviewerNotes?: string): Checkpoint | null { + return this.updateCheckpointStatus(id, "approved", reviewerNotes); + } + listAgents(): AgentRecord[] { return this.db.prepare("SELECT * FROM agents ORDER BY created_at DESC").all() as AgentRecord[]; } @@ -495,11 +511,97 @@ export class Tracker extends EventEmitter { return { agent, activeTaskRun }; } + getMissionStatus(id: string): MissionStatus | null { + const mission = this.getMission(id); + if (!mission) { + return null; + } + + const taskBreakdown = this.db + .prepare( + `SELECT + tasks.id, + tasks.name, + tasks.status, + tasks.agent_id, + latest_run.started_at, + latest_run.completed_at + FROM tasks + LEFT JOIN ( + SELECT tr1.task_id, tr1.started_at, tr1.completed_at + FROM task_runs tr1 + INNER JOIN ( + SELECT task_id, MAX(id) AS max_id + FROM task_runs + GROUP BY task_id + ) latest ON latest.max_id = tr1.id + ) AS latest_run ON latest_run.task_id = tasks.id + WHERE tasks.mission_id = ? + ORDER BY tasks.sort_order ASC, tasks.created_at ASC`, + ) + .all(id) as MissionStatus["task_breakdown"]; + + const totalCount = taskBreakdown.length; + const completedCount = taskBreakdown.filter((task) => task.status === "completed").length; + const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + + this.db.prepare("UPDATE missions SET progress = ? WHERE id = ?").run(progress, id); + + const runningAgents = this.db + .prepare( + `SELECT DISTINCT COALESCE(agents.name, task_runs.agent_id) AS agent_name + FROM task_runs + JOIN tasks ON tasks.id = task_runs.task_id + LEFT JOIN agents ON agents.id = task_runs.agent_id + WHERE tasks.mission_id = ? AND task_runs.status = 'running' + ORDER BY agent_name ASC`, + ) + .all(id) as Array<{ agent_name: string | null }>; + + const averageTiming = this.db + .prepare( + `SELECT AVG((julianday(task_runs.completed_at) - julianday(task_runs.started_at)) * 86400000.0) AS avg_ms + FROM task_runs + JOIN tasks ON tasks.id = task_runs.task_id + WHERE tasks.mission_id = ? + AND task_runs.started_at IS NOT NULL + AND task_runs.completed_at IS NOT NULL + AND task_runs.status = 'completed'`, + ) + .get(id) as { avg_ms: number | null } | undefined; + + const remainingCount = Math.max(totalCount - completedCount, 0); + const estimatedCompletion = + averageTiming?.avg_ms && remainingCount > 0 + ? new Date(Date.now() + averageTiming.avg_ms * remainingCount).toISOString() + : null; + + const updatedMission = this.getMission(id); + if (!updatedMission) { + return null; + } + + return { + mission: { + id: updatedMission.id, + name: updatedMission.name, + status: updatedMission.status, + progress: updatedMission.progress, + }, + task_breakdown: taskBreakdown, + running_agents: runningAgents.flatMap((row) => (row.agent_name ? [row.agent_name] : [])), + completed_count: completedCount, + total_count: totalCount, + estimated_completion: estimatedCompletion, + }; + } + startMission(id: string): boolean { const result = this.db.prepare("UPDATE missions SET status = 'running' WHERE id = ?").run(id); this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); if (result.changes > 0) { this.refreshMissionTaskStatuses(id); + this.emitMissionProgress(id); } return result.changes > 0; } @@ -507,6 +609,9 @@ export class Tracker extends EventEmitter { pauseMission(id: string): boolean { const result = this.db.prepare("UPDATE missions SET status = 'paused' WHERE id = ?").run(id); this.db.prepare("UPDATE tasks SET status = 'paused' WHERE mission_id = ? AND status IN ('pending', 'ready', 'running')").run(id); + if (result.changes > 0) { + this.emitMissionProgress(id); + } return result.changes > 0; } @@ -515,6 +620,7 @@ export class Tracker extends EventEmitter { this.db.prepare("UPDATE tasks SET status = 'pending' WHERE mission_id = ? AND status = 'paused'").run(id); if (result.changes > 0) { this.refreshMissionTaskStatuses(id); + this.emitMissionProgress(id); } return result.changes > 0; } @@ -522,6 +628,9 @@ export class Tracker extends EventEmitter { stopMission(id: string): boolean { const result = this.db.prepare("UPDATE missions SET status = 'stopped' WHERE id = ?").run(id); this.db.prepare("UPDATE tasks SET status = 'stopped' WHERE mission_id = ? AND status != 'completed'").run(id); + if (result.changes > 0) { + this.emitMissionProgress(id); + } return result.changes > 0; } @@ -539,4 +648,19 @@ export class Tracker extends EventEmitter { data: payload, }); } + + private emitMissionProgress(missionId: string): void { + const status = this.getMissionStatus(missionId); + if (!status) { + return; + } + + const event: MissionProgressEvent = { + mission_id: missionId, + progress: status.mission.progress, + completed_count: status.completed_count, + total_count: status.total_count, + }; + this.emitSse("mission.progress", event); + } } diff --git a/workspace-daemon/src/types.ts b/workspace-daemon/src/types.ts index cec90f9b..a53ca261 100644 --- a/workspace-daemon/src/types.ts +++ b/workspace-daemon/src/types.ts @@ -77,6 +77,31 @@ export interface MissionWithProjectContext extends Mission { project_spec: string | null; } +export interface MissionStatusTask { + id: string; + name: string; + status: TaskStatus; + agent_id: string | null; + started_at: string | null; + completed_at: string | null; +} + +export interface MissionProgressEvent { + mission_id: string; + progress: number; + completed_count: number; + total_count: number; +} + +export interface MissionStatus { + mission: Pick; + task_breakdown: MissionStatusTask[]; + running_agents: string[]; + completed_count: number; + total_count: number; + estimated_completion: string | null; +} + export interface Task { id: string; mission_id: string; @@ -191,6 +216,13 @@ export interface WorkflowHooks { after_create?: string[]; } +export interface WorkspaceInfo { + path: string; + createdNow: boolean; + hooks: WorkflowHooks; + git_worktree: boolean; +} + export interface WorkflowConfig { pollIntervalMs: number; maxConcurrentAgents: number; diff --git a/workspace-daemon/src/workspace.ts b/workspace-daemon/src/workspace.ts index 4fb020a0..e9e6f4cb 100644 --- a/workspace-daemon/src/workspace.ts +++ b/workspace-daemon/src/workspace.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { getWorkflowConfig } from "./config"; -import type { Project, Task, WorkflowHooks } from "./types"; +import type { Project, Task, WorkflowHooks, WorkspaceInfo } from "./types"; const execFileAsync = promisify(execFile); @@ -21,15 +21,45 @@ async function runHooks(commands: string[] | undefined, cwd: string): Promise { + getWorktreeBranch(taskId: string): string { + return `task/${sanitizeSegment(taskId)}`; + } + + private async createGitWorktree(projectPath: string, workspacePath: string, taskId: string): Promise { + await execFileAsync("git", ["worktree", "add", workspacePath, "-b", this.getWorktreeBranch(taskId)], { + cwd: projectPath, + }); + } + + async prepare(project: Project, task: Task): Promise { const workflowConfig = getWorkflowConfig(project.path); const projectKey = sanitizeSegment(project.name || project.id); const taskKey = sanitizeSegment(task.name || task.id); const workspacePath = path.join(workflowConfig.workspaceRoot, projectKey, `${task.id}-${taskKey}`); const createdNow = !fs.existsSync(workspacePath); + let gitWorktree = false; - fs.mkdirSync(workspacePath, { recursive: true }); + fs.mkdirSync(path.dirname(workspacePath), { recursive: true }); + + if (createdNow && project.path && hasGitDirectory(project.path)) { + try { + await this.createGitWorktree(project.path, workspacePath, task.id); + gitWorktree = true; + } catch { + fs.mkdirSync(workspacePath, { recursive: true }); + } + } else { + fs.mkdirSync(workspacePath, { recursive: true }); + } if (project.path && fs.existsSync(project.path)) { const manifestPath = path.join(workspacePath, ".workspace-source"); @@ -46,9 +76,30 @@ export class WorkspaceManager { path: workspacePath, createdNow, hooks: workflowConfig.hooks, + git_worktree: gitWorktree, }; } + async ensureWorkspace(project: Project, task: Task): Promise { + return this.prepare(project, task); + } + + async cleanup(project: Project, task: Task, workspace: WorkspaceInfo): Promise { + if (!workspace.git_worktree || !project.path || !fs.existsSync(project.path)) { + return; + } + + try { + await execFileAsync("git", ["worktree", "remove", workspace.path], { cwd: project.path }); + } finally { + try { + await execFileAsync("git", ["branch", "-D", this.getWorktreeBranch(task.id)], { cwd: project.path }); + } catch { + // Ignore branch cleanup failures if the branch was never created or already removed. + } + } + } + async runBeforeRunHooks(workspacePath: string, hooks: WorkflowHooks): Promise { await runHooks(hooks.before_run, workspacePath); } From 2b36f63efb89c612e62cbbf18b8c0c633c293394 Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 18:16:46 -0400 Subject: [PATCH 004/348] Add greeting module entrypoint --- .../greet.ts | 3 +++ .../index.ts | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/greet.ts create mode 100644 workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/index.ts diff --git a/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/greet.ts b/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/greet.ts new file mode 100644 index 00000000..d50420c8 --- /dev/null +++ b/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}!` +} diff --git a/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/index.ts b/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/index.ts new file mode 100644 index 00000000..3e8187eb --- /dev/null +++ b/workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/index.ts @@ -0,0 +1,3 @@ +import { greet } from './greet' + +console.log(greet('World')) From 8140688ff7670ee72ea79cc2af0b39daeafef113 Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 18:16:48 -0400 Subject: [PATCH 005/348] feat: add greeting module --- .../greet.ts | 3 +++ .../index.ts | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/greet.ts create mode 100644 workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/index.ts diff --git a/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/greet.ts b/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/greet.ts new file mode 100644 index 00000000..d50420c8 --- /dev/null +++ b/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}!` +} diff --git a/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/index.ts b/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/index.ts new file mode 100644 index 00000000..3e8187eb --- /dev/null +++ b/workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/index.ts @@ -0,0 +1,3 @@ +import { greet } from './greet' + +console.log(greet('World')) From cb3b9168f9e94302c6b377d900692eb12a446e4f Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 18:17:35 -0400 Subject: [PATCH 006/348] feat: codex JSON-RPC adapter + fix resolveReadyTasks bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex adapter: full app-server JSON-RPC protocol (initialize → thread/start → turn/start → stream events → turn.completed) - Auto-approval for command execution and file change requests - Legacy event fallback for older Codex versions - Abort/timeout with SIGTERM → SIGKILL escalation - Fix resolveReadyTasks: was only querying 'pending' tasks, missing already-ready ones - Integration tested: end-to-end project → task → codex agent → files created → committed → auto-approved --- workspace-daemon/.gitignore | 3 + workspace-daemon/src/adapters/codex.ts | 803 ++++++++++++++++++++++--- workspace-daemon/src/tracker.ts | 20 +- 3 files changed, 724 insertions(+), 102 deletions(-) diff --git a/workspace-daemon/.gitignore b/workspace-daemon/.gitignore index 602e00d0..3fb7bc26 100644 --- a/workspace-daemon/.gitignore +++ b/workspace-daemon/.gitignore @@ -3,3 +3,6 @@ node_modules/ dist/ *.sqlite *.sqlite-journal + +.workspaces/ +.data/ diff --git a/workspace-daemon/src/adapters/codex.ts b/workspace-daemon/src/adapters/codex.ts index a10144eb..70a326c5 100644 --- a/workspace-daemon/src/adapters/codex.ts +++ b/workspace-daemon/src/adapters/codex.ts @@ -1,42 +1,245 @@ import { spawn } from "node:child_process"; -import type { AgentAdapter } from "./types"; -import type { AgentExecutionRequest, AgentExecutionResult } from "../types"; - -function parseJsonLines(chunk: string, onMessage: (event: Record) => void): void { - const lines = chunk.split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - - try { - const parsed = JSON.parse(trimmed) as Record; - onMessage(parsed); - } catch { - onMessage({ type: "output_text", text: trimmed }); - } +import type { AgentAdapter, AgentAdapterContext } from "./types"; +import type { AgentExecutionRequest, AgentExecutionResult, AdapterStreamEvent } from "../types"; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const FORCE_KILL_DELAY_MS = 5_000; +const JSON_RPC_VERSION = "2.0"; +const CLIENT_NAME = "clawsuite-workspace"; +const CLIENT_VERSION = "0.1.0"; + +type JsonRpcId = number | string; + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: unknown; +} + +interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; +} + +interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + error: { + code?: number; + message?: string; + data?: unknown; + }; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +interface CodexAdapterConfig { + command?: string; + args?: string[]; + timeoutMs?: number; + env?: Record; + model?: string; +} + +interface ThreadStartResponse { + thread: { + id: string; + }; +} + +interface TurnStartResponse { + turn: { + id: string; + status?: string; + }; +} + +interface TurnCompletedNotification { + threadId: string; + turn: { + id: string; + status: "completed" | "interrupted" | "failed" | "inProgress"; + error?: { + message?: string | null; + additionalDetails?: string | null; + } | null; + }; +} + +interface ThreadTokenUsageUpdatedNotification { + threadId: string; + turnId: string; + tokenUsage: { + total?: TokenUsageBreakdown | null; + last?: TokenUsageBreakdown | null; + }; +} + +interface TokenUsageBreakdown { + inputTokens?: number; + outputTokens?: number; +} + +interface AgentMessageDeltaNotification { + threadId: string; + turnId: string; + itemId: string; + delta: string; +} + +interface LegacyTurnCompleteEvent { + turn_id: string; + last_agent_message?: string | null; +} + +interface LegacyTokenUsageInfo { + total_token_usage?: { + input_tokens?: number; + output_tokens?: number; + } | null; + last_token_usage?: { + input_tokens?: number; + output_tokens?: number; + } | null; +} + +function parseAdapterConfig(config: string | null): CodexAdapterConfig { + if (!config || config.trim().length === 0) { + return {}; + } + + try { + return JSON.parse(config) as CodexAdapterConfig; + } catch { + return {}; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toPositiveNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function summarizeText(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) { + return "Completed"; + } + + if (normalized.length <= 280) { + return normalized; + } + + return `${normalized.slice(0, 277).trimEnd()}...`; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; } + + return String(error); +} + +function buildFailureResult( + summarySource: string, + inputTokens: number, + outputTokens: number, + error: string, +): AgentExecutionResult { + return { + status: "failed", + summary: summarizeText(summarySource || error || "Codex execution failed"), + checkpointSummary: summarySource || undefined, + inputTokens, + outputTokens, + costCents: 0, + error, + }; +} + +function createDataEvent(type: AdapterStreamEvent["type"], data: Record): AdapterStreamEvent { + return { type, data }; } export class CodexAdapter implements AgentAdapter { readonly type = "codex"; - async execute(request: AgentExecutionRequest, context: { signal?: AbortSignal; onEvent: (event: any) => void }): Promise { + async execute(request: AgentExecutionRequest, context: AgentAdapterContext): Promise { return new Promise((resolve) => { - const command = request.agent.adapter_config ? JSON.parse(request.agent.adapter_config).command : undefined; - const proc = spawn(command ?? "codex", ["app-server"], { + const parsedConfig = parseAdapterConfig(request.agent.adapter_config); + const command = typeof parsedConfig.command === "string" && parsedConfig.command.trim().length > 0 ? parsedConfig.command : "codex"; + const args = Array.isArray(parsedConfig.args) && parsedConfig.args.every((value) => typeof value === "string") + ? parsedConfig.args + : ["app-server"]; + const timeoutMs = toPositiveNumber(parsedConfig.timeoutMs) ?? DEFAULT_TIMEOUT_MS; + const model = + typeof parsedConfig.model === "string" && parsedConfig.model.trim().length > 0 + ? parsedConfig.model + : request.agent.model; + const env = + parsedConfig.env && isRecord(parsedConfig.env) + ? { + ...process.env, + ...Object.fromEntries( + Object.entries(parsedConfig.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + } + : process.env; + + const proc = spawn(command, args, { cwd: request.workspacePath, stdio: ["pipe", "pipe", "pipe"], - env: process.env, + env, }); + let settled = false; let stdoutBuffer = ""; let stderrBuffer = ""; - let summary = ""; + let nextRequestId = 1; + let forceKillHandle: NodeJS.Timeout | null = null; + let currentThreadId: string | null = null; + let currentTurnId: string | null = null; + let finalMessage = ""; + let completedTurnMessage: string | null = null; let inputTokens = 0; let outputTokens = 0; - let settled = false; + const pending = new Map(); + + const timeoutHandle = setTimeout(() => { + void abortRun(`Codex execution timed out after ${Math.round(timeoutMs / 1000)}s`, "failed"); + }, timeoutMs); + + const cleanup = (): void => { + clearTimeout(timeoutHandle); + if (forceKillHandle) { + clearTimeout(forceKillHandle); + forceKillHandle = null; + } + + context.signal?.removeEventListener("abort", handleAbort); + }; + + const rejectPending = (message: string): void => { + for (const [, entry] of pending) { + entry.reject(new Error(message)); + } + pending.clear(); + }; const settle = (result: AgentExecutionResult): void => { if (settled) { @@ -44,103 +247,519 @@ export class CodexAdapter implements AgentAdapter { } settled = true; + cleanup(); + rejectPending(result.error ?? "Codex process ended"); resolve(result); }; - context.signal?.addEventListener("abort", () => { - proc.kill("SIGTERM"); - settle({ - status: "stopped", - summary: "Run aborted", - inputTokens, - outputTokens, - costCents: 0, - error: "Aborted", - }); - }); + const sendMessage = (payload: JsonRpcRequest | JsonRpcNotification | JsonRpcSuccessResponse | JsonRpcErrorResponse): void => { + if (proc.stdin.destroyed || !proc.stdin.writable) { + throw new Error("Codex stdin is not writable"); + } - proc.stdout.setEncoding("utf8"); - proc.stdout.on("data", (chunk: string) => { - stdoutBuffer += chunk; - parseJsonLines(chunk, (event) => { - const type = typeof event.type === "string" ? event.type : "output"; - if (type === "item.completed" && typeof event.item === "object" && event.item && "text" in event.item) { - const text = typeof event.item.text === "string" ? event.item.text : ""; - if (text) { - summary = `${summary}\n${text}`.trim(); - context.onEvent({ type: "output", message: text }); - } - } else if (type === "turn.completed" && typeof event.usage === "object" && event.usage) { - const usage = event.usage as Record; - inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : inputTokens; - outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : outputTokens; - context.onEvent({ type: "turn.completed", data: usage }); - } else if (type === "output_text" && typeof event.text === "string") { - context.onEvent({ type: "output", message: event.text }); - } else { - context.onEvent({ type: "status", data: event }); + proc.stdin.write(`${JSON.stringify(payload)}\n`); + }; + + const sendRequest = (method: string, params?: unknown): Promise => { + const id = nextRequestId++; + + return new Promise((resolveRequest, rejectRequest) => { + pending.set(id, { + resolve: resolveRequest as (value: unknown) => void, + reject: rejectRequest, + }); + + try { + sendMessage({ + jsonrpc: JSON_RPC_VERSION, + id, + method, + params, + }); + } catch (error) { + pending.delete(id); + rejectRequest(error instanceof Error ? error : new Error(errorMessage(error))); } }); - }); + }; - proc.stderr.setEncoding("utf8"); - proc.stderr.on("data", (chunk: string) => { - stderrBuffer += chunk; - context.onEvent({ type: "error", message: chunk.trim() }); - }); + const sendNotification = (method: string, params?: unknown): void => { + sendMessage({ + jsonrpc: JSON_RPC_VERSION, + method, + ...(typeof params === "undefined" ? {} : { params }), + }); + }; - proc.on("spawn", () => { - const payloads = [ - { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, - { jsonrpc: "2.0", id: 2, method: "thread/start", params: {} }, - { - jsonrpc: "2.0", - id: 3, - method: "turn/start", - params: { - input: request.prompt, - }, + const sendResult = (id: JsonRpcId, result: unknown): void => { + sendMessage({ + jsonrpc: JSON_RPC_VERSION, + id, + result, + }); + }; + + const sendError = (id: JsonRpcId, message: string, code = -32000): void => { + sendMessage({ + jsonrpc: JSON_RPC_VERSION, + id, + error: { + code, + message, }, - ]; + }); + }; - for (const payload of payloads) { - proc.stdin.write(`${JSON.stringify(payload)}\n`); + const teardownProcess = (): void => { + if (!proc.killed) { + proc.kill("SIGTERM"); + forceKillHandle = setTimeout(() => { + proc.kill("SIGKILL"); + }, FORCE_KILL_DELAY_MS); } - }); + }; - proc.on("error", (error) => { + const completeSuccess = (): void => { + const checkpointSummary = (completedTurnMessage ?? finalMessage).trim() || undefined; + const summary = summarizeText(checkpointSummary ?? ""); settle({ - status: "failed", - summary: summary || "Codex execution failed", + status: "completed", + summary, + checkpointSummary, inputTokens, outputTokens, costCents: 0, - error: error.message, }); - }); + }; - proc.on("close", (code) => { - if (code === 0) { + const abortRun = async (message: string, status: AgentExecutionResult["status"]): Promise => { + context.onEvent({ type: "status", message }); + + if (currentThreadId && currentTurnId && status !== "completed") { + try { + await sendRequest("turn/interrupt", { + threadId: currentThreadId, + turnId: currentTurnId, + }); + } catch { + // Fall through to process teardown. + } + } + + teardownProcess(); + + if (status === "stopped") { settle({ - status: "completed", - summary: summary || stdoutBuffer.trim() || "Completed", - checkpointSummary: summary || stdoutBuffer.trim() || "Completed", + status, + summary: "Run aborted", + checkpointSummary: finalMessage.trim() || undefined, inputTokens, outputTokens, costCents: 0, + error: "Aborted", }); return; } - settle({ - status: "failed", - summary: summary || "Codex execution failed", - inputTokens, - outputTokens, - costCents: 0, - error: stderrBuffer.trim() || stdoutBuffer.trim() || `Process exited with code ${code ?? -1}`, + settle( + buildFailureResult( + completedTurnMessage ?? finalMessage, + inputTokens, + outputTokens, + message, + ), + ); + }; + + const handleAbort = (): void => { + void abortRun("Run aborted", "stopped"); + }; + + const applyTokenUsage = (usage: TokenUsageBreakdown | null | undefined): void => { + if (!usage) { + return; + } + + if (typeof usage.inputTokens === "number") { + inputTokens = usage.inputTokens; + } + + if (typeof usage.outputTokens === "number") { + outputTokens = usage.outputTokens; + } + }; + + const handleNotification = (method: string, rawParams: unknown): void => { + if (!isRecord(rawParams)) { + context.onEvent(createDataEvent("status", { method })); + return; + } + + switch (method) { + case "turn/started": { + const turnId = typeof rawParams.turnId === "string" ? rawParams.turnId : null; + if (turnId) { + currentTurnId = turnId; + } + context.onEvent(createDataEvent("status", { method, ...rawParams })); + break; + } + + case "thread/tokenUsage/updated": { + const notification = rawParams as unknown as ThreadTokenUsageUpdatedNotification; + if (!currentTurnId || notification.turnId === currentTurnId) { + applyTokenUsage(notification.tokenUsage.last ?? notification.tokenUsage.total); + } + context.onEvent({ + type: "status", + data: { + method, + inputTokens, + outputTokens, + }, + }); + break; + } + + case "item/agentMessage/delta": { + const notification = rawParams as unknown as AgentMessageDeltaNotification; + if (notification.delta) { + finalMessage += notification.delta; + context.onEvent({ type: "agent_message", message: notification.delta }); + context.onEvent({ type: "output", message: notification.delta }); + } + break; + } + + case "turn/completed": { + const notification = rawParams as unknown as TurnCompletedNotification; + const completedTurnId = notification.turn.id; + if (completedTurnId) { + currentTurnId = completedTurnId; + } + + if (notification.turn.status === "failed") { + const failureMessage = + notification.turn.error?.message ?? + notification.turn.error?.additionalDetails ?? + "Codex turn failed"; + settle(buildFailureResult(completedTurnMessage ?? finalMessage, inputTokens, outputTokens, failureMessage)); + teardownProcess(); + return; + } + + if (notification.turn.status === "interrupted") { + settle({ + status: "stopped", + summary: summarizeText(completedTurnMessage ?? finalMessage), + checkpointSummary: (completedTurnMessage ?? finalMessage).trim() || undefined, + inputTokens, + outputTokens, + costCents: 0, + error: "Interrupted", + }); + teardownProcess(); + return; + } + + context.onEvent({ + type: "turn.completed", + data: { + method, + threadId: notification.threadId, + turnId: notification.turn.id, + status: notification.turn.status, + inputTokens, + outputTokens, + }, + }); + + if (notification.turn.status === "completed") { + completeSuccess(); + teardownProcess(); + } + break; + } + + case "error": { + const errorValue = + rawParams.error && isRecord(rawParams.error) && typeof rawParams.error.message === "string" + ? rawParams.error.message + : "Codex protocol error"; + stderrBuffer = `${stderrBuffer}${errorValue}\n`; + context.onEvent({ type: "error", message: errorValue }); + break; + } + + default: + context.onEvent(createDataEvent("status", { method, ...rawParams })); + break; + } + }; + + const handleLegacyEvent = (event: Record): void => { + const type = typeof event.type === "string" ? event.type : null; + if (!type) { + return; + } + + switch (type) { + case "agent_message_content_delta": + case "agent_message_delta": { + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta) { + finalMessage += delta; + context.onEvent({ type: "agent_message", message: delta }); + context.onEvent({ type: "output", message: delta }); + } + break; + } + + case "token_count": { + const info = isRecord(event.info) ? (event.info as unknown as LegacyTokenUsageInfo) : null; + applyTokenUsage( + info?.last_token_usage + ? { + inputTokens: info.last_token_usage.input_tokens, + outputTokens: info.last_token_usage.output_tokens, + } + : info?.total_token_usage + ? { + inputTokens: info.total_token_usage.input_tokens, + outputTokens: info.total_token_usage.output_tokens, + } + : null, + ); + break; + } + + case "task_complete": { + const legacy = event as unknown as LegacyTurnCompleteEvent; + if (typeof legacy.last_agent_message === "string" && legacy.last_agent_message.trim().length > 0) { + completedTurnMessage = legacy.last_agent_message; + finalMessage = legacy.last_agent_message; + } + context.onEvent({ + type: "turn.completed", + data: { + turnId: legacy.turn_id, + inputTokens, + outputTokens, + }, + }); + break; + } + + case "error": { + const message = typeof event.message === "string" ? event.message : "Codex event error"; + stderrBuffer = `${stderrBuffer}${message}\n`; + context.onEvent({ type: "error", message }); + break; + } + + default: + context.onEvent(createDataEvent("status", event)); + break; + } + }; + + const handleServerRequest = (rpcRequest: JsonRpcRequest): void => { + switch (rpcRequest.method) { + case "item/commandExecution/requestApproval": + context.onEvent({ + type: "status", + data: { + method: rpcRequest.method, + policy: "always", + }, + }); + sendResult(rpcRequest.id, { decision: "acceptForSession" }); + break; + + case "item/fileChange/requestApproval": + context.onEvent({ + type: "status", + data: { + method: rpcRequest.method, + policy: "always", + }, + }); + sendResult(rpcRequest.id, { decision: "acceptForSession" }); + break; + + case "execCommandApproval": + case "applyPatchApproval": + context.onEvent({ + type: "status", + data: { + method: rpcRequest.method, + policy: "always", + }, + }); + sendResult(rpcRequest.id, { decision: "approved_for_session" }); + break; + + default: + sendError(rpcRequest.id, `Unsupported server request: ${rpcRequest.method}`); + context.onEvent({ + type: "error", + message: `Unsupported server request: ${rpcRequest.method}`, + }); + break; + } + }; + + const handleJsonRpcMessage = (message: unknown): void => { + if (!isRecord(message)) { + return; + } + + if (typeof message.method === "string" && Object.prototype.hasOwnProperty.call(message, "id")) { + handleServerRequest(message as unknown as JsonRpcRequest); + return; + } + + if (typeof message.method === "string") { + handleNotification(message.method, message.params); + return; + } + + if (Object.prototype.hasOwnProperty.call(message, "id")) { + const id = message.id as JsonRpcId; + const entry = pending.get(id); + if (!entry) { + return; + } + + pending.delete(id); + if (isRecord(message.error)) { + entry.reject(new Error(typeof message.error.message === "string" ? message.error.message : "JSON-RPC request failed")); + return; + } + + entry.resolve(message.result); + return; + } + + if (typeof message.type === "string") { + handleLegacyEvent(message); + } + }; + + const parseStdoutChunk = (chunk: string): void => { + stdoutBuffer += chunk; + + while (true) { + const newlineIndex = stdoutBuffer.indexOf("\n"); + if (newlineIndex === -1) { + break; + } + + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + + if (!line) { + continue; + } + + try { + handleJsonRpcMessage(JSON.parse(line) as unknown); + } catch { + finalMessage += `${line}\n`; + context.onEvent({ type: "output", message: `${line}\n` }); + } + } + }; + + const bootstrap = async (): Promise => { + const threadResponse = (await sendRequest("initialize", { + clientInfo: { + name: CLIENT_NAME, + title: null, + version: CLIENT_VERSION, + }, + capabilities: { + experimentalApi: false, + }, + }).then(async () => { + sendNotification("initialized"); + + return sendRequest("thread/start", { + model: model ?? undefined, + cwd: request.workspacePath, + experimentalRawEvents: false, + persistExtendedHistory: false, + }); + })) as ThreadStartResponse; + + currentThreadId = threadResponse.thread.id; + + const turnResponse = (await sendRequest("turn/start", { + threadId: currentThreadId, + model: model ?? undefined, + cwd: request.workspacePath, + input: [ + { + type: "text", + text: request.prompt, + text_elements: [], + }, + ], + })) as TurnStartResponse; + + currentTurnId = turnResponse.turn.id; + }; + + context.signal?.addEventListener("abort", handleAbort, { once: true }); + + proc.stdout.setEncoding("utf8"); + proc.stdout.on("data", (chunk: string) => { + parseStdoutChunk(chunk); + }); + + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (chunk: string) => { + stderrBuffer += chunk; + context.onEvent({ type: "error", message: chunk }); + }); + + proc.on("error", (error) => { + settle(buildFailureResult(completedTurnMessage ?? finalMessage, inputTokens, outputTokens, error.message)); + }); + + proc.on("spawn", () => { + void bootstrap().catch((error) => { + const message = errorMessage(error); + teardownProcess(); + settle(buildFailureResult(completedTurnMessage ?? finalMessage, inputTokens, outputTokens, message)); }); }); + + proc.on("close", (code) => { + if (settled) { + return; + } + + const trailingOutput = stdoutBuffer.trim(); + if (trailingOutput) { + finalMessage += `${trailingOutput}\n`; + } + + if (code === 0 && (completedTurnMessage ?? finalMessage).trim().length > 0) { + completeSuccess(); + return; + } + + const failureMessage = stderrBuffer.trim() || `Process exited with code ${code ?? -1}`; + settle( + buildFailureResult( + completedTurnMessage ?? finalMessage, + inputTokens, + outputTokens, + failureMessage, + ), + ); + }); }); } } diff --git a/workspace-daemon/src/tracker.ts b/workspace-daemon/src/tracker.ts index 947436a1..2895756c 100644 --- a/workspace-daemon/src/tracker.ts +++ b/workspace-daemon/src/tracker.ts @@ -311,26 +311,26 @@ export class Tracker extends EventEmitter { } resolveReadyTasks(limit: number): TaskWithRelations[] { - const tasks = this.listTasks({ status: "pending" }); - const byId = new Map(tasks.map((task) => [task.id, task])); + // Collect tasks that are already ready + const alreadyReady = this.listTasks({ status: "ready" }); + + // Promote pending tasks whose dependencies are satisfied + const pendingTasks = this.listTasks({ status: "pending" }); const completedTaskIds = new Set( (this.db.prepare("SELECT id FROM tasks WHERE status = 'completed'").all() as Array<{ id: string }>).map((row) => row.id), ); - const ready: TaskWithRelations[] = []; + const pendingById = new Map(pendingTasks.map((task) => [task.id, task])); - for (const task of tasks) { + for (const task of pendingTasks) { const dependencies = parseJsonOrDefault(task.depends_on, []); - const isReady = dependencies.every((dependencyId) => completedTaskIds.has(dependencyId) || !byId.has(dependencyId)); + const isReady = dependencies.every((dependencyId) => completedTaskIds.has(dependencyId) || !pendingById.has(dependencyId)); if (isReady) { this.setTaskStatus(task.id, "ready"); - ready.push({ ...task, status: "ready" }); - } - if (ready.length >= limit) { - break; + alreadyReady.push({ ...task, status: "ready" }); } } - return ready; + return alreadyReady.slice(0, limit); } createTaskRun(taskId: string, agentId: string | null, workspacePath: string | null, attempt: number): TaskRun { From a0cf2ed4aa6032d8c51d72a324eb382975fac784 Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 18:38:06 -0400 Subject: [PATCH 007/348] chore: remove test artifacts from merge --- workspace-daemon/.data/workspace-daemon.sqlite | Bin 4096 -> 0 bytes .../.data/workspace-daemon.sqlite-shm | Bin 32768 -> 0 bytes .../.data/workspace-daemon.sqlite-wal | Bin 267832 -> 0 bytes .../greet.ts | 3 --- .../index.ts | 3 --- .../greet.ts | 3 --- .../index.ts | 3 --- 7 files changed, 12 deletions(-) delete mode 100644 workspace-daemon/.data/workspace-daemon.sqlite delete mode 100644 workspace-daemon/.data/workspace-daemon.sqlite-shm delete mode 100644 workspace-daemon/.data/workspace-daemon.sqlite-wal delete mode 100644 workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/greet.ts delete mode 100644 workspace-daemon/.workspaces/integration-test/271ab3a7455b5f0939126aaca2ffc3b3-create-greeting-module/index.ts delete mode 100644 workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/greet.ts delete mode 100644 workspace-daemon/.workspaces/integration-test/c99c0c1eb7db59f0be60c5333cdbdcac-create-greeting-module/index.ts diff --git a/workspace-daemon/.data/workspace-daemon.sqlite b/workspace-daemon/.data/workspace-daemon.sqlite deleted file mode 100644 index db7a7459cf0d014b0dc2333abb5541c58c2d6ed1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYBBV;st3JAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*g{}s{ diff --git a/workspace-daemon/.data/workspace-daemon.sqlite-shm b/workspace-daemon/.data/workspace-daemon.sqlite-shm deleted file mode 100644 index ed85cef2599170af9e966a18c16c99df93a84ae4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*%PzxF5XbRRmr_O1R^94e_xqhv-o)Ch(TOai1A#Wb`<`Him_2#lSo2ljU&o*<*Ywv3BLm_|w0tg_0 z00IagfB*srAbzP0^>3nPMAcKH0hp+v}AO1L{2wD6m{Ffh?HeQW@Jtl zWkuFyOLpZzs)@b@hySI()L$MJ^jRr64mw|q;ijb`^RgtXvLV~DCx_n~c4E}1m$_ML z^*-ja1{9cWz<2(iSGL_+R9FHFVZ})>jct^p!V>t}V;EMZWYX9UMJgfh7~8lG`2673QJ%otT+j#u`SV5SOWWD#Yr%Y?M0^$h*Q9}>{AHDDPVVCPzb~+ zU^l-|2*fGC-9mBx-$`u(1vvo%fk*}HK06A5I0fvsMGAp91?;|33V}EU?1o+nfj9+5 S#ctfD5QtI0Zd<2*BJcr{z%qCM diff --git a/workspace-daemon/.data/workspace-daemon.sqlite-wal b/workspace-daemon/.data/workspace-daemon.sqlite-wal deleted file mode 100644 index f10aa8cfca211c49f04e8debc422841edf738cf3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 267832 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 z Date: Sun, 8 Mar 2026 18:39:39 -0400 Subject: [PATCH 008/348] fix: remove unused TaskRunStatus import --- workspace-daemon/src/tracker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/workspace-daemon/src/tracker.ts b/workspace-daemon/src/tracker.ts index 2895756c..72946b50 100644 --- a/workspace-daemon/src/tracker.ts +++ b/workspace-daemon/src/tracker.ts @@ -21,7 +21,6 @@ import type { RunEventType, Task, TaskRun, - TaskRunStatus, TaskRunWithRelations, TaskStatus, TaskWithRelations, From a534c20732433fe318f985fedb6745812a3a97d2 Mon Sep 17 00:00:00 2001 From: outsourc-e Date: Sun, 8 Mar 2026 18:48:59 -0400 Subject: [PATCH 009/348] =?UTF-8?q?feat:=20projects=20UI=20=E2=80=94=20das?= =?UTF-8?q?hboard,=20API=20proxy,=20sidebar=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projects screen (1187 lines): project cards, create/detail views, phase/mission/task management, start mission, status badges - Workspace proxy helper forwarding to daemon on :3099 - 7 API proxy routes: projects CRUD, phases, missions, workspace-tasks - Sidebar: Projects entry with Folder icon after Agent Hub - Route: /projects with error/pending components - tsc clean --- src/routeTree.gen.ts | 186 ++- src/routes/api/workspace-tasks.ts | 68 + .../api/workspace/missions.$id.start.ts | 43 + src/routes/api/workspace/missions.ts | 43 + src/routes/api/workspace/phases.ts | 43 + src/routes/api/workspace/projects.$id.ts | 89 ++ src/routes/api/workspace/projects.ts | 66 + src/routes/projects.tsx | 40 + src/screens/chat/components/chat-sidebar.tsx | 10 + src/screens/projects/projects-screen.tsx | 1187 +++++++++++++++++ src/server/workspace-proxy.ts | 58 + 11 files changed, 1828 insertions(+), 5 deletions(-) create mode 100644 src/routes/api/workspace-tasks.ts create mode 100644 src/routes/api/workspace/missions.$id.start.ts create mode 100644 src/routes/api/workspace/missions.ts create mode 100644 src/routes/api/workspace/phases.ts create mode 100644 src/routes/api/workspace/projects.$id.ts create mode 100644 src/routes/api/workspace/projects.ts create mode 100644 src/routes/projects.tsx create mode 100644 src/screens/projects/projects-screen.tsx create mode 100644 src/server/workspace-proxy.ts diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8d5bedf5..d8c84c5d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as TasksRouteImport } from './routes/tasks' import { Route as SkillsRouteImport } from './routes/skills' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SessionsRouteImport } from './routes/sessions' +import { Route as ProjectsRouteImport } from './routes/projects' import { Route as NodesRouteImport } from './routes/nodes' import { Route as NewRouteImport } from './routes/new' import { Route as MemoryRouteImport } from './routes/memory' @@ -38,6 +39,7 @@ import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as ChatIndexRouteImport } from './routes/chat/index' import { Route as SettingsProvidersRouteImport } from './routes/settings/providers' import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey' +import { Route as ApiWorkspaceTasksRouteImport } from './routes/api/workspace-tasks' import { Route as ApiWorkspaceRouteImport } from './routes/api/workspace' import { Route as ApiValidateProviderRouteImport } from './routes/api/validate-provider' import { Route as ApiUsageAnalyticsRouteImport } from './routes/api/usage-analytics' @@ -88,6 +90,9 @@ import { Route as ApiAgentKillRouteImport } from './routes/api/agent-kill' import { Route as ApiAgentDispatchRouteImport } from './routes/api/agent-dispatch' import { Route as ApiAgentActivityRouteImport } from './routes/api/agent-activity' import { Route as ApiTasksIndexRouteImport } from './routes/api/tasks/index' +import { Route as ApiWorkspaceProjectsRouteImport } from './routes/api/workspace/projects' +import { Route as ApiWorkspacePhasesRouteImport } from './routes/api/workspace/phases' +import { Route as ApiWorkspaceMissionsRouteImport } from './routes/api/workspace/missions' import { Route as ApiTasksTaskIdRouteImport } from './routes/api/tasks/$taskId' import { Route as ApiSessionsSendRouteImport } from './routes/api/sessions/send' import { Route as ApiMemoryWriteRouteImport } from './routes/api/memory/write' @@ -116,9 +121,11 @@ import { Route as ApiBrowserStatusRouteImport } from './routes/api/browser/statu import { Route as ApiBrowserScreenshotRouteImport } from './routes/api/browser/screenshot' import { Route as ApiBrowserNavigateRouteImport } from './routes/api/browser/navigate' import { Route as ApiGatewayApprovalsIndexRouteImport } from './routes/api/gateway/approvals/index' +import { Route as ApiWorkspaceProjectsIdRouteImport } from './routes/api/workspace/projects.$id' import { Route as ApiSessionsSessionKeyStatusRouteImport } from './routes/api/sessions/$sessionKey.status' import { Route as ApiCronRunsJobIdRouteImport } from './routes/api/cron/runs/$jobId' import { Route as ApiCliAgentsPidKillRouteImport } from './routes/api/cli-agents.$pid.kill' +import { Route as ApiWorkspaceMissionsIdStartRouteImport } from './routes/api/workspace/missions.$id.start' import { Route as ApiGatewayApprovalsApprovalIdActionRouteImport } from './routes/api/gateway/approvals/$approvalId/$action' const WizardRoute = WizardRouteImport.update({ @@ -156,6 +163,11 @@ const SessionsRoute = SessionsRouteImport.update({ path: '/sessions', getParentRoute: () => rootRouteImport, } as any) +const ProjectsRoute = ProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => rootRouteImport, +} as any) const NodesRoute = NodesRouteImport.update({ id: '/nodes', path: '/nodes', @@ -266,6 +278,11 @@ const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({ path: '/chat/$sessionKey', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkspaceTasksRoute = ApiWorkspaceTasksRouteImport.update({ + id: '/api/workspace-tasks', + path: '/api/workspace-tasks', + getParentRoute: () => rootRouteImport, +} as any) const ApiWorkspaceRoute = ApiWorkspaceRouteImport.update({ id: '/api/workspace', path: '/api/workspace', @@ -516,6 +533,21 @@ const ApiTasksIndexRoute = ApiTasksIndexRouteImport.update({ path: '/api/tasks/', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkspaceProjectsRoute = ApiWorkspaceProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => ApiWorkspaceRoute, +} as any) +const ApiWorkspacePhasesRoute = ApiWorkspacePhasesRouteImport.update({ + id: '/phases', + path: '/phases', + getParentRoute: () => ApiWorkspaceRoute, +} as any) +const ApiWorkspaceMissionsRoute = ApiWorkspaceMissionsRouteImport.update({ + id: '/missions', + path: '/missions', + getParentRoute: () => ApiWorkspaceRoute, +} as any) const ApiTasksTaskIdRoute = ApiTasksTaskIdRouteImport.update({ id: '/api/tasks/$taskId', path: '/api/tasks/$taskId', @@ -657,6 +689,11 @@ const ApiGatewayApprovalsIndexRoute = path: '/api/gateway/approvals/', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkspaceProjectsIdRoute = ApiWorkspaceProjectsIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ApiWorkspaceProjectsRoute, +} as any) const ApiSessionsSessionKeyStatusRoute = ApiSessionsSessionKeyStatusRouteImport.update({ id: '/$sessionKey/status', @@ -673,6 +710,12 @@ const ApiCliAgentsPidKillRoute = ApiCliAgentsPidKillRouteImport.update({ path: '/$pid/kill', getParentRoute: () => ApiCliAgentsRoute, } as any) +const ApiWorkspaceMissionsIdStartRoute = + ApiWorkspaceMissionsIdStartRouteImport.update({ + id: '/$id/start', + path: '/$id/start', + getParentRoute: () => ApiWorkspaceMissionsRoute, + } as any) const ApiGatewayApprovalsApprovalIdActionRoute = ApiGatewayApprovalsApprovalIdActionRouteImport.update({ id: '/api/gateway/approvals/$approvalId/$action', @@ -699,6 +742,7 @@ export interface FileRoutesByFullPath { '/memory': typeof MemoryRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute + '/projects': typeof ProjectsRoute '/sessions': typeof SessionsRoute '/settings': typeof SettingsRouteWithChildren '/skills': typeof SkillsRoute @@ -754,7 +798,8 @@ export interface FileRoutesByFullPath { '/api/usage': typeof ApiUsageRoute '/api/usage-analytics': typeof ApiUsageAnalyticsRoute '/api/validate-provider': typeof ApiValidateProviderRoute - '/api/workspace': typeof ApiWorkspaceRoute + '/api/workspace': typeof ApiWorkspaceRouteWithChildren + '/api/workspace-tasks': typeof ApiWorkspaceTasksRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute '/settings/providers': typeof SettingsProvidersRoute '/chat/': typeof ChatIndexRoute @@ -786,12 +831,17 @@ export interface FileRoutesByFullPath { '/api/memory/write': typeof ApiMemoryWriteRoute '/api/sessions/send': typeof ApiSessionsSendRoute '/api/tasks/$taskId': typeof ApiTasksTaskIdRoute + '/api/workspace/missions': typeof ApiWorkspaceMissionsRouteWithChildren + '/api/workspace/phases': typeof ApiWorkspacePhasesRoute + '/api/workspace/projects': typeof ApiWorkspaceProjectsRouteWithChildren '/api/tasks/': typeof ApiTasksIndexRoute '/api/cli-agents/$pid/kill': typeof ApiCliAgentsPidKillRoute '/api/cron/runs/$jobId': typeof ApiCronRunsJobIdRoute '/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute + '/api/workspace/projects/$id': typeof ApiWorkspaceProjectsIdRoute '/api/gateway/approvals/': typeof ApiGatewayApprovalsIndexRoute '/api/gateway/approvals/$approvalId/$action': typeof ApiGatewayApprovalsApprovalIdActionRoute + '/api/workspace/missions/$id/start': typeof ApiWorkspaceMissionsIdStartRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -812,6 +862,7 @@ export interface FileRoutesByTo { '/memory': typeof MemoryRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute + '/projects': typeof ProjectsRoute '/sessions': typeof SessionsRoute '/skills': typeof SkillsRoute '/tasks': typeof TasksRoute @@ -866,7 +917,8 @@ export interface FileRoutesByTo { '/api/usage': typeof ApiUsageRoute '/api/usage-analytics': typeof ApiUsageAnalyticsRoute '/api/validate-provider': typeof ApiValidateProviderRoute - '/api/workspace': typeof ApiWorkspaceRoute + '/api/workspace': typeof ApiWorkspaceRouteWithChildren + '/api/workspace-tasks': typeof ApiWorkspaceTasksRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute '/settings/providers': typeof SettingsProvidersRoute '/chat': typeof ChatIndexRoute @@ -898,12 +950,17 @@ export interface FileRoutesByTo { '/api/memory/write': typeof ApiMemoryWriteRoute '/api/sessions/send': typeof ApiSessionsSendRoute '/api/tasks/$taskId': typeof ApiTasksTaskIdRoute + '/api/workspace/missions': typeof ApiWorkspaceMissionsRouteWithChildren + '/api/workspace/phases': typeof ApiWorkspacePhasesRoute + '/api/workspace/projects': typeof ApiWorkspaceProjectsRouteWithChildren '/api/tasks': typeof ApiTasksIndexRoute '/api/cli-agents/$pid/kill': typeof ApiCliAgentsPidKillRoute '/api/cron/runs/$jobId': typeof ApiCronRunsJobIdRoute '/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute + '/api/workspace/projects/$id': typeof ApiWorkspaceProjectsIdRoute '/api/gateway/approvals': typeof ApiGatewayApprovalsIndexRoute '/api/gateway/approvals/$approvalId/$action': typeof ApiGatewayApprovalsApprovalIdActionRoute + '/api/workspace/missions/$id/start': typeof ApiWorkspaceMissionsIdStartRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -925,6 +982,7 @@ export interface FileRoutesById { '/memory': typeof MemoryRoute '/new': typeof NewRoute '/nodes': typeof NodesRoute + '/projects': typeof ProjectsRoute '/sessions': typeof SessionsRoute '/settings': typeof SettingsRouteWithChildren '/skills': typeof SkillsRoute @@ -980,7 +1038,8 @@ export interface FileRoutesById { '/api/usage': typeof ApiUsageRoute '/api/usage-analytics': typeof ApiUsageAnalyticsRoute '/api/validate-provider': typeof ApiValidateProviderRoute - '/api/workspace': typeof ApiWorkspaceRoute + '/api/workspace': typeof ApiWorkspaceRouteWithChildren + '/api/workspace-tasks': typeof ApiWorkspaceTasksRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute '/settings/providers': typeof SettingsProvidersRoute '/chat/': typeof ChatIndexRoute @@ -1012,12 +1071,17 @@ export interface FileRoutesById { '/api/memory/write': typeof ApiMemoryWriteRoute '/api/sessions/send': typeof ApiSessionsSendRoute '/api/tasks/$taskId': typeof ApiTasksTaskIdRoute + '/api/workspace/missions': typeof ApiWorkspaceMissionsRouteWithChildren + '/api/workspace/phases': typeof ApiWorkspacePhasesRoute + '/api/workspace/projects': typeof ApiWorkspaceProjectsRouteWithChildren '/api/tasks/': typeof ApiTasksIndexRoute '/api/cli-agents/$pid/kill': typeof ApiCliAgentsPidKillRoute '/api/cron/runs/$jobId': typeof ApiCronRunsJobIdRoute '/api/sessions/$sessionKey/status': typeof ApiSessionsSessionKeyStatusRoute + '/api/workspace/projects/$id': typeof ApiWorkspaceProjectsIdRoute '/api/gateway/approvals/': typeof ApiGatewayApprovalsIndexRoute '/api/gateway/approvals/$approvalId/$action': typeof ApiGatewayApprovalsApprovalIdActionRoute + '/api/workspace/missions/$id/start': typeof ApiWorkspaceMissionsIdStartRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -1040,6 +1104,7 @@ export interface FileRouteTypes { | '/memory' | '/new' | '/nodes' + | '/projects' | '/sessions' | '/settings' | '/skills' @@ -1096,6 +1161,7 @@ export interface FileRouteTypes { | '/api/usage-analytics' | '/api/validate-provider' | '/api/workspace' + | '/api/workspace-tasks' | '/chat/$sessionKey' | '/settings/providers' | '/chat/' @@ -1127,12 +1193,17 @@ export interface FileRouteTypes { | '/api/memory/write' | '/api/sessions/send' | '/api/tasks/$taskId' + | '/api/workspace/missions' + | '/api/workspace/phases' + | '/api/workspace/projects' | '/api/tasks/' | '/api/cli-agents/$pid/kill' | '/api/cron/runs/$jobId' | '/api/sessions/$sessionKey/status' + | '/api/workspace/projects/$id' | '/api/gateway/approvals/' | '/api/gateway/approvals/$approvalId/$action' + | '/api/workspace/missions/$id/start' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -1153,6 +1224,7 @@ export interface FileRouteTypes { | '/memory' | '/new' | '/nodes' + | '/projects' | '/sessions' | '/skills' | '/tasks' @@ -1208,6 +1280,7 @@ export interface FileRouteTypes { | '/api/usage-analytics' | '/api/validate-provider' | '/api/workspace' + | '/api/workspace-tasks' | '/chat/$sessionKey' | '/settings/providers' | '/chat' @@ -1239,12 +1312,17 @@ export interface FileRouteTypes { | '/api/memory/write' | '/api/sessions/send' | '/api/tasks/$taskId' + | '/api/workspace/missions' + | '/api/workspace/phases' + | '/api/workspace/projects' | '/api/tasks' | '/api/cli-agents/$pid/kill' | '/api/cron/runs/$jobId' | '/api/sessions/$sessionKey/status' + | '/api/workspace/projects/$id' | '/api/gateway/approvals' | '/api/gateway/approvals/$approvalId/$action' + | '/api/workspace/missions/$id/start' id: | '__root__' | '/' @@ -1265,6 +1343,7 @@ export interface FileRouteTypes { | '/memory' | '/new' | '/nodes' + | '/projects' | '/sessions' | '/settings' | '/skills' @@ -1321,6 +1400,7 @@ export interface FileRouteTypes { | '/api/usage-analytics' | '/api/validate-provider' | '/api/workspace' + | '/api/workspace-tasks' | '/chat/$sessionKey' | '/settings/providers' | '/chat/' @@ -1352,12 +1432,17 @@ export interface FileRouteTypes { | '/api/memory/write' | '/api/sessions/send' | '/api/tasks/$taskId' + | '/api/workspace/missions' + | '/api/workspace/phases' + | '/api/workspace/projects' | '/api/tasks/' | '/api/cli-agents/$pid/kill' | '/api/cron/runs/$jobId' | '/api/sessions/$sessionKey/status' + | '/api/workspace/projects/$id' | '/api/gateway/approvals/' | '/api/gateway/approvals/$approvalId/$action' + | '/api/workspace/missions/$id/start' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -1379,6 +1464,7 @@ export interface RootRouteChildren { MemoryRoute: typeof MemoryRoute NewRoute: typeof NewRoute NodesRoute: typeof NodesRoute + ProjectsRoute: typeof ProjectsRoute SessionsRoute: typeof SessionsRoute SettingsRoute: typeof SettingsRouteWithChildren SkillsRoute: typeof SkillsRoute @@ -1434,7 +1520,8 @@ export interface RootRouteChildren { ApiUsageRoute: typeof ApiUsageRoute ApiUsageAnalyticsRoute: typeof ApiUsageAnalyticsRoute ApiValidateProviderRoute: typeof ApiValidateProviderRoute - ApiWorkspaceRoute: typeof ApiWorkspaceRoute + ApiWorkspaceRoute: typeof ApiWorkspaceRouteWithChildren + ApiWorkspaceTasksRoute: typeof ApiWorkspaceTasksRoute ChatSessionKeyRoute: typeof ChatSessionKeyRoute ChatIndexRoute: typeof ChatIndexRoute ApiCloudProvisionRoute: typeof ApiCloudProvisionRoute @@ -1509,6 +1596,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SessionsRouteImport parentRoute: typeof rootRouteImport } + '/projects': { + id: '/projects' + path: '/projects' + fullPath: '/projects' + preLoaderRoute: typeof ProjectsRouteImport + parentRoute: typeof rootRouteImport + } '/nodes': { id: '/nodes' path: '/nodes' @@ -1663,6 +1757,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSessionKeyRouteImport parentRoute: typeof rootRouteImport } + '/api/workspace-tasks': { + id: '/api/workspace-tasks' + path: '/api/workspace-tasks' + fullPath: '/api/workspace-tasks' + preLoaderRoute: typeof ApiWorkspaceTasksRouteImport + parentRoute: typeof rootRouteImport + } '/api/workspace': { id: '/api/workspace' path: '/api/workspace' @@ -2013,6 +2114,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTasksIndexRouteImport parentRoute: typeof rootRouteImport } + '/api/workspace/projects': { + id: '/api/workspace/projects' + path: '/projects' + fullPath: '/api/workspace/projects' + preLoaderRoute: typeof ApiWorkspaceProjectsRouteImport + parentRoute: typeof ApiWorkspaceRoute + } + '/api/workspace/phases': { + id: '/api/workspace/phases' + path: '/phases' + fullPath: '/api/workspace/phases' + preLoaderRoute: typeof ApiWorkspacePhasesRouteImport + parentRoute: typeof ApiWorkspaceRoute + } + '/api/workspace/missions': { + id: '/api/workspace/missions' + path: '/missions' + fullPath: '/api/workspace/missions' + preLoaderRoute: typeof ApiWorkspaceMissionsRouteImport + parentRoute: typeof ApiWorkspaceRoute + } '/api/tasks/$taskId': { id: '/api/tasks/$taskId' path: '/api/tasks/$taskId' @@ -2209,6 +2331,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGatewayApprovalsIndexRouteImport parentRoute: typeof rootRouteImport } + '/api/workspace/projects/$id': { + id: '/api/workspace/projects/$id' + path: '/$id' + fullPath: '/api/workspace/projects/$id' + preLoaderRoute: typeof ApiWorkspaceProjectsIdRouteImport + parentRoute: typeof ApiWorkspaceProjectsRoute + } '/api/sessions/$sessionKey/status': { id: '/api/sessions/$sessionKey/status' path: '/$sessionKey/status' @@ -2230,6 +2359,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCliAgentsPidKillRouteImport parentRoute: typeof ApiCliAgentsRoute } + '/api/workspace/missions/$id/start': { + id: '/api/workspace/missions/$id/start' + path: '/$id/start' + fullPath: '/api/workspace/missions/$id/start' + preLoaderRoute: typeof ApiWorkspaceMissionsIdStartRouteImport + parentRoute: typeof ApiWorkspaceMissionsRoute + } '/api/gateway/approvals/$approvalId/$action': { id: '/api/gateway/approvals/$approvalId/$action' path: '/api/gateway/approvals/$approvalId/$action' @@ -2331,6 +2467,44 @@ const ApiSessionsRouteWithChildren = ApiSessionsRoute._addFileChildren( ApiSessionsRouteChildren, ) +interface ApiWorkspaceMissionsRouteChildren { + ApiWorkspaceMissionsIdStartRoute: typeof ApiWorkspaceMissionsIdStartRoute +} + +const ApiWorkspaceMissionsRouteChildren: ApiWorkspaceMissionsRouteChildren = { + ApiWorkspaceMissionsIdStartRoute: ApiWorkspaceMissionsIdStartRoute, +} + +const ApiWorkspaceMissionsRouteWithChildren = + ApiWorkspaceMissionsRoute._addFileChildren(ApiWorkspaceMissionsRouteChildren) + +interface ApiWorkspaceProjectsRouteChildren { + ApiWorkspaceProjectsIdRoute: typeof ApiWorkspaceProjectsIdRoute +} + +const ApiWorkspaceProjectsRouteChildren: ApiWorkspaceProjectsRouteChildren = { + ApiWorkspaceProjectsIdRoute: ApiWorkspaceProjectsIdRoute, +} + +const ApiWorkspaceProjectsRouteWithChildren = + ApiWorkspaceProjectsRoute._addFileChildren(ApiWorkspaceProjectsRouteChildren) + +interface ApiWorkspaceRouteChildren { + ApiWorkspaceMissionsRoute: typeof ApiWorkspaceMissionsRouteWithChildren + ApiWorkspacePhasesRoute: typeof ApiWorkspacePhasesRoute + ApiWorkspaceProjectsRoute: typeof ApiWorkspaceProjectsRouteWithChildren +} + +const ApiWorkspaceRouteChildren: ApiWorkspaceRouteChildren = { + ApiWorkspaceMissionsRoute: ApiWorkspaceMissionsRouteWithChildren, + ApiWorkspacePhasesRoute: ApiWorkspacePhasesRoute, + ApiWorkspaceProjectsRoute: ApiWorkspaceProjectsRouteWithChildren, +} + +const ApiWorkspaceRouteWithChildren = ApiWorkspaceRoute._addFileChildren( + ApiWorkspaceRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SplatRoute: SplatRoute, @@ -2350,6 +2524,7 @@ const rootRouteChildren: RootRouteChildren = { MemoryRoute: MemoryRoute, NewRoute: NewRoute, NodesRoute: NodesRoute, + ProjectsRoute: ProjectsRoute, SessionsRoute: SessionsRoute, SettingsRoute: SettingsRouteWithChildren, SkillsRoute: SkillsRoute, @@ -2405,7 +2580,8 @@ const rootRouteChildren: RootRouteChildren = { ApiUsageRoute: ApiUsageRoute, ApiUsageAnalyticsRoute: ApiUsageAnalyticsRoute, ApiValidateProviderRoute: ApiValidateProviderRoute, - ApiWorkspaceRoute: ApiWorkspaceRoute, + ApiWorkspaceRoute: ApiWorkspaceRouteWithChildren, + ApiWorkspaceTasksRoute: ApiWorkspaceTasksRoute, ChatSessionKeyRoute: ChatSessionKeyRoute, ChatIndexRoute: ChatIndexRoute, ApiCloudProvisionRoute: ApiCloudProvisionRoute, diff --git a/src/routes/api/workspace-tasks.ts b/src/routes/api/workspace-tasks.ts new file mode 100644 index 00000000..63035ea0 --- /dev/null +++ b/src/routes/api/workspace-tasks.ts @@ -0,0 +1,68 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace-tasks')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const ip = getClientIp(request) + if (!rateLimit(`workspace-tasks-get:${ip}`, 120, 60_000)) { + return rateLimitResponse() + } + + try { + const url = new URL(request.url) + return await forwardWorkspaceRequest({ + request, + path: '/tasks', + searchParams: url.searchParams, + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + + POST: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-tasks-post:${ip}`, 30, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: '/tasks', + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/workspace/missions.$id.start.ts b/src/routes/api/workspace/missions.$id.start.ts new file mode 100644 index 00000000..7d78a7e9 --- /dev/null +++ b/src/routes/api/workspace/missions.$id.start.ts @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace/missions/$id/start')({ + server: { + handlers: { + POST: async ({ request, params }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-mission-start:${ip}`, 20, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: `/missions/${params.id}/start`, + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/workspace/missions.ts b/src/routes/api/workspace/missions.ts new file mode 100644 index 00000000..dcb66ba0 --- /dev/null +++ b/src/routes/api/workspace/missions.ts @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace/missions')({ + server: { + handlers: { + POST: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-missions-post:${ip}`, 30, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: '/missions', + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/workspace/phases.ts b/src/routes/api/workspace/phases.ts new file mode 100644 index 00000000..dc13bed8 --- /dev/null +++ b/src/routes/api/workspace/phases.ts @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace/phases')({ + server: { + handlers: { + POST: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-phases-post:${ip}`, 30, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: '/phases', + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/workspace/projects.$id.ts b/src/routes/api/workspace/projects.$id.ts new file mode 100644 index 00000000..4ebbd5ac --- /dev/null +++ b/src/routes/api/workspace/projects.$id.ts @@ -0,0 +1,89 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace/projects/$id')({ + server: { + handlers: { + GET: async ({ request, params }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const ip = getClientIp(request) + if (!rateLimit(`workspace-project-get:${ip}`, 120, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: `/projects/${params.id}`, + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + + PUT: async ({ request, params }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-project-put:${ip}`, 30, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: `/projects/${params.id}`, + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + + DELETE: async ({ request, params }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const ip = getClientIp(request) + if (!rateLimit(`workspace-project-delete:${ip}`, 20, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: `/projects/${params.id}`, + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/api/workspace/projects.ts b/src/routes/api/workspace/projects.ts new file mode 100644 index 00000000..370509ba --- /dev/null +++ b/src/routes/api/workspace/projects.ts @@ -0,0 +1,66 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { isAuthenticated } from '../../../server/auth-middleware' +import { + getClientIp, + rateLimit, + rateLimitResponse, + requireJsonContentType, + safeErrorMessage, +} from '../../../server/rate-limit' +import { forwardWorkspaceRequest } from '../../../server/workspace-proxy' + +export const Route = createFileRoute('/api/workspace/projects')({ + server: { + handlers: { + GET: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const ip = getClientIp(request) + if (!rateLimit(`workspace-projects-get:${ip}`, 120, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: '/projects', + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + + POST: async ({ request }) => { + if (!isAuthenticated(request)) { + return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + } + + const csrfCheck = requireJsonContentType(request) + if (csrfCheck) return csrfCheck + + const ip = getClientIp(request) + if (!rateLimit(`workspace-projects-post:${ip}`, 30, 60_000)) { + return rateLimitResponse() + } + + try { + return await forwardWorkspaceRequest({ + request, + path: '/projects', + }) + } catch (error) { + return json( + { ok: false, error: safeErrorMessage(error) }, + { status: 502 }, + ) + } + }, + }, + }, +}) diff --git a/src/routes/projects.tsx b/src/routes/projects.tsx new file mode 100644 index 00000000..3fde6cce --- /dev/null +++ b/src/routes/projects.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { usePageTitle } from '@/hooks/use-page-title' +import { ProjectsScreen } from '@/screens/projects/projects-screen' + +export const Route = createFileRoute('/projects')({ + component: function ProjectsRoute() { + usePageTitle('Projects') + return + }, + errorComponent: function ProjectsError({ error }) { + return ( +
+

+ Failed to Load Projects +

+

+ {error instanceof Error + ? error.message + : 'An unexpected error occurred'} +

+ +
+ ) + }, + pendingComponent: function ProjectsPending() { + return ( +
+
+
+

Loading projects...

+
+
+ ) + }, +}) diff --git a/src/screens/chat/components/chat-sidebar.tsx b/src/screens/chat/components/chat-sidebar.tsx index ab5ab924..a349cf05 100644 --- a/src/screens/chat/components/chat-sidebar.tsx +++ b/src/screens/chat/components/chat-sidebar.tsx @@ -19,6 +19,7 @@ import { PuzzleIcon, Search01Icon, ApiIcon, + Folder01Icon, Settings01Icon, ServerStack01Icon, SmartPhone01Icon, @@ -540,6 +541,7 @@ function ChatSidebarComponent({ const isBrowserActive = pathname === '/browser' const isTerminalActive = pathname === '/terminal' const isTasksActive = pathname === '/tasks' + const isProjectsActive = pathname.startsWith('/projects') // Gateway const isCronActive = pathname === '/cron' const isChannelsActive = pathname === '/channels' @@ -560,6 +562,7 @@ function ChatSidebarComponent({ const suiteRoutes = [ '/dashboard', '/agent-swarm', + '/projects', '/new', '/browser', '/terminal', @@ -789,6 +792,13 @@ function ChatSidebarComponent({ active: isAgentSwarmActive, dataTour: 'agent-hub', }, + { + kind: 'link', + to: '/projects', + icon: Folder01Icon, + label: 'Projects', + active: isProjectsActive, + }, { kind: 'link', to: '/browser', diff --git a/src/screens/projects/projects-screen.tsx b/src/screens/projects/projects-screen.tsx new file mode 100644 index 00000000..71dbcd54 --- /dev/null +++ b/src/screens/projects/projects-screen.tsx @@ -0,0 +1,1187 @@ +import { + Add01Icon, + ArrowDown01Icon, + ArrowRight01Icon, + Folder01Icon, + PlayCircleIcon, + Task01Icon, +} from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import type React from 'react' +import { useEffect, useMemo, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + DialogClose, + DialogContent, + DialogDescription, + DialogRoot, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/toast' +import { cn } from '@/lib/utils' + +type WorkspaceStatus = + | 'pending' + | 'ready' + | 'running' + | 'completed' + | 'failed' + | string + +type WorkspaceTask = { + id: string + mission_id?: string + name: string + description?: string + status: WorkspaceStatus + sort_order?: number + depends_on: string[] +} + +type WorkspaceMission = { + id: string + phase_id?: string + name: string + status: WorkspaceStatus + tasks: Array +} + +type WorkspacePhase = { + id: string + project_id?: string + name: string + sort_order?: number + missions: Array +} + +type WorkspaceProject = { + id: string + name: string + path?: string + spec?: string + status: WorkspaceStatus + phases: Array +} + +type ProjectFormState = { + name: string + path: string + spec: string +} + +type PhaseFormState = { + name: string +} + +type MissionFormState = { + name: string +} + +type TaskFormState = { + name: string + description: string + dependsOn: string +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function normalizeStatus(value: unknown): WorkspaceStatus { + return asString(value) ?? 'pending' +} + +function normalizeTask(value: unknown): WorkspaceTask { + const record = asRecord(value) + return { + id: asString(record?.id) ?? asString(record?.task_id) ?? crypto.randomUUID(), + mission_id: asString(record?.mission_id), + name: asString(record?.name) ?? asString(record?.title) ?? 'Untitled task', + description: asString(record?.description), + status: normalizeStatus(record?.status), + sort_order: asNumber(record?.sort_order), + depends_on: asArray(record?.depends_on).map((item) => String(item)), + } +} + +function normalizeMission(value: unknown): WorkspaceMission { + const record = asRecord(value) + return { + id: asString(record?.id) ?? asString(record?.mission_id) ?? crypto.randomUUID(), + phase_id: asString(record?.phase_id), + name: asString(record?.name) ?? 'Untitled mission', + status: normalizeStatus(record?.status), + tasks: asArray(record?.tasks).map(normalizeTask), + } +} + +function normalizePhase(value: unknown): WorkspacePhase { + const record = asRecord(value) + return { + id: asString(record?.id) ?? asString(record?.phase_id) ?? crypto.randomUUID(), + project_id: asString(record?.project_id), + name: asString(record?.name) ?? 'Untitled phase', + sort_order: asNumber(record?.sort_order), + missions: asArray(record?.missions).map(normalizeMission), + } +} + +function normalizeProject(value: unknown): WorkspaceProject { + const record = asRecord(value) + return { + id: asString(record?.id) ?? asString(record?.project_id) ?? crypto.randomUUID(), + name: asString(record?.name) ?? 'Untitled project', + path: asString(record?.path), + spec: asString(record?.spec), + status: normalizeStatus(record?.status), + phases: asArray(record?.phases).map(normalizePhase), + } +} + +function extractProjects(payload: unknown): Array { + if (Array.isArray(payload)) return payload.map(normalizeProject) + + const record = asRecord(payload) + const candidates = [record?.projects, record?.data, record?.items] + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate.map(normalizeProject) + } + } + + return [] +} + +function extractProject(payload: unknown): WorkspaceProject | null { + if (Array.isArray(payload)) return payload[0] ? normalizeProject(payload[0]) : null + + const record = asRecord(payload) + const projectValue = record?.project ?? record?.data ?? payload + const projectRecord = asRecord(projectValue) + return projectRecord ? normalizeProject(projectRecord) : null +} + +function extractTasks(payload: unknown): Array { + if (Array.isArray(payload)) return payload.map(normalizeTask) + + const record = asRecord(payload) + const candidates = [record?.tasks, record?.data, record?.items] + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate.map(normalizeTask) + } + } + + return [] +} + +function getMissionCount(project: WorkspaceProject): number { + return project.phases.reduce((count, phase) => count + phase.missions.length, 0) +} + +function getTaskCount(project: WorkspaceProject): number { + return project.phases.reduce( + (count, phase) => + count + + phase.missions.reduce((missionCount, mission) => missionCount + mission.tasks.length, 0), + 0, + ) +} + +function getStatusBadgeClass(status: WorkspaceStatus): string { + if (status === 'ready') { + return 'border-blue-500/30 bg-blue-500/10 text-blue-300' + } + if (status === 'running') { + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' + } + if (status === 'completed') { + return 'border-green-500/30 bg-green-500/10 text-green-300' + } + if (status === 'failed') { + return 'border-red-500/30 bg-red-500/10 text-red-300' + } + return 'border-primary-700 bg-primary-800/70 text-primary-300' +} + +function getTaskDotClass(status: WorkspaceStatus): string { + if (status === 'ready') return 'bg-blue-400' + if (status === 'running' || status === 'in_progress') return 'bg-emerald-400' + if (status === 'completed' || status === 'done') return 'bg-green-400' + if (status === 'failed') return 'bg-red-400' + return 'bg-primary-500' +} + +function formatStatus(status: WorkspaceStatus): string { + return status + .replace(/_/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +async function readPayload(response: Response): Promise { + const text = await response.text() + if (!text) return null + + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +async function apiRequest(input: string, init?: RequestInit): Promise { + const response = await fetch(input, init) + const payload = await readPayload(response) + + if (!response.ok) { + const record = asRecord(payload) + throw new Error( + asString(record?.error) ?? + asString(record?.message) ?? + `Request failed with status ${response.status}`, + ) + } + + return payload +} + +async function loadMissionTasks(missionId: string): Promise> { + const payload = await apiRequest( + `/api/workspace-tasks?mission_id=${encodeURIComponent(missionId)}`, + ) + return extractTasks(payload) +} + +type CreateDialogProps = { + open: boolean + title: string + description: string + submitting: boolean + onOpenChange: (open: boolean) => void + children: React.ReactNode + onSubmit: (event: React.FormEvent) => void + submitLabel: string +} + +function CreateDialog({ + open, + title, + description, + submitting, + onOpenChange, + children, + onSubmit, + submitLabel, +}: CreateDialogProps) { + return ( + + +
+
+ + {title} + + + {description} + +
+ +
{children}
+ +
+ Cancel} /> + +
+
+
+
+ ) +} + +function FieldLabel({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( + + ) +} + +export function ProjectsScreen() { + const [projects, setProjects] = useState>([]) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [projectDetail, setProjectDetail] = useState(null) + const [expandedPhases, setExpandedPhases] = useState>({}) + const [listLoading, setListLoading] = useState(true) + const [detailLoading, setDetailLoading] = useState(false) + const [refreshToken, setRefreshToken] = useState(0) + const [projectDialogOpen, setProjectDialogOpen] = useState(false) + const [phaseProject, setPhaseProject] = useState(null) + const [missionPhase, setMissionPhase] = useState(null) + const [taskMission, setTaskMission] = useState(null) + const [submittingKey, setSubmittingKey] = useState(null) + const [projectForm, setProjectForm] = useState({ + name: '', + path: '', + spec: '', + }) + const [phaseForm, setPhaseForm] = useState({ name: '' }) + const [missionForm, setMissionForm] = useState({ name: '' }) + const [taskForm, setTaskForm] = useState({ + name: '', + description: '', + dependsOn: '', + }) + + useEffect(() => { + let cancelled = false + + async function fetchProjects() { + setListLoading(true) + + try { + const payload = await apiRequest('/api/workspace/projects') + if (cancelled) return + + const nextProjects = extractProjects(payload) + setProjects(nextProjects) + + setSelectedProjectId((current) => { + if (current && nextProjects.some((project) => project.id === current)) { + return current + } + return nextProjects[0]?.id ?? null + }) + } catch (error) { + if (!cancelled) { + toast( + error instanceof Error ? error.message : 'Failed to load projects', + { type: 'error' }, + ) + } + } finally { + if (!cancelled) { + setListLoading(false) + } + } + } + + void fetchProjects() + + return () => { + cancelled = true + } + }, [refreshToken]) + + useEffect(() => { + if (!selectedProjectId) { + setProjectDetail(null) + return + } + + let cancelled = false + + async function fetchProjectDetail() { + setDetailLoading(true) + + try { + const payload = await apiRequest(`/api/workspace/projects/${selectedProjectId}`) + const detail = extractProject(payload) + + if (!detail) { + throw new Error('Project detail was empty') + } + + const taskEntries = await Promise.all( + detail.phases.flatMap((phase) => + phase.missions.map(async (mission) => ({ + missionId: mission.id, + tasks: await loadMissionTasks(mission.id), + })), + ), + ) + + if (cancelled) return + + const taskMap = new Map(taskEntries.map((entry) => [entry.missionId, entry.tasks])) + const hydratedDetail: WorkspaceProject = { + ...detail, + phases: detail.phases + .slice() + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) + .map((phase) => ({ + ...phase, + missions: phase.missions.map((mission) => ({ + ...mission, + tasks: taskMap.get(mission.id) ?? mission.tasks, + })), + })), + } + + setProjectDetail(hydratedDetail) + setExpandedPhases((current) => { + const next = { ...current } + for (const phase of hydratedDetail.phases) { + if (next[phase.id] === undefined) { + next[phase.id] = true + } + } + return next + }) + } catch (error) { + if (!cancelled) { + toast( + error instanceof Error + ? error.message + : 'Failed to load project detail', + { type: 'error' }, + ) + } + } finally { + if (!cancelled) { + setDetailLoading(false) + } + } + } + + void fetchProjectDetail() + + return () => { + cancelled = true + } + }, [selectedProjectId, refreshToken]) + + const selectedSummary = useMemo( + () => projects.find((project) => project.id === selectedProjectId) ?? null, + [projects, selectedProjectId], + ) + + function triggerRefresh() { + setRefreshToken((value) => value + 1) + } + + async function handleCreateProject(event: React.FormEvent) { + event.preventDefault() + if (!projectForm.name.trim()) { + toast('Project name is required', { type: 'warning' }) + return + } + + setSubmittingKey('project') + + try { + await apiRequest('/api/workspace/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: projectForm.name.trim(), + path: projectForm.path.trim() || undefined, + spec: projectForm.spec.trim() || undefined, + }), + }) + + toast('Project created', { type: 'success' }) + setProjectDialogOpen(false) + setProjectForm({ name: '', path: '', spec: '' }) + triggerRefresh() + } catch (error) { + toast(error instanceof Error ? error.message : 'Failed to create project', { + type: 'error', + }) + } finally { + setSubmittingKey(null) + } + } + + async function handleCreatePhase(event: React.FormEvent) { + event.preventDefault() + if (!phaseProject || !phaseForm.name.trim()) { + toast('Phase name is required', { type: 'warning' }) + return + } + + setSubmittingKey('phase') + + try { + await apiRequest('/api/workspace/phases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: phaseProject.id, + name: phaseForm.name.trim(), + sort_order: phaseProject.phases.length, + }), + }) + + toast('Phase added', { type: 'success' }) + setPhaseProject(null) + setPhaseForm({ name: '' }) + triggerRefresh() + } catch (error) { + toast(error instanceof Error ? error.message : 'Failed to add phase', { + type: 'error', + }) + } finally { + setSubmittingKey(null) + } + } + + async function handleCreateMission(event: React.FormEvent) { + event.preventDefault() + if (!missionPhase || !missionForm.name.trim()) { + toast('Mission name is required', { type: 'warning' }) + return + } + + setSubmittingKey('mission') + + try { + await apiRequest('/api/workspace/missions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase_id: missionPhase.id, + name: missionForm.name.trim(), + }), + }) + + toast('Mission added', { type: 'success' }) + setMissionPhase(null) + setMissionForm({ name: '' }) + triggerRefresh() + } catch (error) { + toast(error instanceof Error ? error.message : 'Failed to add mission', { + type: 'error', + }) + } finally { + setSubmittingKey(null) + } + } + + async function handleCreateTask(event: React.FormEvent) { + event.preventDefault() + if (!taskMission || !taskForm.name.trim()) { + toast('Task name is required', { type: 'warning' }) + return + } + + setSubmittingKey('task') + + try { + await apiRequest('/api/workspace-tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mission_id: taskMission.id, + name: taskForm.name.trim(), + description: taskForm.description.trim(), + sort_order: taskMission.tasks.length, + depends_on: taskForm.dependsOn + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + }), + }) + + toast('Task added', { type: 'success' }) + setTaskMission(null) + setTaskForm({ name: '', description: '', dependsOn: '' }) + triggerRefresh() + } catch (error) { + toast(error instanceof Error ? error.message : 'Failed to add task', { + type: 'error', + }) + } finally { + setSubmittingKey(null) + } + } + + async function handleStartMission(missionId: string) { + setSubmittingKey(`start:${missionId}`) + + try { + await apiRequest(`/api/workspace/missions/${missionId}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + toast('Mission started', { type: 'success' }) + triggerRefresh() + } catch (error) { + toast(error instanceof Error ? error.message : 'Failed to start mission', { + type: 'error', + }) + } finally { + setSubmittingKey(null) + } + } + + function togglePhase(phaseId: string) { + setExpandedPhases((current) => ({ + ...current, + [phaseId]: !current[phaseId], + })) + } + + return ( +
+
+
+
+
+ +
+
+

+ Projects +

+

+ Track specs, phases, missions, and execution work across your workspace. +

+
+
+ +
+ + +
+
+ + {listLoading && projects.length === 0 ? ( +
+
+

Loading workspace projects...

+
+ ) : projects.length === 0 ? ( +
+
+ +
+

+ No projects yet +

+

+ Create your first project to organize phases, missions, and task execution for an agent workflow. +

+ +
+ ) : ( +
+
+ {projects.map((project) => { + const active = project.id === selectedProjectId + return ( + + ) + })} +
+ +
+ {selectedSummary ? ( + <> +
+
+
+

+ {projectDetail?.name ?? selectedSummary.name} +

+ + {formatStatus(projectDetail?.status ?? selectedSummary.status)} + +
+
+

{projectDetail?.path || selectedSummary.path || 'No path configured'}

+ {(projectDetail?.spec || selectedSummary.spec) && ( +

+ {projectDetail?.spec || selectedSummary.spec} +

+ )} +
+
+ + +
+ + {detailLoading ? ( +
+
+

Loading project detail...

+
+ ) : projectDetail && projectDetail.phases.length > 0 ? ( +
+ {projectDetail.phases.map((phase, phaseIndex) => { + const expanded = expandedPhases[phase.id] ?? true + return ( +
+ + +
+ + + {expanded ? ( +
+ {phase.missions.length === 0 ? ( +
+ No missions in this phase yet. +
+ ) : ( + phase.missions.map((mission) => ( +
+
+
+
+

+ {mission.name} +

+ + {formatStatus(mission.status)} + +
+

+ {mission.tasks.length} task + {mission.tasks.length === 1 ? '' : 's'} +

+
+ +
+ {mission.status !== 'running' && + mission.status !== 'completed' ? ( + + ) : null} + +
+
+ +
+ {mission.tasks.length === 0 ? ( +
+ No tasks for this mission yet. +
+ ) : ( + mission.tasks.map((task) => ( +
+
+
+ +

+ {task.name} +

+
+ {task.description ? ( +

+ {task.description} +

+ ) : null} + {task.depends_on.length > 0 ? ( +

+ Depends on: {task.depends_on.join(', ')} +

+ ) : null} +
+ + {formatStatus(task.status)} + +
+ )) + )} +
+
+ )) + )} +
+ ) : null} +
+ ) + })} +
+ ) : ( +
+

+ This project has no phases yet. +

+

+ Add a phase to start structuring the work. +

+
+ )} + + ) : ( +
+
+

+ Pick a project +

+

+ Select a project from the list to inspect phases, missions, and tasks. +

+
+
+ )} + + + )} + + + + + + setProjectForm((current) => ({ + ...current, + name: event.target.value, + })) + } + className="w-full rounded-xl border border-primary-700 bg-primary-800 px-3 py-2.5 text-sm text-primary-100 outline-none transition-colors focus:border-accent-500" + placeholder="OpenClaw Workspace Refresh" + autoFocus + /> + + + + setProjectForm((current) => ({ + ...current, + path: event.target.value, + })) + } + className="w-full rounded-xl border border-primary-700 bg-primary-800 px-3 py-2.5 text-sm text-primary-100 outline-none transition-colors focus:border-accent-500" + placeholder="/Users/aurora/.openclaw/workspace/clawsuite" + /> + + +