Merge codex/spoon-stabilize-unify-build: 5-phase stabilize/unify/build (65 tasks)
Phases 1-5 complete: stabilization criticals, runtime unification (codex/opencode/claude in per-user box), dev-box /machine surface, GitHub sync correctness + webhooks, notifications + polish. Final whole-branch review passed; all packages green.
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
|
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
|
||||||
durable maintenance threads, and OpenCode-powered workspaces.
|
durable maintenance threads, and a persistent per-user dev box that agents,
|
||||||
|
terminals, and project commands all run inside.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -61,14 +62,38 @@ in the data model, but GitHub is the active automation surface today.
|
|||||||
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
|
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
|
||||||
changes so irrelevant commits do not keep a fork permanently actionable.
|
changes so irrelevant commits do not keep a fork permanently actionable.
|
||||||
|
|
||||||
- **OpenCode workspaces**
|
- **One persistent per-user box**
|
||||||
Agent work happens in an isolated workspace with a file tree, browser editor,
|
Every user owns a single long-running Fedora container `spoon-box-{username}`
|
||||||
diff viewer, command panel, logs, artifacts, and draft PR actions.
|
with a persistent home. Every thread's agent turn, terminal session, and
|
||||||
|
project command `docker exec`s into that same box — there is no per-job
|
||||||
|
throwaway container.
|
||||||
|
|
||||||
|
- **Three agent runtimes**
|
||||||
|
Codex, OpenCode, and Claude Code all run inside the box behind one adapter
|
||||||
|
interface, selected per thread and validated at queue time against the AI
|
||||||
|
provider profile.
|
||||||
|
|
||||||
|
- **Workspaces**
|
||||||
|
Agent work happens in a workspace with a file tree, browser editor, diff
|
||||||
|
viewer, command panel, logs, artifacts, an interactive terminal, and draft PR
|
||||||
|
actions.
|
||||||
|
|
||||||
|
- **My Machine**
|
||||||
|
A `/machine` surface to start/stop/restart the box, open a `~`-rooted
|
||||||
|
terminal, and browse the persistent home.
|
||||||
|
|
||||||
|
- **GitHub webhooks**
|
||||||
|
A signature-verified webhook keeps drift fresh on `push` and flips connection
|
||||||
|
status on `installation` changes; the hourly cron is a fallback.
|
||||||
|
|
||||||
|
- **Notifications**
|
||||||
|
An in-app bell plus preference-gated transactional email for maintenance
|
||||||
|
threads, agent turns, needed input, sync failures, and connection re-auth.
|
||||||
|
|
||||||
- **User-owned providers and secrets**
|
- **User-owned providers and secrets**
|
||||||
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
|
AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
|
||||||
encrypted. Secrets are redacted from logs and refused from commits when
|
secrets are encrypted. Secrets are written `0600`, redacted from logs, and
|
||||||
materialized into env files.
|
refused from commits when materialized into env files.
|
||||||
|
|
||||||
- **Draft PR handoff**
|
- **Draft PR handoff**
|
||||||
Code changes become branches and draft pull requests. Spoon does not
|
Code changes become branches and draft pull requests. Spoon does not
|
||||||
@@ -138,12 +163,23 @@ custom work exists.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>OpenCode workspaces</strong></summary>
|
<summary><strong>The box and workspaces</strong></summary>
|
||||||
|
|
||||||
Spoon's optional agent worker is designed to run outside Convex actions. The
|
Spoon's optional agent worker is designed to run outside Convex actions. The
|
||||||
worker claims queued jobs, clones the current GitHub fork, creates a branch,
|
worker claims queued jobs, clones the current GitHub fork into the owner's
|
||||||
starts an isolated workspace, and exposes workspace operations to the Next app
|
persistent home, creates a branch, and exposes workspace operations to the Next
|
||||||
through server-only API proxies.
|
app through server-only API proxies.
|
||||||
|
|
||||||
|
Everything runs inside **one long-running container per user**,
|
||||||
|
`spoon-box-{username}`. The box is a Fedora image (`docker/agent-job.Dockerfile`)
|
||||||
|
started with `sleep infinity` and a `--memory 4g` / `--cpus 2` cap. Each
|
||||||
|
thread's agent turn, terminal, and project commands `docker exec` into that same
|
||||||
|
box; the box's `/home/{username}` is a bind-mounted persistent home so dotfiles,
|
||||||
|
installed tools, shell history, and thread checkouts under `~/Code/{spoon}/{branch}`
|
||||||
|
survive across sessions. The box is reference-counted by the worker and reaped
|
||||||
|
after `SPOON_AGENT_BOX_IDLE_MS` idle (default 30m). There is no per-job
|
||||||
|
`docker run --rm` container and no separate `opencode serve` container — that
|
||||||
|
path was removed.
|
||||||
|
|
||||||
Workspace capabilities:
|
Workspace capabilities:
|
||||||
|
|
||||||
@@ -154,16 +190,18 @@ Workspace capabilities:
|
|||||||
- inspect diffs
|
- inspect diffs
|
||||||
- send thread messages to the agent
|
- send thread messages to the agent
|
||||||
- run configured commands
|
- run configured commands
|
||||||
|
- use an interactive terminal (xterm.js → box PTY; see
|
||||||
|
[docs/agent-terminal.md](docs/agent-terminal.md))
|
||||||
- store logs and artifacts
|
- store logs and artifacts
|
||||||
- push a branch
|
- push a branch
|
||||||
- open a draft PR
|
- open a draft PR
|
||||||
|
|
||||||
The browser never receives worker tokens and never talks directly to the worker
|
The browser never receives worker tokens and never talks directly to the worker
|
||||||
or job container.
|
or the box.
|
||||||
|
|
||||||
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
|
Worker cleanup is available in `Settings -> Worker`. It can delete stale
|
||||||
workspace records and ask the active worker to remove orphaned job containers
|
workspace records and ask the active worker to remove orphaned containers and
|
||||||
and inactive work directories.
|
inactive work directories (persistent per-user homes are preserved).
|
||||||
|
|
||||||
Local worker development:
|
Local worker development:
|
||||||
|
|
||||||
@@ -174,20 +212,53 @@ bun dev:next:worker
|
|||||||
bun dev:next:worker:staging
|
bun dev:next:worker:staging
|
||||||
```
|
```
|
||||||
|
|
||||||
Local host-run worker commands still load env through Infisical, then
|
Local host-run worker commands load env through Infisical, then
|
||||||
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
|
`scripts/dev-agent-worker` selects Podman when available and falls back to
|
||||||
and publishes the OpenCode server on a localhost port so the host worker can
|
Docker. Override the container CLI with:
|
||||||
reach the job container. Override with:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||||
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Production agent runtime images</strong></summary>
|
<summary><strong>Agent runtimes</strong></summary>
|
||||||
|
|
||||||
|
Three agent CLIs run inside the box behind one `AgentRuntime` adapter interface
|
||||||
|
(`apps/agent-worker/src/runtime/*-adapter.ts`), selected by `job.runtime`:
|
||||||
|
|
||||||
|
| Runtime | CLI | Selected for |
|
||||||
|
| ---------- | ----------------------------------- | -------------------------------------------------- |
|
||||||
|
| `codex` | `@openai/codex` | OpenAI providers and Codex ChatGPT-login snapshots |
|
||||||
|
| `opencode` | `opencode-ai` | OpenAI-compatible API-key providers (default) |
|
||||||
|
| `claude` | `@anthropic-ai/claude-code@2.1.207` | Anthropic providers |
|
||||||
|
|
||||||
|
The runtime is validated at queue time (`runtimeSupport.ts` +
|
||||||
|
`agentJobs.insertJob`): a job is rejected unless the resolved runtime is one the
|
||||||
|
AI provider profile supports. `runtimesForProfile` maps profiles to runtimes —
|
||||||
|
ChatGPT-login snapshots → `codex` only; `openai` API keys → `opencode`/`codex`;
|
||||||
|
Anthropic API keys → `claude`/`opencode`; Anthropic `anthropic_oauth_json`
|
||||||
|
credential snapshots → `claude` only; every other OpenAI-compatible API key →
|
||||||
|
`opencode`.
|
||||||
|
|
||||||
|
Auth is materialized into the box before a turn:
|
||||||
|
|
||||||
|
- **Codex ChatGPT-login** profiles get the encrypted `auth.json` written to
|
||||||
|
`CODEX_HOME/.codex/auth.json`.
|
||||||
|
- **Anthropic API-key** profiles authenticate via `ANTHROPIC_API_KEY` in the
|
||||||
|
environment (no file needed).
|
||||||
|
- **Anthropic `anthropic_oauth_json`** credential-snapshot profiles get the
|
||||||
|
encrypted OAuth blob written to `~/.claude/.credentials.json`.
|
||||||
|
- **API-key** profiles run through OpenCode.
|
||||||
|
|
||||||
|
All materialized auth files are written `0600`. Treat those saved auth files
|
||||||
|
like a password and only use them on trusted workers.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Production runtime images</strong></summary>
|
||||||
|
|
||||||
Gitea CI builds and pushes three production images:
|
Gitea CI builds and pushes three production images:
|
||||||
|
|
||||||
@@ -197,31 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker:latest
|
|||||||
git.gbrown.org/gib/spoon-agent-job:latest
|
git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The worker image is the long-running service that polls Convex. The job image is
|
The worker image is the long-running service that polls Convex. The
|
||||||
the isolated workbench that the worker launches for each agent job. For the MVP,
|
`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
|
||||||
production should use the repo-provided JS/TS workbench image:
|
worker `exec`s into (it is not launched fresh per job). Point the worker at it
|
||||||
|
with:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
||||||
```
|
```
|
||||||
|
|
||||||
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
|
||||||
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
|
||||||
project's production runtime; it is the agent execution environment.
|
zoxide, gh, gum, oh-my-posh) and the pinned agent CLIs OpenCode, Codex, and
|
||||||
|
Claude Code. It is not the forked project's production runtime; it is the agent
|
||||||
|
execution environment.
|
||||||
|
|
||||||
Production worker runtime requirements:
|
Production worker runtime requirements:
|
||||||
|
|
||||||
- `spoon-agent-worker` must run as a separate service.
|
- `spoon-agent-worker` must run as a separate service.
|
||||||
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
|
||||||
containers.
|
into per-user boxes on the host daemon.
|
||||||
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
|
||||||
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
|
||||||
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
`SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
|
||||||
can pull the private `spoon-agent-job` image.
|
the host daemon can bind-mount per-user homes under `${WORKDIR}/homes/{username}`.
|
||||||
|
- The production Docker host must be logged into `git.gbrown.org` so the worker
|
||||||
|
can pull the private `spoon-agent-job` box image.
|
||||||
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
||||||
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
|
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
|
||||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
|
||||||
file, diff, message, command, and draft PR actions.
|
box file, diff, message, command, terminal, and draft PR actions.
|
||||||
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||||
If the private key is stored in a single-line dotenv value, encode newlines as
|
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||||
literal `\n` characters so the worker can restore the PEM before using it.
|
literal `\n` characters so the worker can restore the PEM before using it.
|
||||||
@@ -240,22 +316,57 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
|||||||
Deployment readiness checklist:
|
Deployment readiness checklist:
|
||||||
|
|
||||||
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
||||||
GitHub App env, and Convex Auth signing keys.
|
GitHub App env (including `GITHUB_APP_WEBHOOK_SECRET`), and Convex Auth
|
||||||
|
signing keys.
|
||||||
2. Compose env has `SPOON_AGENT_WORKER_URL`,
|
2. Compose env has `SPOON_AGENT_WORKER_URL`,
|
||||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
|
||||||
App private key.
|
App private key; the `spoon-next` image is built with
|
||||||
|
`NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL`.
|
||||||
3. The production Docker host can pull private images from `git.gbrown.org`.
|
3. The production Docker host can pull private images from `git.gbrown.org`.
|
||||||
4. `Settings -> Worker` reports the expected job image, runtime, network, and
|
4. `Settings -> Worker` reports the expected box image, runtime, network, and
|
||||||
active workspace count.
|
active box count.
|
||||||
5. The first test thread uses a configured API-key provider or a trusted Codex
|
5. The first test thread uses a configured API-key provider or a trusted Codex
|
||||||
login profile.
|
login profile.
|
||||||
6. If a worker restart leaves stale workspace state, use the workspace recovery
|
6. If a worker restart leaves stale state, use the workspace recovery panel or
|
||||||
panel or `Settings -> Worker` cleanup.
|
`Settings -> Worker` cleanup.
|
||||||
|
|
||||||
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
</details>
|
||||||
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
|
|
||||||
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
|
<details>
|
||||||
Treat that saved auth file like a password and only use it on trusted workers.
|
<summary><strong>GitHub webhooks</strong></summary>
|
||||||
|
|
||||||
|
Spoon exposes a signature-verified webhook as a Convex `httpAction` at
|
||||||
|
`POST /webhooks/github` (`packages/backend/convex/githubWebhooks.ts`,
|
||||||
|
`http.ts`). Configure the GitHub App webhook URL as
|
||||||
|
`<CONVEX_SITE_URL>/webhooks/github` and set `GITHUB_APP_WEBHOOK_SECRET`; the
|
||||||
|
endpoint fails closed (HTTP 503) when no secret is configured and rejects any
|
||||||
|
payload whose `x-hub-signature-256` does not verify.
|
||||||
|
|
||||||
|
- `push` → a targeted drift refresh for every Spoon tracking that repository.
|
||||||
|
- `installation` / `installation_repositories` → the matching connection's
|
||||||
|
status flips to `revoked` (deleted/suspended), `active` (created/unsuspended),
|
||||||
|
or otherwise `needs_reauth`, surfaced in `Settings -> Integrations`.
|
||||||
|
|
||||||
|
The hourly `refreshDueSpoons` cron remains a fallback, so drift still refreshes
|
||||||
|
even if a webhook is missed.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Notifications</strong></summary>
|
||||||
|
|
||||||
|
Spoon delivers an in-app bell plus preference-gated transactional email
|
||||||
|
(`packages/backend/convex/notifications.ts`). Five kinds are emitted:
|
||||||
|
`maintenance_thread`, `agent_turn_finished`, `agent_needs_input`, `sync_failed`,
|
||||||
|
and `connection_needs_reauth`.
|
||||||
|
|
||||||
|
- Every event inserts an in-app notification (bell badge via `unreadCount`,
|
||||||
|
list via `listMine`, `markRead` / `markAllRead`).
|
||||||
|
- Email is sent through UseSend (`USESEND_*`) only when the user has an email
|
||||||
|
and the matching per-kind preference is not disabled. Unset preferences
|
||||||
|
default to enabled.
|
||||||
|
- Per-kind email toggles live in `Settings -> Notifications`. Web push is out of
|
||||||
|
scope.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -285,7 +396,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
|
|||||||
<summary><strong>Core tables</strong></summary>
|
<summary><strong>Core tables</strong></summary>
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
| ------------------------ | --------------------------------------------------------- |
|
| ------------------------- | ----------------------------------------------------------- |
|
||||||
| `spoons` | Managed fork records |
|
| `spoons` | Managed fork records |
|
||||||
| `threads` | Durable maintenance and work conversations |
|
| `threads` | Durable maintenance and work conversations |
|
||||||
| `threadMessages` | Messages inside threads |
|
| `threadMessages` | Messages inside threads |
|
||||||
@@ -297,11 +408,15 @@ Treat that saved auth file like a password and only use it on trusted workers.
|
|||||||
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
||||||
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
||||||
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
||||||
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
|
| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
|
||||||
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
||||||
| `agentJobEvents` | Append-only worker event log |
|
| `agentJobEvents` | Append-only worker event log |
|
||||||
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
||||||
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
|
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
|
||||||
|
| `userDotfiles` | Encrypted per-user dotfiles overlay + repo/setup config |
|
||||||
|
| `userEnvironment` | Per-user box/home environment config |
|
||||||
|
| `notifications` | In-app notification rows (bell) |
|
||||||
|
| `notificationPreferences` | Per-user email notification toggles |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -309,7 +424,7 @@ Treat that saved auth file like a password and only use it on trusted workers.
|
|||||||
<summary><strong>Important routes</strong></summary>
|
<summary><strong>Important routes</strong></summary>
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --------------------------------- | --------------------------------------- |
|
| --------------------------------- | ----------------------------------------------- |
|
||||||
| `/` | Public product landing page |
|
| `/` | Public product landing page |
|
||||||
| `/dashboard` | Maintenance overview |
|
| `/dashboard` | Maintenance overview |
|
||||||
| `/spoons` | Managed fork list |
|
| `/spoons` | Managed fork list |
|
||||||
@@ -318,11 +433,16 @@ Treat that saved auth file like a password and only use it on trusted workers.
|
|||||||
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
||||||
| `/threads` | Global thread queue |
|
| `/threads` | Global thread queue |
|
||||||
| `/threads/[threadId]` | Thread detail |
|
| `/threads/[threadId]` | Thread detail |
|
||||||
|
| `/machine` | My Machine: box status, terminal, home |
|
||||||
| `/settings/profile` | User profile settings |
|
| `/settings/profile` | User profile settings |
|
||||||
| `/settings/integrations` | GitHub and service integration settings |
|
| `/settings/integrations` | GitHub and service integration settings |
|
||||||
| `/settings/ai-providers` | AI/OpenCode provider profiles |
|
| `/settings/ai-providers` | AI provider profiles (Codex/OpenCode/Anthropic) |
|
||||||
|
| `/settings/dotfiles` | Per-user dotfiles overlay + repo |
|
||||||
|
| `/settings/notifications` | Email notification preferences |
|
||||||
|
|
||||||
Legacy `/updates` and `/agents` routes redirect into `/threads`.
|
The Convex `httpAction` `POST /webhooks/github` (mounted at
|
||||||
|
`<CONVEX_SITE_URL>/webhooks/github`) handles GitHub App webhooks. Legacy
|
||||||
|
`/updates` and `/agents` routes redirect into `/threads`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -424,9 +544,10 @@ not call Infisical.
|
|||||||
<summary><strong>Public Next variables</strong></summary>
|
<summary><strong>Public Next variables</strong></summary>
|
||||||
|
|
||||||
| Variable | Used for |
|
| Variable | Used for |
|
||||||
| --------------------------------- | ------------------------------------------- |
|
| --------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
|
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
|
||||||
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
|
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
|
||||||
|
| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
|
||||||
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
|
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
|
||||||
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
||||||
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
||||||
@@ -487,15 +608,16 @@ not call Infisical.
|
|||||||
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
||||||
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
||||||
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||||
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
| `SPOON_AGENT_TERMINAL_SECRET` | HMAC secret for terminal/box tokens (falls back to the internal/worker token) |
|
||||||
|
| `SPOON_AGENT_JOB_IMAGE` | Per-user box (Fedora) image the worker `exec`s into |
|
||||||
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||||
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||||
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
| `SPOON_AGENT_BOX_IDLE_MS` | Idle time before a per-user box is reaped (default `1800000`) |
|
||||||
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||||
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Agent turn timeout |
|
||||||
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
| `SPOON_AGENT_WORKDIR` | Worker work directory; per-user homes live under `homes/{username}` |
|
||||||
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
|
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
|
||||||
| `SPOON_AGENT_NETWORK` | Optional job container network |
|
| `SPOON_AGENT_NETWORK` | Optional box container network |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -523,12 +645,20 @@ not call Infisical.
|
|||||||
- GitHub drift refresh, commit cache, PR cache, and sync-run history
|
- GitHub drift refresh, commit cache, PR cache, and sync-run history
|
||||||
- Effective drift and ignored upstream change records
|
- Effective drift and ignored upstream change records
|
||||||
- Global Threads page and Spoon-scoped Threads tab
|
- Global Threads page and Spoon-scoped Threads tab
|
||||||
- OpenCode/Codex-oriented agent worker and browser workspace foundation
|
- Persistent per-user box the worker `exec`s into for agent turns, terminal, and
|
||||||
|
commands
|
||||||
|
- Three agent runtimes (Codex, OpenCode, Claude Code) behind one adapter, with
|
||||||
|
queue-time runtime validation
|
||||||
|
- My Machine surface (box status/start/stop/restart, terminal, home browser)
|
||||||
|
- Interactive workspace terminal (xterm.js → box PTY)
|
||||||
|
- Per-user dotfiles overlay + optional dotfiles repo
|
||||||
|
- Signature-verified GitHub webhook with hourly cron fallback
|
||||||
|
- In-app + email notifications with per-user preferences
|
||||||
- Monaco editor with optional Vim mode
|
- Monaco editor with optional Vim mode
|
||||||
- Diff viewer, command panel, worker logs, and artifacts
|
- Diff viewer, command panel, worker logs, and artifacts
|
||||||
- Encrypted Spoon secrets and bulk `.env` import
|
- Encrypted Spoon secrets and bulk `.env` import
|
||||||
- Encrypted AI provider profiles, including Codex auth JSON and API-key
|
- Encrypted AI provider profiles, including Codex/Anthropic auth JSON and
|
||||||
provider support
|
API-key provider support
|
||||||
- Authentik, GitHub, and password auth through Convex Auth
|
- Authentik, GitHub, and password auth through Convex Auth
|
||||||
- Self-hosted Convex/Postgres deployment model
|
- Self-hosted Convex/Postgres deployment model
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
|
|||||||
return events;
|
return events;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Claude Code `--output-format stream-json` emits one JSON object per line:
|
||||||
|
// `system:init` announces the session id, `assistant` messages carry text and
|
||||||
|
// tool_use blocks, `user` messages carry tool_result blocks, and `result`
|
||||||
|
// terminates the turn (success with final text, or an `error_*` subtype).
|
||||||
|
export const normalizeClaudeJsonLine = (
|
||||||
|
line: string,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
if (!line.trim()) return [];
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'status', status: line }];
|
||||||
|
}
|
||||||
|
const event = asRecord(parsed);
|
||||||
|
if (!event) return [{ kind: 'status', status: line }];
|
||||||
|
const type = stringify(event.type);
|
||||||
|
const subtype = stringify(event.subtype);
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
|
||||||
|
if (type === 'system') {
|
||||||
|
const sessionId = stringify(event.session_id);
|
||||||
|
if (subtype === 'init' && sessionId) {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: subtype || 'system' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'assistant') {
|
||||||
|
const message = asRecord(event.message);
|
||||||
|
const content = message?.content;
|
||||||
|
let text = '';
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
const record = asRecord(block);
|
||||||
|
if (!record) continue;
|
||||||
|
const blockType = stringify(record.type);
|
||||||
|
if (blockType === 'text') {
|
||||||
|
text += textFromPart(record);
|
||||||
|
} else if (blockType === 'tool_use') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: stringify(record.name ?? 'tool'),
|
||||||
|
input: JSON.stringify(record.input ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) events.unshift({ kind: 'assistant_delta', content: text });
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: 'assistant' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'content_block_delta') {
|
||||||
|
const delta = asRecord(event.delta);
|
||||||
|
const text = delta ? textFromPart(delta) : '';
|
||||||
|
if (text) events.push({ kind: 'assistant_delta', content: text });
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: type });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'user') {
|
||||||
|
const message = asRecord(event.message);
|
||||||
|
const content = message?.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
const record = asRecord(block);
|
||||||
|
if (!record || stringify(record.type) !== 'tool_result') continue;
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'tool',
|
||||||
|
output: toolOutputFromRecord(record),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: 'user' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'result') {
|
||||||
|
const sessionId = stringify(event.session_id);
|
||||||
|
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||||
|
if (subtype === 'success') {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_completed',
|
||||||
|
content: stringify(event.result),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(event.result ?? event.error ?? subtype),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: 'status', status: type || line }];
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeOpenCodeEvent = (
|
export const normalizeOpenCodeEvent = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
): NormalizedAgentEvent[] => {
|
): NormalizedAgentEvent[] => {
|
||||||
@@ -464,3 +572,56 @@ export const normalizeOpenCodeEvent = (
|
|||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `opencode run --format json` emits newline-delimited JSON: streamed events share
|
||||||
|
// the server's `{ type, properties }` SSE envelope, plus a final result object that
|
||||||
|
// carries the session id and the assistant's completed text.
|
||||||
|
export const normalizeOpenCodeRunLine = (
|
||||||
|
line: string,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
if (!line.trim()) return [];
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'status', status: line }];
|
||||||
|
}
|
||||||
|
const record = asRecord(parsed);
|
||||||
|
if (!record) return [{ kind: 'status', status: line }];
|
||||||
|
if ('type' in record && 'properties' in record) {
|
||||||
|
return normalizeOpenCodeEvent(record);
|
||||||
|
}
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const sessionId = record.sessionID ?? record.sessionId;
|
||||||
|
if (typeof sessionId === 'string') {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
const parts = record.parts;
|
||||||
|
let text = '';
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
text = parts
|
||||||
|
.map((part) => {
|
||||||
|
const record = asRecord(part);
|
||||||
|
return record ? textFromPart(record) : '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
const message = asRecord(record.message);
|
||||||
|
text =
|
||||||
|
textFromPart(record) ||
|
||||||
|
(message ? stringify(message.content ?? message.text) : '');
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
const externalMessageId = stringify(record.messageID ?? record.id);
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_completed',
|
||||||
|
content: text,
|
||||||
|
...(externalMessageId ? { externalMessageId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: line });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { assertContainedRealPath } from './path-containment';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
inspectUserBoxStatus,
|
||||||
|
stopWorkspaceContainer,
|
||||||
|
userContainerName,
|
||||||
|
} from './runtime/docker';
|
||||||
|
import { resetBox } from './user-container';
|
||||||
|
|
||||||
|
// Matches FileTreeNode in apps/next/src/components/agent-workspace/types.ts.
|
||||||
|
type FileTreeNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
children?: FileTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-user box home layout, shared by the HTTP routes and the terminal WS bridge
|
||||||
|
// so path derivation lives in exactly one place (must match worker.ts).
|
||||||
|
export const boxHomePaths = (username: string) => ({
|
||||||
|
homeDir: path.resolve(env.workdir, 'homes', username),
|
||||||
|
containerHome: path.posix.join('/home', username),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BoxStatus = {
|
||||||
|
running: boolean;
|
||||||
|
image: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
memoryLimitBytes: number | null;
|
||||||
|
containerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
|
||||||
|
const status = await inspectUserBoxStatus(username);
|
||||||
|
return { ...status, containerName: userContainerName(username) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startBox = async (username: string): Promise<void> => {
|
||||||
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
|
await ensureUserContainer({ username, workdir: homeDir, containerHome });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopBox = async (username: string): Promise<void> => {
|
||||||
|
await stopWorkspaceContainer(userContainerName(username));
|
||||||
|
resetBox(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restartBox = async (username: string): Promise<void> => {
|
||||||
|
await stopBox(username);
|
||||||
|
await startBox(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Directories never surfaced in the box file tree — VCS internals, package
|
||||||
|
// caches and build output that would bloat (or hang) the walk.
|
||||||
|
const BOX_TREE_IGNORE = new Set([
|
||||||
|
'.git',
|
||||||
|
'node_modules',
|
||||||
|
'.cache',
|
||||||
|
'.npm',
|
||||||
|
'.bun',
|
||||||
|
'.cargo',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.next',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Home-relative paths (POSIX) that are always skipped regardless of depth.
|
||||||
|
const BOX_TREE_IGNORE_PATHS = new Set(['.local/share/Trash']);
|
||||||
|
|
||||||
|
// Guard against a huge home hanging the request; stop descending past this.
|
||||||
|
const BOX_TREE_MAX_NODES = 5000;
|
||||||
|
|
||||||
|
// Realpath-checked absolute path inside the user's home. Rejects lexical `..`
|
||||||
|
// escapes and symlinks that resolve outside home (reuses the Phase-1
|
||||||
|
// containment helper); for writes, also rejects a symlinked final target.
|
||||||
|
const safeHomePath = (
|
||||||
|
homeDir: string,
|
||||||
|
relPath: string,
|
||||||
|
opts: { forWrite?: boolean } = {},
|
||||||
|
): Promise<string> =>
|
||||||
|
assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' });
|
||||||
|
|
||||||
|
export const listBoxTree = async (username: string): Promise<FileTreeNode> => {
|
||||||
|
const { homeDir } = boxHomePaths(username);
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const buildChildren = async (
|
||||||
|
absoluteDir: string,
|
||||||
|
relativeDir: string,
|
||||||
|
): Promise<FileTreeNode[]> => {
|
||||||
|
const entries = await readdir(absoluteDir);
|
||||||
|
const nodes = await Promise.all(
|
||||||
|
entries
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map(async (entry): Promise<FileTreeNode | null> => {
|
||||||
|
const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
|
||||||
|
if (
|
||||||
|
BOX_TREE_IGNORE.has(entry) ||
|
||||||
|
BOX_TREE_IGNORE_PATHS.has(relativePath)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (count >= BOX_TREE_MAX_NODES) return null;
|
||||||
|
count += 1;
|
||||||
|
const absolutePath = path.join(absoluteDir, entry);
|
||||||
|
const stats = await stat(absolutePath).catch(() => null);
|
||||||
|
if (!stats) return null;
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
return {
|
||||||
|
name: entry,
|
||||||
|
path: relativePath,
|
||||||
|
type: 'directory',
|
||||||
|
children: await buildChildren(absolutePath, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: entry, path: relativePath, type: 'file' };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return nodes.filter((node): node is FileTreeNode => Boolean(node));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: '~',
|
||||||
|
path: '',
|
||||||
|
type: 'directory',
|
||||||
|
children: await buildChildren(homeDir, ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readBoxFile = async (
|
||||||
|
username: string,
|
||||||
|
relPath: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const { homeDir } = boxHomePaths(username);
|
||||||
|
const target = await safeHomePath(homeDir, relPath);
|
||||||
|
return await readFile(target, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeBoxFile = async (
|
||||||
|
username: string,
|
||||||
|
relPath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<{ success: true }> => {
|
||||||
|
const { homeDir } = boxHomePaths(username);
|
||||||
|
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, content);
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
type CommandResult = { exitCode: number; output: string };
|
||||||
|
|
||||||
|
const launchScript = `
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
pid_file, job_id, *command = sys.argv[1:]
|
||||||
|
if os.getpgrp() != os.getpid():
|
||||||
|
os.setsid()
|
||||||
|
os.environ["SPOON_CODEX_JOB_ID"] = job_id
|
||||||
|
with open(pid_file, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(str(os.getpid()))
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.execvpe(command[0], command, os.environ)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const abortScript = `
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
pid_file, job_id = sys.argv[1:]
|
||||||
|
pid = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
with open(pid_file, encoding="utf-8") as handle:
|
||||||
|
pid = int(handle.read().strip())
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
if pid is None:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/environ", "rb") as handle:
|
||||||
|
environment = handle.read().split(b"\\0")
|
||||||
|
marker = f"SPOON_CODEX_JOB_ID={job_id}".encode()
|
||||||
|
if marker not in environment or os.getpgid(pid) != pid:
|
||||||
|
raise RuntimeError("Codex process identity did not match the requested job")
|
||||||
|
os.killpg(pid, signal.SIGTERM)
|
||||||
|
for _ in range(20):
|
||||||
|
time.sleep(0.1)
|
||||||
|
try:
|
||||||
|
os.killpg(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
os.killpg(pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(pid_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const managedCodexCommand = (args: {
|
||||||
|
jobId: string;
|
||||||
|
pidFile: string;
|
||||||
|
command: string[];
|
||||||
|
}) => [
|
||||||
|
'python3',
|
||||||
|
'-c',
|
||||||
|
launchScript,
|
||||||
|
args.pidFile,
|
||||||
|
args.jobId,
|
||||||
|
...args.command,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const abortManagedCodexProcess = async (args: {
|
||||||
|
jobId: string;
|
||||||
|
pidFile: string;
|
||||||
|
execute: (command: string[]) => Promise<CommandResult>;
|
||||||
|
}) => {
|
||||||
|
const result = await args.execute([
|
||||||
|
'python3',
|
||||||
|
'-c',
|
||||||
|
abortScript,
|
||||||
|
args.pidFile,
|
||||||
|
args.jobId,
|
||||||
|
]);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to abort Codex process: ${result.output}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,25 +27,14 @@ export const env = {
|
|||||||
'docker',
|
'docker',
|
||||||
containerVolumeOptions:
|
containerVolumeOptions:
|
||||||
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||||
containerAccess:
|
|
||||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
|
||||||
? 'host_port'
|
|
||||||
: 'network',
|
|
||||||
jobImage:
|
jobImage:
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||||
// Interactive terminal: image for the persistent shell container (defaults to
|
// Secret shared with the Next app for verifying interactive terminal tokens.
|
||||||
// the job image), the secret shared with the Next app for verifying terminal
|
|
||||||
// tokens, and how long an idle terminal container survives before cleanup.
|
|
||||||
terminalImage:
|
|
||||||
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
|
||||||
'spoon-agent-job:latest',
|
|
||||||
terminalSecret:
|
terminalSecret:
|
||||||
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
'',
|
'',
|
||||||
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
|
||||||
// How long a per-user box container survives with no active jobs/terminals.
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||||
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||||
@@ -60,7 +49,6 @@ export const env = {
|
|||||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
'',
|
'',
|
||||||
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
|
||||||
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||||
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
|
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const createHeartbeatController = (args: {
|
||||||
|
intervalMs: number;
|
||||||
|
heartbeat: (jobId: string) => Promise<{ cancelRequested: boolean }>;
|
||||||
|
onCancel: (jobId: string) => Promise<void>;
|
||||||
|
onError: (error: unknown) => void;
|
||||||
|
}) => {
|
||||||
|
const timers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
const stop = (jobId: string) => {
|
||||||
|
const timer = timers.get(jobId);
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timers.delete(jobId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async (jobId: string) => {
|
||||||
|
if (inFlight.has(jobId) || !timers.has(jobId)) return;
|
||||||
|
inFlight.add(jobId);
|
||||||
|
try {
|
||||||
|
const result = await args.heartbeat(jobId);
|
||||||
|
if (result.cancelRequested) {
|
||||||
|
stop(jobId);
|
||||||
|
await args.onCancel(jobId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
args.onError(error);
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(jobId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: (jobId: string) => {
|
||||||
|
stop(jobId);
|
||||||
|
const timer = setInterval(() => void tick(jobId), args.intervalMs);
|
||||||
|
timer.unref();
|
||||||
|
timers.set(jobId, timer);
|
||||||
|
},
|
||||||
|
stop,
|
||||||
|
has: (jobId: string) => timers.has(jobId),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { startWorkerServer } from './server';
|
import { startWorkerServer } from './server';
|
||||||
|
import { reconcileExistingBoxes } from './user-container';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||||
@@ -26,4 +27,5 @@ if (env.devWatchdog) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startWorkerServer();
|
startWorkerServer();
|
||||||
|
await reconcileExistingBoxes();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { assertContainedRealPath } from './path-containment';
|
||||||
|
|
||||||
|
export type MaterializedSecret = { name: string; value: string };
|
||||||
|
|
||||||
|
// Format matches the historical `NAME="value"` env-file layout the agent CLIs
|
||||||
|
// source; values are JSON-quoted so newlines/quotes survive.
|
||||||
|
export const formatMaterializedEnv = (secrets: MaterializedSecret[]) =>
|
||||||
|
`${secrets
|
||||||
|
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
||||||
|
.join('\n')}\n`;
|
||||||
|
|
||||||
|
// Resolves the contained env-file path inside the checkout and writes it 0600
|
||||||
|
// so plaintext secrets never sit world/group-readable in the persistent home.
|
||||||
|
export const writeMaterializedEnv = async (
|
||||||
|
repoDir: string,
|
||||||
|
envFilePath: string,
|
||||||
|
secrets: MaterializedSecret[],
|
||||||
|
): Promise<string> => {
|
||||||
|
const envPath = await assertContainedRealPath(repoDir, envFilePath, {
|
||||||
|
forWrite: true,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(envPath), { recursive: true });
|
||||||
|
await writeFile(envPath, formatMaterializedEnv(secrets), { mode: 0o600 });
|
||||||
|
// writeFile's mode only applies on create; chmod unconditionally tightens an
|
||||||
|
// already-existing (possibly 0644) file so plaintext secrets never leak.
|
||||||
|
await chmod(envPath, 0o600);
|
||||||
|
return envPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Removes a previously materialized env file; no-ops when the path is unset or
|
||||||
|
// already gone so it is safe on every teardown path.
|
||||||
|
export const removeMaterializedEnv = async (envPath: string | undefined) => {
|
||||||
|
if (!envPath) return;
|
||||||
|
await rm(envPath, { force: true });
|
||||||
|
};
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { OpencodeClient } from '@opencode-ai/sdk';
|
|
||||||
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from './agent-events';
|
|
||||||
import { normalizeOpenCodeEvent } from './agent-events';
|
|
||||||
|
|
||||||
export type OpenCodeSession = {
|
|
||||||
client: OpencodeClient;
|
|
||||||
sessionId: string;
|
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const basicAuth = (username: string, password: string) =>
|
|
||||||
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
||||||
|
|
||||||
const modelParts = (model: string) => {
|
|
||||||
const [rawProviderId, ...rest] = model.split('/');
|
|
||||||
const providerID =
|
|
||||||
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
|
|
||||||
const modelID = rest.length > 0 ? rest.join('/') : model;
|
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createOpenCodeSession = async (args: {
|
|
||||||
baseUrl: string;
|
|
||||||
password: string;
|
|
||||||
directory: string;
|
|
||||||
title: string;
|
|
||||||
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
|
|
||||||
}) => {
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const client = createOpencodeClient({
|
|
||||||
baseUrl: args.baseUrl,
|
|
||||||
directory: args.directory,
|
|
||||||
headers: {
|
|
||||||
authorization: basicAuth('opencode', args.password),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const created = await client.session.create({
|
|
||||||
query: { directory: args.directory },
|
|
||||||
body: { title: args.title },
|
|
||||||
});
|
|
||||||
if (!created.data) {
|
|
||||||
throw new Error('OpenCode session could not be created.');
|
|
||||||
}
|
|
||||||
const sessionId = created.data.id;
|
|
||||||
void (async () => {
|
|
||||||
const events = await client.event.subscribe({
|
|
||||||
signal: abortController.signal,
|
|
||||||
query: { directory: args.directory },
|
|
||||||
onSseEvent: (event) => {
|
|
||||||
for (const normalized of normalizeOpenCodeEvent(event.data)) {
|
|
||||||
void args.onEvent(normalized);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSseError: (error) => {
|
|
||||||
void args.onEvent({
|
|
||||||
kind: 'error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for await (const event of events.stream) {
|
|
||||||
for (const normalized of normalizeOpenCodeEvent(event)) {
|
|
||||||
await args.onEvent(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
if (!abortController.signal.aborted) {
|
|
||||||
void args.onEvent({
|
|
||||||
kind: 'error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
close: () => abortController.abort(),
|
|
||||||
} satisfies OpenCodeSession;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const promptOpenCodeSession = async (args: {
|
|
||||||
session: OpenCodeSession;
|
|
||||||
prompt: string;
|
|
||||||
model: string;
|
|
||||||
directory: string;
|
|
||||||
}) => {
|
|
||||||
const model = modelParts(args.model);
|
|
||||||
const result = await args.session.client.session.promptAsync({
|
|
||||||
path: { id: args.session.sessionId },
|
|
||||||
query: { directory: args.directory },
|
|
||||||
body: {
|
|
||||||
model,
|
|
||||||
parts: [{ type: 'text', text: args.prompt }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error('OpenCode prompt was rejected.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
|
|
||||||
await session.client.session.abort({
|
|
||||||
path: { id: session.sessionId },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const replyOpenCodePermission = async (args: {
|
|
||||||
session: OpenCodeSession;
|
|
||||||
permissionId: string;
|
|
||||||
response: 'once' | 'always' | 'reject';
|
|
||||||
directory: string;
|
|
||||||
}) => {
|
|
||||||
const result = await args.session.client.postSessionIdPermissionsPermissionId(
|
|
||||||
{
|
|
||||||
path: { id: args.session.sessionId, permissionID: args.permissionId },
|
|
||||||
query: { directory: args.directory },
|
|
||||||
body: { response: args.response },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error('OpenCode permission response was rejected.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { lstat, realpath } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const realpathOrDeepestExisting = async (target: string): Promise<string> => {
|
||||||
|
let current = target;
|
||||||
|
const tail: string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const real = await realpath(current);
|
||||||
|
return path.join(real, ...tail.reverse());
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code !== 'ENOENT') throw error;
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) throw error;
|
||||||
|
tail.push(path.basename(current));
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertContainedRealPath = async (
|
||||||
|
root: string,
|
||||||
|
requested: string,
|
||||||
|
opts: { forWrite?: boolean; label?: string } = {},
|
||||||
|
): Promise<string> => {
|
||||||
|
const lexical = path.resolve(root, requested);
|
||||||
|
const realRoot = await realpath(root);
|
||||||
|
const resolved = await realpathOrDeepestExisting(lexical);
|
||||||
|
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to access path outside ${opts.label ?? 'root'}: ${requested}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.forWrite) {
|
||||||
|
const info = await lstat(lexical).catch(() => null);
|
||||||
|
if (info?.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write through a symlink: ${requested}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
const secretPatterns = [
|
const secretPatterns: { pattern: RegExp; replacement: string }[] = [
|
||||||
/ghs_[A-Za-z0-9_]+/g,
|
{ pattern: /ghs_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
|
||||||
/github_pat_[A-Za-z0-9_]+/g,
|
{ pattern: /github_pat_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
|
||||||
/sk-[A-Za-z0-9_-]+/g,
|
{ pattern: /sk-[A-Za-z0-9_-]+/g, replacement: '[redacted]' },
|
||||||
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
|
{
|
||||||
|
pattern: /(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
|
||||||
|
replacement: '$1=[redacted]',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const createRedactor = (values: string[]) => {
|
export const createRedactor = (values: string[]) => {
|
||||||
@@ -12,8 +15,8 @@ export const createRedactor = (values: string[]) => {
|
|||||||
for (const secret of secrets) {
|
for (const secret of secrets) {
|
||||||
output = output.split(secret).join('[redacted]');
|
output = output.split(secret).join('[redacted]');
|
||||||
}
|
}
|
||||||
for (const pattern of secretPatterns) {
|
for (const { pattern, replacement } of secretPatterns) {
|
||||||
output = output.replace(pattern, '$1=[redacted]');
|
output = output.replace(pattern, replacement);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { streamExecInContainer } from './docker';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
|
|
||||||
|
export type AgentRuntimeName = 'codex' | 'opencode' | 'claude';
|
||||||
|
|
||||||
|
export type ExecStreamFn = typeof streamExecInContainer;
|
||||||
|
|
||||||
|
export type TurnResult = {
|
||||||
|
// Assistant text the adapter captured out-of-band (Codex --output-last-message
|
||||||
|
// file, Claude `result` event). Empty/undefined when everything streamed via onEvent.
|
||||||
|
finalMessage?: string;
|
||||||
|
// Runtime session id to persist for continuity (resume/--session/--resume).
|
||||||
|
sessionId?: string;
|
||||||
|
// Non-empty when the runtime reported a hard failure the caller must surface.
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentRuntime = {
|
||||||
|
readonly name: AgentRuntimeName;
|
||||||
|
prepareAuth(workspace: AdapterWorkspace): Promise<void>;
|
||||||
|
runTurn(
|
||||||
|
workspace: AdapterWorkspace,
|
||||||
|
prompt: string,
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||||
|
): Promise<TurnResult>;
|
||||||
|
abort(workspace: AdapterWorkspace): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdapterFactory = (deps?: {
|
||||||
|
execStream?: ExecStreamFn;
|
||||||
|
}) => AgentRuntime;
|
||||||
|
|
||||||
|
const factories = new Map<AgentRuntimeName, AdapterFactory>();
|
||||||
|
|
||||||
|
export const registerAdapter = (
|
||||||
|
name: AgentRuntimeName,
|
||||||
|
factory: AdapterFactory,
|
||||||
|
): void => {
|
||||||
|
factories.set(name, factory);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdapter = (name: AgentRuntimeName): AgentRuntime => {
|
||||||
|
const factory = factories.get(name);
|
||||||
|
if (!factory) {
|
||||||
|
throw new Error(`No agent runtime adapter registered for "${name}".`);
|
||||||
|
}
|
||||||
|
return factory();
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||||
|
// permissions. Shared by the credential-writing adapters (Claude, and available
|
||||||
|
// to Codex/OpenCode). `label` names the source in the parse-error message.
|
||||||
|
export const writeJsonFile = async (
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
label = 'auth',
|
||||||
|
) => {
|
||||||
|
let normalized = content.trim();
|
||||||
|
try {
|
||||||
|
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} JSON is not valid JSON.`);
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import { normalizeClaudeJsonLine } from '../agent-events';
|
||||||
|
import { env } from '../env';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import { writeJsonFile } from './auth';
|
||||||
|
import {
|
||||||
|
buildMarkedCommand,
|
||||||
|
killBoxProcessesByMarker,
|
||||||
|
streamExecInContainer,
|
||||||
|
} from './docker';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
|
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
||||||
|
|
||||||
|
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
||||||
|
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||||
|
|
||||||
|
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||||
|
// API-key profiles authenticate purely via ANTHROPIC_API_KEY in the
|
||||||
|
// environment; only OAuth-json profiles need `.credentials.json` on disk.
|
||||||
|
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
||||||
|
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('Claude auth profile is missing credentials.json contents.');
|
||||||
|
}
|
||||||
|
const credentialsPath = path.join(
|
||||||
|
workspace.homeDir,
|
||||||
|
'.claude',
|
||||||
|
'.credentials.json',
|
||||||
|
);
|
||||||
|
await writeJsonFile(credentialsPath, secret, 'Claude auth');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTurn = async (
|
||||||
|
workspace: AdapterWorkspace,
|
||||||
|
prompt: string,
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||||
|
): Promise<TurnResult> => {
|
||||||
|
workspace.runtime = 'claude';
|
||||||
|
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||||
|
const marker = workspace.turnMarker;
|
||||||
|
|
||||||
|
// Flags verified against `claude --help` (@anthropic-ai/claude-code@2.1.207):
|
||||||
|
// `-p`, `--output-format stream-json`, `--verbose`, `--dangerously-skip-permissions`,
|
||||||
|
// `--model`, and `--resume <sessionId>` (continuity) all exist.
|
||||||
|
const argv = [
|
||||||
|
'claude',
|
||||||
|
'-p',
|
||||||
|
prompt,
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
'--verbose',
|
||||||
|
'--dangerously-skip-permissions',
|
||||||
|
'--model',
|
||||||
|
claudeModel(workspace.claim),
|
||||||
|
...(workspace.claudeSessionId
|
||||||
|
? ['--resume', workspace.claudeSessionId]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const command = buildMarkedCommand(marker, argv);
|
||||||
|
|
||||||
|
const aiEnv = claudeEnv(workspace.claim, workspace.containerHome);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let capturedSessionId: string | undefined;
|
||||||
|
let capturedError: string | undefined;
|
||||||
|
let finalMessage: string | undefined;
|
||||||
|
|
||||||
|
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||||
|
// in-box process group. Schedule an explicit marker kill on timeout and clear
|
||||||
|
// it once the exec resolves.
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
void killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker,
|
||||||
|
});
|
||||||
|
}, env.jobTimeoutMs);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await execStream({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
|
command,
|
||||||
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
redact: workspace.redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
onStdoutLine: async (line) => {
|
||||||
|
for (const event of normalizeClaudeJsonLine(line)) {
|
||||||
|
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||||
|
if (event.kind === 'error') capturedError = event.message;
|
||||||
|
if (event.kind === 'assistant_completed' && event.content) {
|
||||||
|
finalMessage = event.content;
|
||||||
|
}
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: async (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
await onEvent({ kind: 'status', status: line });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
capturedError = result.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = async (workspace: AdapterWorkspace) => {
|
||||||
|
if (!workspace.turnMarker) return;
|
||||||
|
await killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker: workspace.turnMarker,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name: 'claude', prepareAuth, runTurn, abort };
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import { normalizeCodexJsonLine } from '../agent-events';
|
||||||
|
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
||||||
|
import { env } from '../env';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import {
|
||||||
|
buildMarkedCommand,
|
||||||
|
killBoxProcessesByMarker,
|
||||||
|
streamExecInContainer,
|
||||||
|
} from './docker';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
|
import {
|
||||||
|
codexModelArgs,
|
||||||
|
isCodexLoginProfile,
|
||||||
|
providerEnvironment,
|
||||||
|
} from './provider';
|
||||||
|
|
||||||
|
// Reused verbatim from the worker: normalize + pretty-print an auth JSON blob
|
||||||
|
// before writing it with owner-only permissions.
|
||||||
|
const writeJsonFile = async (filePath: string, content: string) => {
|
||||||
|
let normalized = content.trim();
|
||||||
|
try {
|
||||||
|
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Codex auth JSON is not valid JSON.');
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNoise = (line: string) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return (
|
||||||
|
!trimmed ||
|
||||||
|
trimmed === 'Reading additional input from stdin...' ||
|
||||||
|
trimmed.includes('`[features].codex_hooks` is deprecated')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCodexAdapter: AdapterFactory = (deps) => {
|
||||||
|
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||||
|
|
||||||
|
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||||
|
// API-key profiles (e.g. OpenAI api_key) authenticate purely via
|
||||||
|
// OPENAI_API_KEY from providerEnvironment; only ChatGPT-login/JSON-snapshot
|
||||||
|
// profiles need a Codex `auth.json` on disk. Mirror the Claude adapter,
|
||||||
|
// which early-returns for non-OAuth profiles.
|
||||||
|
if (!isCodexLoginProfile(workspace.claim)) return;
|
||||||
|
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('Codex auth profile is missing auth.json contents.');
|
||||||
|
}
|
||||||
|
await prepareCodexWorkspaceFiles({
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
repoDir: workspace.repoDir,
|
||||||
|
});
|
||||||
|
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
|
||||||
|
await writeJsonFile(codexAuthPath, secret);
|
||||||
|
|
||||||
|
// Also seed OpenCode's auth location with the saved JSON for forward
|
||||||
|
// compatibility if this profile later runs through OpenCode directly.
|
||||||
|
const openCodeAuthPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.local',
|
||||||
|
'share',
|
||||||
|
'opencode',
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
await writeJsonFile(openCodeAuthPath, secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTurn = async (
|
||||||
|
workspace: AdapterWorkspace,
|
||||||
|
prompt: string,
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||||
|
): Promise<TurnResult> => {
|
||||||
|
workspace.runtime = 'codex';
|
||||||
|
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||||
|
const marker = workspace.turnMarker;
|
||||||
|
|
||||||
|
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
|
||||||
|
const outputFileHostPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.codex',
|
||||||
|
outputFileName,
|
||||||
|
);
|
||||||
|
const outputFileContainerPath = path.posix.join(
|
||||||
|
workspace.containerHome,
|
||||||
|
'.codex',
|
||||||
|
outputFileName,
|
||||||
|
);
|
||||||
|
const codexArgv = workspace.codexSessionId
|
||||||
|
? [
|
||||||
|
'codex',
|
||||||
|
'exec',
|
||||||
|
'resume',
|
||||||
|
'--json',
|
||||||
|
...codexModelArgs(workspace.claim),
|
||||||
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
|
'--output-last-message',
|
||||||
|
outputFileContainerPath,
|
||||||
|
workspace.codexSessionId,
|
||||||
|
prompt,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'codex',
|
||||||
|
'exec',
|
||||||
|
'--json',
|
||||||
|
...codexModelArgs(workspace.claim),
|
||||||
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
|
'--output-last-message',
|
||||||
|
outputFileContainerPath,
|
||||||
|
'--cd',
|
||||||
|
workspace.containerRepo,
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
const command = buildMarkedCommand(marker, codexArgv);
|
||||||
|
|
||||||
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let capturedSessionId: string | undefined;
|
||||||
|
let capturedError: string | undefined;
|
||||||
|
|
||||||
|
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||||
|
// in-box process group. Schedule an explicit marker kill on timeout and
|
||||||
|
// clear it once the exec resolves.
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
void killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker,
|
||||||
|
});
|
||||||
|
}, env.jobTimeoutMs);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await execStream({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
|
command,
|
||||||
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
redact: workspace.redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
onStdoutLine: async (line) => {
|
||||||
|
for (const event of normalizeCodexJsonLine(line)) {
|
||||||
|
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||||
|
if (event.kind === 'error') capturedError = event.message;
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: async (line) => {
|
||||||
|
if (isNoise(line)) return;
|
||||||
|
await onEvent({ kind: 'status', status: line });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
capturedError = result.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalMessage: string | undefined;
|
||||||
|
try {
|
||||||
|
const lastMessage = await readFile(outputFileHostPath, 'utf8');
|
||||||
|
if (lastMessage.trim()) finalMessage = lastMessage.trim();
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||||
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = async (workspace: AdapterWorkspace) => {
|
||||||
|
if (!workspace.turnMarker) return;
|
||||||
|
await killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker: workspace.turnMarker,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name: 'codex', prepareAuth, runTurn, abort };
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
|
import Docker from 'dockerode';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
@@ -10,6 +11,13 @@ type CommandResult = {
|
|||||||
output: string;
|
output: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let dockerClient: Docker | undefined;
|
||||||
|
|
||||||
|
export const getDockerClient = () => {
|
||||||
|
dockerClient ??= new Docker();
|
||||||
|
return dockerClient;
|
||||||
|
};
|
||||||
|
|
||||||
const environmentArgs = (environment: Record<string, string>) =>
|
const environmentArgs = (environment: Record<string, string>) =>
|
||||||
Object.entries(environment).flatMap(([name, value]) => [
|
Object.entries(environment).flatMap(([name, value]) => [
|
||||||
'-e',
|
'-e',
|
||||||
@@ -95,138 +103,6 @@ export const jobWorkspaceVolumeSpec = (
|
|||||||
: `${source}:${containerHome}`;
|
: `${source}:${containerHome}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
|
||||||
workdir: string;
|
|
||||||
containerHome?: string;
|
|
||||||
containerCwd?: string;
|
|
||||||
command: string[];
|
|
||||||
environment: Record<string, string>;
|
|
||||||
redact: (value: string) => string;
|
|
||||||
timeoutMs: number;
|
|
||||||
}): Promise<CommandResult> => {
|
|
||||||
await ensureJobImagePulled();
|
|
||||||
const result = await execa(
|
|
||||||
containerRuntime(),
|
|
||||||
[
|
|
||||||
'run',
|
|
||||||
'--rm',
|
|
||||||
'--memory',
|
|
||||||
'4g',
|
|
||||||
'--cpus',
|
|
||||||
'2',
|
|
||||||
...networkArgs(),
|
|
||||||
...environmentArgs(args.environment),
|
|
||||||
'-v',
|
|
||||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
|
||||||
'-w',
|
|
||||||
args.containerCwd ?? '/workspace/repo',
|
|
||||||
env.jobImage,
|
|
||||||
...args.command,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
all: true,
|
|
||||||
reject: false,
|
|
||||||
stdin: 'ignore',
|
|
||||||
timeout: args.timeoutMs,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return normalizeRunResult(result, result.all, args.redact);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startWorkspaceContainer = async (args: {
|
|
||||||
workdir: string;
|
|
||||||
containerHome?: string;
|
|
||||||
containerCwd?: string;
|
|
||||||
containerName: string;
|
|
||||||
environment: Record<string, string>;
|
|
||||||
command?: string[];
|
|
||||||
publishTcpPort?: number;
|
|
||||||
}) => {
|
|
||||||
await ensureJobImagePulled();
|
|
||||||
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
|
|
||||||
reject: false,
|
|
||||||
});
|
|
||||||
const result = await execa(
|
|
||||||
containerRuntime(),
|
|
||||||
[
|
|
||||||
'run',
|
|
||||||
'-d',
|
|
||||||
'--name',
|
|
||||||
args.containerName,
|
|
||||||
'--memory',
|
|
||||||
'4g',
|
|
||||||
'--cpus',
|
|
||||||
'2',
|
|
||||||
...networkArgs(),
|
|
||||||
...(args.publishTcpPort
|
|
||||||
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
|
|
||||||
: []),
|
|
||||||
...environmentArgs(args.environment),
|
|
||||||
'-v',
|
|
||||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
|
||||||
'-w',
|
|
||||||
args.containerCwd ?? '/workspace/repo',
|
|
||||||
env.jobImage,
|
|
||||||
...(args.command ?? ['sleep', 'infinity']),
|
|
||||||
],
|
|
||||||
{ all: true, stdin: 'ignore' },
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
containerId: result.stdout.trim(),
|
|
||||||
containerName: args.containerName,
|
|
||||||
hostPort: args.publishTcpPort
|
|
||||||
? await getPublishedPort(args.containerName, args.publishTcpPort)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPublishedPort = async (
|
|
||||||
containerName: string,
|
|
||||||
containerPort: number,
|
|
||||||
) => {
|
|
||||||
const result = await execa(
|
|
||||||
containerRuntime(),
|
|
||||||
['port', containerName, `${containerPort}/tcp`],
|
|
||||||
{ all: true, reject: false, stdin: 'ignore' },
|
|
||||||
);
|
|
||||||
const output = result.all.trim();
|
|
||||||
const match = /:(\d+)\s*$/.exec(output);
|
|
||||||
if (!match?.[1]) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not determine published port for ${containerName}:${containerPort}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return match[1];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const execInWorkspaceContainer = async (args: {
|
|
||||||
containerName: string;
|
|
||||||
command: string[];
|
|
||||||
environment?: Record<string, string>;
|
|
||||||
redact: (value: string) => string;
|
|
||||||
timeoutMs: number;
|
|
||||||
}): Promise<CommandResult> => {
|
|
||||||
const result = await execa(
|
|
||||||
containerRuntime(),
|
|
||||||
[
|
|
||||||
'exec',
|
|
||||||
...(args.environment ? environmentArgs(args.environment) : []),
|
|
||||||
args.containerName,
|
|
||||||
...args.command,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
all: true,
|
|
||||||
reject: false,
|
|
||||||
stdin: 'ignore',
|
|
||||||
timeout: args.timeoutMs,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
exitCode: result.exitCode ?? 0,
|
|
||||||
output: args.redact(result.all),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shared line-streaming + result normalization for a started subprocess
|
// Shared line-streaming + result normalization for a started subprocess
|
||||||
// (used by both `docker run` and `docker exec` paths).
|
// (used by both `docker run` and `docker exec` paths).
|
||||||
type StreamingSubprocess = {
|
type StreamingSubprocess = {
|
||||||
@@ -288,51 +164,6 @@ const streamSubprocess = async (
|
|||||||
return normalizeRunResult(result, output.join(''), redact);
|
return normalizeRunResult(result, output.join(''), redact);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const streamInJobContainer = async (args: {
|
|
||||||
workdir: string;
|
|
||||||
containerHome?: string;
|
|
||||||
containerCwd?: string;
|
|
||||||
command: string[];
|
|
||||||
environment: Record<string, string>;
|
|
||||||
redact: (value: string) => string;
|
|
||||||
timeoutMs: number;
|
|
||||||
onStdoutLine?: (line: string) => Promise<void>;
|
|
||||||
onStderrLine?: (line: string) => Promise<void>;
|
|
||||||
}): Promise<CommandResult> => {
|
|
||||||
await ensureJobImagePulled();
|
|
||||||
const subprocess = execa(
|
|
||||||
containerRuntime(),
|
|
||||||
[
|
|
||||||
'run',
|
|
||||||
'--rm',
|
|
||||||
'--memory',
|
|
||||||
'4g',
|
|
||||||
'--cpus',
|
|
||||||
'2',
|
|
||||||
...networkArgs(),
|
|
||||||
...environmentArgs(args.environment),
|
|
||||||
'-v',
|
|
||||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
|
||||||
'-w',
|
|
||||||
args.containerCwd ?? '/workspace/repo',
|
|
||||||
env.jobImage,
|
|
||||||
...args.command,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
all: true,
|
|
||||||
reject: false,
|
|
||||||
stdin: 'ignore',
|
|
||||||
timeout: args.timeoutMs,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return streamSubprocess(
|
|
||||||
subprocess,
|
|
||||||
args.redact,
|
|
||||||
args.onStdoutLine,
|
|
||||||
args.onStderrLine,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Per-user persistent "box" container that all of a user's threads exec into
|
// Per-user persistent "box" container that all of a user's threads exec into
|
||||||
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||||
export const userContainerName = (username: string) =>
|
export const userContainerName = (username: string) =>
|
||||||
@@ -362,6 +193,7 @@ export const ensureUserContainer = async (args: {
|
|||||||
[
|
[
|
||||||
'run',
|
'run',
|
||||||
'-d',
|
'-d',
|
||||||
|
'--init',
|
||||||
'--name',
|
'--name',
|
||||||
name,
|
name,
|
||||||
'--memory',
|
'--memory',
|
||||||
@@ -382,6 +214,127 @@ export const ensureUserContainer = async (args: {
|
|||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inspect the per-user box in one shot. Non-zero exit (no such container) yields
|
||||||
|
// an all-null/false status. `--memory 0` means unlimited, which the Go template
|
||||||
|
// prints as `0`; we normalize that (and any unparseable value) to null.
|
||||||
|
export const inspectUserBoxStatus = async (
|
||||||
|
username: string,
|
||||||
|
): Promise<{
|
||||||
|
running: boolean;
|
||||||
|
image: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
memoryLimitBytes: number | null;
|
||||||
|
}> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'inspect',
|
||||||
|
'--format',
|
||||||
|
'{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}',
|
||||||
|
userContainerName(username),
|
||||||
|
],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
|
||||||
|
}
|
||||||
|
const [runningField, imageField, startedAtField, memoryField] = result.stdout
|
||||||
|
.trim()
|
||||||
|
.split('|');
|
||||||
|
const running = runningField === 'true';
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "no image"
|
||||||
|
image: imageField || null,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "not started"
|
||||||
|
startedAt: running ? startedAtField || null : null,
|
||||||
|
memoryLimitBytes: Number(memoryField) || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
// The marker is embedded literally into a `# comment` line and into a `pgrep`
|
||||||
|
// pattern, so it must not contain shell metacharacters or a newline (either could
|
||||||
|
// break out of the comment or the pattern). Callers generate it, but validate
|
||||||
|
// defensively so a malformed marker fails loudly instead of silently corrupting
|
||||||
|
// the script.
|
||||||
|
const MARKER_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
const assertMarker = (marker: string) => {
|
||||||
|
if (!MARKER_PATTERN.test(marker)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid process marker ${JSON.stringify(marker)}: must match ${String(MARKER_PATTERN)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wraps a CLI argv so the process runs as a new session/process-group leader whose
|
||||||
|
// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT
|
||||||
|
// `exec` the CLI: bash tail-exec's a final simple command, which would replace the
|
||||||
|
// marker-carrying bash with the CLI and make `pgrep -f <marker>` miss the running
|
||||||
|
// turn. Keeping a trailing `rc=$?; exit "$rc"` after the CLI means the CLI is no
|
||||||
|
// longer the script's final statement, so bash survives as the group leader while
|
||||||
|
// still propagating the CLI's exit code (downstream `normalizeRunResult` relies on
|
||||||
|
// it — a bare trailing `wait` would mask a non-zero CLI failure as 0). The CLI runs
|
||||||
|
// in the same process group as this bash, so the kill helper's group signal reaches
|
||||||
|
// it. stdout/stderr fds are inherited by the CLI so streaming still works.
|
||||||
|
export const buildMarkedCommand = (
|
||||||
|
marker: string,
|
||||||
|
command: string[],
|
||||||
|
): string[] => {
|
||||||
|
assertMarker(marker);
|
||||||
|
const script = [
|
||||||
|
`# ${marker}`,
|
||||||
|
'exec 0</dev/null',
|
||||||
|
command.map(shellQuote).join(' '),
|
||||||
|
'rc=$?',
|
||||||
|
'exit "$rc"',
|
||||||
|
].join('\n');
|
||||||
|
return ['setsid', 'bash', '-lc', script];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kills every process group whose bash parent matches the marker, TERM then KILL.
|
||||||
|
// The negative pid (`kill -TERM -"$pgid"`) targets the whole process group, so the
|
||||||
|
// CLI and any children it spawned die together.
|
||||||
|
//
|
||||||
|
// pgrep self-match: this kill script runs via `bash -lc <script>`, whose OWN argv
|
||||||
|
// contains the marker literal, so `pgrep -f <marker>` matches the kill script (and
|
||||||
|
// its command-substitution subshells) too. Left unguarded it would `kill -TERM` its
|
||||||
|
// own process group and die on iteration one, leaking the real target. So we compute
|
||||||
|
// this script's pgid up front and skip any match sharing it, killing only the
|
||||||
|
// target's process group.
|
||||||
|
export const buildKillScript = (marker: string): string => {
|
||||||
|
assertMarker(marker);
|
||||||
|
return [
|
||||||
|
`self_pgid=$(ps -o pgid= -p $$ | tr -d ' ')`,
|
||||||
|
`pids=$(pgrep -f ${shellQuote(marker)} || true)`,
|
||||||
|
`for pid in $pids; do`,
|
||||||
|
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
|
||||||
|
` [ -z "$pgid" ] && continue`,
|
||||||
|
` [ "$pgid" = "$self_pgid" ] && continue`,
|
||||||
|
` kill -TERM -"$pgid" 2>/dev/null || true`,
|
||||||
|
`done`,
|
||||||
|
`sleep 2`,
|
||||||
|
`for pid in $pids; do`,
|
||||||
|
` pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
|
||||||
|
` [ -z "$pgid" ] && continue`,
|
||||||
|
` [ "$pgid" = "$self_pgid" ] && continue`,
|
||||||
|
` kill -KILL -"$pgid" 2>/dev/null || true`,
|
||||||
|
`done`,
|
||||||
|
].join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const killBoxProcessesByMarker = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
marker: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['exec', args.containerName, 'bash', '-lc', buildKillScript(args.marker)],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const streamExecInContainer = async (args: {
|
export const streamExecInContainer = async (args: {
|
||||||
containerName: string;
|
containerName: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
@@ -441,17 +394,6 @@ export const stopWorkspaceContainer = async (containerName: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const inspectWorkspaceContainer = async (containerName: string) => {
|
|
||||||
const result = await execa(containerRuntime(), ['inspect', containerName], {
|
|
||||||
all: true,
|
|
||||||
reject: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
exists: result.exitCode === 0,
|
|
||||||
output: result.all,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listWorkspaceContainerNames = async (prefix: string) => {
|
export const listWorkspaceContainerNames = async (prefix: string) => {
|
||||||
const result = await execa(
|
const result = await execa(
|
||||||
containerRuntime(),
|
containerRuntime(),
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import { normalizeOpenCodeRunLine } from '../agent-events';
|
||||||
|
import { env } from '../env';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import {
|
||||||
|
buildMarkedCommand,
|
||||||
|
killBoxProcessesByMarker,
|
||||||
|
streamExecInContainer,
|
||||||
|
} from './docker';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
|
import { opencodeModel, providerEnvironment } from './provider';
|
||||||
|
|
||||||
|
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||||
|
// permissions (mirrors the CodexAdapter helper).
|
||||||
|
const writeJsonFile = async (filePath: string, content: string) => {
|
||||||
|
let normalized = content.trim();
|
||||||
|
try {
|
||||||
|
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
||||||
|
} catch {
|
||||||
|
throw new Error('OpenCode auth JSON is not valid JSON.');
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOpenCodeAdapter: AdapterFactory = (deps) => {
|
||||||
|
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||||
|
|
||||||
|
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||||
|
// API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY
|
||||||
|
// in the environment; only `opencode_auth_json` profiles need auth.json on disk.
|
||||||
|
const profile = workspace.claim.aiProviderProfile;
|
||||||
|
if (profile?.authType !== 'opencode_auth_json') return;
|
||||||
|
if (!profile.secret) {
|
||||||
|
throw new Error('OpenCode auth profile is missing auth.json contents.');
|
||||||
|
}
|
||||||
|
const authPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.local',
|
||||||
|
'share',
|
||||||
|
'opencode',
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
await writeJsonFile(authPath, profile.secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTurn = async (
|
||||||
|
workspace: AdapterWorkspace,
|
||||||
|
prompt: string,
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||||
|
): Promise<TurnResult> => {
|
||||||
|
workspace.runtime = 'opencode';
|
||||||
|
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||||
|
const marker = workspace.turnMarker;
|
||||||
|
|
||||||
|
// Flags verified against `opencode run --help` (opencode-ai@1.17.9):
|
||||||
|
// `--format json`, `--model`, and `--session <id>` all exist.
|
||||||
|
const argv = [
|
||||||
|
'opencode',
|
||||||
|
'run',
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
'--model',
|
||||||
|
opencodeModel(workspace.claim),
|
||||||
|
...(workspace.opencodeSessionId
|
||||||
|
? ['--session', workspace.opencodeSessionId]
|
||||||
|
: []),
|
||||||
|
'--',
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
const command = buildMarkedCommand(marker, argv);
|
||||||
|
|
||||||
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let capturedSessionId: string | undefined;
|
||||||
|
let capturedError: string | undefined;
|
||||||
|
let finalMessage: string | undefined;
|
||||||
|
|
||||||
|
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||||
|
// in-box process group. Schedule an explicit marker kill on timeout and clear
|
||||||
|
// it once the exec resolves.
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
void killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker,
|
||||||
|
});
|
||||||
|
}, env.jobTimeoutMs);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await execStream({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
|
command,
|
||||||
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
redact: workspace.redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
onStdoutLine: async (line) => {
|
||||||
|
for (const event of normalizeOpenCodeRunLine(line)) {
|
||||||
|
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||||
|
if (event.kind === 'error') capturedError = event.message;
|
||||||
|
if (event.kind === 'assistant_completed' && event.content) {
|
||||||
|
finalMessage = event.content;
|
||||||
|
}
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: async (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
await onEvent({ kind: 'status', status: line });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
capturedError = result.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = async (workspace: AdapterWorkspace) => {
|
||||||
|
if (!workspace.turnMarker) return;
|
||||||
|
await killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker: workspace.turnMarker,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name: 'opencode', prepareAuth, runTurn, abort };
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import type { AgentRuntimeName } from './agent-runtime';
|
||||||
|
|
||||||
|
export type Claim = {
|
||||||
|
job: {
|
||||||
|
_id: Id<'agentJobs'>;
|
||||||
|
prompt: string;
|
||||||
|
runtime?: 'codex' | 'opencode' | 'claude';
|
||||||
|
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
|
||||||
|
envFilePath?: string;
|
||||||
|
materializeEnvFile?: boolean;
|
||||||
|
baseBranch: string;
|
||||||
|
workBranch: string;
|
||||||
|
forkOwner: string;
|
||||||
|
forkRepo: string;
|
||||||
|
upstreamOwner: string;
|
||||||
|
upstreamRepo: string;
|
||||||
|
};
|
||||||
|
spoon: { name: string };
|
||||||
|
openai: {
|
||||||
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
};
|
||||||
|
aiProviderProfile?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider:
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'google'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'requesty'
|
||||||
|
| 'litellm'
|
||||||
|
| 'cloudflare_ai_gateway'
|
||||||
|
| 'custom_openai_compatible'
|
||||||
|
| 'opencode_openai_login';
|
||||||
|
authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none';
|
||||||
|
secret?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
};
|
||||||
|
github: { installationId?: string };
|
||||||
|
agentSettings?: {
|
||||||
|
installCommand?: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
testCommand?: string;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
} | null;
|
||||||
|
secrets: { name: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The subset of the worker's active workspace that agent adapters read.
|
||||||
|
export type AdapterWorkspace = {
|
||||||
|
claim: Claim;
|
||||||
|
workdir: string;
|
||||||
|
homeDir: string;
|
||||||
|
username: string;
|
||||||
|
containerHome: string;
|
||||||
|
containerRepo: string;
|
||||||
|
repoDir: string;
|
||||||
|
boxName: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
runtime: AgentRuntimeName;
|
||||||
|
codexSessionId?: string;
|
||||||
|
opencodeSessionId?: string;
|
||||||
|
claudeSessionId?: string;
|
||||||
|
turnMarker?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCodexLoginProfile = (claim: Claim) =>
|
||||||
|
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
|
||||||
|
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
||||||
|
|
||||||
|
export const collectJsonStringValues = (value?: string): string[] => {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
const values: string[] = [];
|
||||||
|
const visit = (item: unknown) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
if (item.length >= 12) values.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
item.forEach(visit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
Object.values(item).forEach(visit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(parsed);
|
||||||
|
return values;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const providerEnvironment = (
|
||||||
|
claim: Claim,
|
||||||
|
workspaceRoot?: string,
|
||||||
|
): Record<string, string> => {
|
||||||
|
if (isCodexLoginProfile(claim)) {
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
throw new Error('Codex auth profiles require a prepared workspace.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
CODEX_HOME: path.join(workspaceRoot, '.codex'),
|
||||||
|
HOME: workspaceRoot,
|
||||||
|
XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'),
|
||||||
|
XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const profile = claim.aiProviderProfile;
|
||||||
|
const secret = profile?.secret ?? claim.openai.apiKey;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('No AI provider credential is configured for this job.');
|
||||||
|
}
|
||||||
|
const baseUrl: Record<string, string> = profile?.baseUrl
|
||||||
|
? { OPENAI_BASE_URL: profile.baseUrl }
|
||||||
|
: {};
|
||||||
|
if (!profile || profile.provider === 'openai') {
|
||||||
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
|
||||||
|
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
|
||||||
|
if (profile.provider === 'openrouter') {
|
||||||
|
return { OPENROUTER_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'requesty') {
|
||||||
|
return { REQUESTY_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'cloudflare_ai_gateway') {
|
||||||
|
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
profile.provider === 'litellm' ||
|
||||||
|
profile.provider === 'custom_openai_compatible'
|
||||||
|
) {
|
||||||
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported AI provider profile.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const opencodeModel = (claim: Claim) => {
|
||||||
|
const profile = claim.aiProviderProfile;
|
||||||
|
const model = profile?.model ?? claim.openai.model;
|
||||||
|
if (model.includes('/')) return model;
|
||||||
|
if (!profile) return `openai/${model}`;
|
||||||
|
if (
|
||||||
|
profile.provider === 'custom_openai_compatible' ||
|
||||||
|
profile.provider === 'cloudflare_ai_gateway'
|
||||||
|
) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
|
||||||
|
return `${profile.provider}/${model}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const codexModel = (claim: Claim) => {
|
||||||
|
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
|
||||||
|
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const codexModelArgs = (claim: Claim) =>
|
||||||
|
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
|
||||||
|
|
||||||
|
// Claude Code OAuth profiles store their credentials as a JSON blob written to
|
||||||
|
// `~/.claude/.credentials.json`; API-key profiles authenticate via
|
||||||
|
// ANTHROPIC_API_KEY. Keyed on the Anthropic OAuth snapshot type.
|
||||||
|
export const isClaudeOAuthProfile = (claim: Claim) =>
|
||||||
|
claim.aiProviderProfile?.authType === 'anthropic_oauth_json';
|
||||||
|
|
||||||
|
// The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
|
||||||
|
// so strip any `provider/` prefix like codexModel does.
|
||||||
|
export const claudeModel = (claim: Claim) => {
|
||||||
|
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
|
||||||
|
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Environment for a `claude -p` turn. OAuth-json profiles authenticate from the
|
||||||
|
// on-disk credentials file (only HOME is needed); API-key profiles export
|
||||||
|
// ANTHROPIC_API_KEY.
|
||||||
|
export const claudeEnv = (
|
||||||
|
claim: Claim,
|
||||||
|
containerHome: string,
|
||||||
|
): Record<string, string> => {
|
||||||
|
if (isClaudeOAuthProfile(claim)) return { HOME: containerHome };
|
||||||
|
const secret = claim.aiProviderProfile?.secret ?? claim.openai.apiKey;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('No Anthropic API key is configured for this Claude job.');
|
||||||
|
}
|
||||||
|
return { ANTHROPIC_API_KEY: secret, HOME: containerHome };
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerAdapter } from './agent-runtime';
|
||||||
|
import { createClaudeAdapter } from './claude-adapter';
|
||||||
|
import { createCodexAdapter } from './codex-adapter';
|
||||||
|
import { createOpenCodeAdapter } from './opencode-adapter';
|
||||||
|
|
||||||
|
registerAdapter('codex', createCodexAdapter);
|
||||||
|
registerAdapter('opencode', createOpenCodeAdapter);
|
||||||
|
registerAdapter('claude', createClaudeAdapter);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { AgentRuntimeName } from './agent-runtime';
|
||||||
|
|
||||||
|
// Pure, side-effect-free decision for how a completed runtime turn should be
|
||||||
|
// surfaced. Kept out of worker.ts so it is unit-testable without loading the
|
||||||
|
// worker's Convex/Docker dependencies.
|
||||||
|
//
|
||||||
|
// Inputs:
|
||||||
|
// - assistantText: text already streamed to the user (may be partial).
|
||||||
|
// - recoveredText: text recovered from `TurnResult.finalMessage` (already
|
||||||
|
// redacted+truncated by the caller), used only when nothing streamed.
|
||||||
|
// - error: `TurnResult.error` — non-empty when the runtime reported a hard
|
||||||
|
// failure (nonzero exit / failure event).
|
||||||
|
// - runtime: runtime name, for the failure message prefix.
|
||||||
|
//
|
||||||
|
// Output:
|
||||||
|
// - text: the assistant text to persist (streamed text, else recovered text).
|
||||||
|
// - failure: when set, the caller must surface the turn as FAILED (throw). The
|
||||||
|
// partial text in `text` is still preserved so the user can see it.
|
||||||
|
export const resolveTurnOutcome = (args: {
|
||||||
|
assistantText: string;
|
||||||
|
recoveredText?: string;
|
||||||
|
error?: string;
|
||||||
|
runtime: AgentRuntimeName;
|
||||||
|
}): { text: string; failure?: string } => {
|
||||||
|
const { assistantText, recoveredText, error, runtime } = args;
|
||||||
|
let text = assistantText;
|
||||||
|
if (!text.trim() && recoveredText) {
|
||||||
|
text = recoveredText;
|
||||||
|
}
|
||||||
|
if (!text.trim()) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
failure: error
|
||||||
|
? `${runtime} failed:\n${error}`
|
||||||
|
: 'Codex completed without producing an assistant response.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// A hard failure that streamed partial text must still be surfaced as failed;
|
||||||
|
// the streamed text is preserved above so the user sees the truncated answer.
|
||||||
|
if (error) {
|
||||||
|
return { text, failure: `${runtime} failed:\n${error}` };
|
||||||
|
}
|
||||||
|
return { text };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure decision for whether the `content` on an `assistant_completed` event
|
||||||
|
// should be folded into the accumulated assistant text.
|
||||||
|
//
|
||||||
|
// Some runtimes (Claude `-p --output-format stream-json`, OpenCode) emit the
|
||||||
|
// final answer TWICE: once as streamed `assistant_delta` chunks and again as
|
||||||
|
// the `content` on the terminal `assistant_completed`/`result` event. Appending
|
||||||
|
// both yields a doubled "<answer><answer>". So only fold in the completed
|
||||||
|
// content when nothing has streamed yet (a runtime that emits only a final
|
||||||
|
// result). When deltas already carried the answer, skip it. The truly-empty
|
||||||
|
// case is still covered by resolveTurnOutcome's recoveredText/finalMessage
|
||||||
|
// fallback, so nothing is lost.
|
||||||
|
export const shouldAppendCompletedContent = (
|
||||||
|
currentText: string,
|
||||||
|
completedContent?: string,
|
||||||
|
): boolean => Boolean(completedContent) && !currentText.trim();
|
||||||
@@ -3,6 +3,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBoxStatus,
|
||||||
|
listBoxTree,
|
||||||
|
readBoxFile,
|
||||||
|
restartBox,
|
||||||
|
startBox,
|
||||||
|
stopBox,
|
||||||
|
writeBoxFile,
|
||||||
|
} from './box';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { attachTerminalServer } from './terminal';
|
import { attachTerminalServer } from './terminal';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +65,11 @@ const jobRoute = (pathname: string) => {
|
|||||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const boxRoute = (pathname: string) => {
|
||||||
|
const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname);
|
||||||
|
return match?.[1] ? { action: match[1] } : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const startWorkerServer = () => {
|
export const startWorkerServer = () => {
|
||||||
const server = createServer((request, response) => {
|
const server = createServer((request, response) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -73,6 +87,59 @@ export const startWorkerServer = () => {
|
|||||||
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const box = boxRoute(url.pathname);
|
||||||
|
if (box) {
|
||||||
|
const user = url.searchParams.get('user') ?? '';
|
||||||
|
if (!user) {
|
||||||
|
sendJson(response, 400, { error: 'Missing user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && box.action === 'status') {
|
||||||
|
sendJson(response, 200, { status: await getBoxStatus(user) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && box.action === 'lifecycle') {
|
||||||
|
const body = await parseJson<{ action?: string }>(request);
|
||||||
|
if (body.action === 'start') {
|
||||||
|
await startBox(user);
|
||||||
|
} else if (body.action === 'stop') {
|
||||||
|
await stopBox(user);
|
||||||
|
} else if (body.action === 'restart') {
|
||||||
|
await restartBox(user);
|
||||||
|
} else {
|
||||||
|
sendJson(response, 400, { error: 'Unknown action' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { status: await getBoxStatus(user) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && box.action === 'tree') {
|
||||||
|
sendJson(response, 200, { tree: await listBoxTree(user) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && box.action === 'file') {
|
||||||
|
const filePath = url.searchParams.get('path') ?? '';
|
||||||
|
sendJson(response, 200, {
|
||||||
|
path: filePath,
|
||||||
|
content: await readBoxFile(user, filePath),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'PUT' && box.action === 'file') {
|
||||||
|
const body = await parseJson<{ path?: string; content?: string }>(
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await writeBoxFile(user, body.path ?? '', body.content ?? ''),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const route = jobRoute(url.pathname);
|
const route = jobRoute(url.pathname);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
sendJson(response, 404, { error: 'Not found' });
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
@@ -175,6 +242,8 @@ export const startWorkerServer = () => {
|
|||||||
const status =
|
const status =
|
||||||
message === 'Unauthorized'
|
message === 'Unauthorized'
|
||||||
? 401
|
? 401
|
||||||
|
: message.startsWith('Refusing to')
|
||||||
|
? 400
|
||||||
: message.includes('not supported')
|
: message.includes('not supported')
|
||||||
? 409
|
? 409
|
||||||
: 500;
|
: 500;
|
||||||
|
|||||||
@@ -27,3 +27,41 @@ export const verifyTerminalToken = (
|
|||||||
timingSafeEqual(providedBuf, expectedBuf)
|
timingSafeEqual(providedBuf, expectedBuf)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract the username a box token authorizes without verifying its signature.
|
||||||
|
// The token itself authorizes the connection, so the upgrade handler reads the
|
||||||
|
// username from it (rather than a query param) and then verifies the whole
|
||||||
|
// token against that username. Returns null unless the format matches
|
||||||
|
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`.
|
||||||
|
export const parseBoxTokenUsername = (token: string): string | null => {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 4 || parts[1] !== 'box') return null;
|
||||||
|
const username = parts[2];
|
||||||
|
if (!username) return null;
|
||||||
|
return username;
|
||||||
|
};
|
||||||
|
|
||||||
|
// User-scoped variant authorizing a terminal connection to a user's box.
|
||||||
|
// Embeds a literal `box` segment so its four parts never collide with the
|
||||||
|
// three-part job token above. Format:
|
||||||
|
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
|
||||||
|
export const verifyBoxTerminalToken = (
|
||||||
|
token: string,
|
||||||
|
username: string,
|
||||||
|
secret: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!token || !secret) return false;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 4 || parts[1] !== 'box') return false;
|
||||||
|
const [expRaw, , tokenUsername, provided] = parts;
|
||||||
|
if (tokenUsername !== username) return false;
|
||||||
|
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||||
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||||
|
const expected = signature(`${expRaw}.box.${tokenUsername}`, secret);
|
||||||
|
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||||
|
const expectedBuf = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
providedBuf.length === expectedBuf.length &&
|
||||||
|
timingSafeEqual(providedBuf, expectedBuf)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
||||||
import type { Server } from 'node:http';
|
import type { Server } from 'node:http';
|
||||||
|
import type { Duplex } from 'node:stream';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
import type { BoxHandle } from './user-container';
|
||||||
|
import { boxHomePaths } from './box';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { verifyTerminalToken } from './terminal-token';
|
import { getDockerClient } from './runtime/docker';
|
||||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
import {
|
||||||
|
parseBoxTokenUsername,
|
||||||
|
verifyBoxTerminalToken,
|
||||||
|
verifyTerminalToken,
|
||||||
|
} from './terminal-token';
|
||||||
|
import { acquireUserBox } from './user-container';
|
||||||
|
import { ensureBashProfile } from './user-environment';
|
||||||
import { getTerminalWorkspace } from './worker';
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
const clampDimension = (value: unknown) => {
|
const clampDimension = (value: unknown) => {
|
||||||
@@ -15,55 +22,79 @@ const clampDimension = (value: unknown) => {
|
|||||||
return Math.min(Math.max(n, 1), 1000);
|
return Math.min(Math.max(n, 1), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single-quote a string for a POSIX shell.
|
export const parseResizeMessage = (
|
||||||
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
data: Buffer,
|
||||||
|
isBinary: boolean,
|
||||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
): { cols: number; rows: number } | null => {
|
||||||
const workspace = getTerminalWorkspace(jobId);
|
if (isBinary) return null;
|
||||||
if (!workspace) {
|
|
||||||
ws.close(1011, 'Workspace is not active.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
|
||||||
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
|
||||||
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
|
||||||
//
|
|
||||||
// Register the message handler immediately and buffer input/size until the exec
|
|
||||||
// is ready (acquiring the box can take seconds on first connect), so the initial
|
|
||||||
// resize and early keystrokes aren't dropped.
|
|
||||||
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
|
||||||
const pendingInput: Buffer[] = [];
|
|
||||||
let cols = 80;
|
|
||||||
let rows = 24;
|
|
||||||
|
|
||||||
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
|
||||||
if (!isBinary) {
|
|
||||||
// Text frames are control messages (resize); anything else is raw input.
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString('utf8')) as {
|
const message = JSON.parse(data.toString('utf8')) as {
|
||||||
type?: string;
|
type?: string;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
};
|
};
|
||||||
if (message.type === 'resize') {
|
if (message.type !== 'resize') return null;
|
||||||
const c = clampDimension(message.cols);
|
const cols = clampDimension(message.cols);
|
||||||
const r = clampDimension(message.rows);
|
const rows = clampDimension(message.rows);
|
||||||
if (c && r) {
|
if (!cols || !rows) return null;
|
||||||
cols = c;
|
return { cols, rows };
|
||||||
rows = r;
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The login-shell command shared by the job and box terminals: prefer a
|
||||||
|
// resumable tmux session, else fall back to a plain interactive login shell.
|
||||||
|
const SHELL_CMD = [
|
||||||
|
'/bin/bash',
|
||||||
|
'-lc',
|
||||||
|
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi',
|
||||||
|
];
|
||||||
|
|
||||||
|
type ShellBridgeOptions = {
|
||||||
|
// Acquire the per-user box (reference-counted); its handle is released on
|
||||||
|
// cleanup. Called after the input handler is wired so early keystrokes buffer.
|
||||||
|
acquire: () => Promise<BoxHandle>;
|
||||||
|
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
||||||
|
prepare?: () => Promise<void>;
|
||||||
|
// Directory the login shell starts in (container path).
|
||||||
|
cwd: string;
|
||||||
|
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
|
||||||
|
envFlags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared PTY bridge: wire a WebSocket to an interactive login shell running via
|
||||||
|
* a real TTY (dockerode `exec` with `Tty:true` + `exec.resize`) inside the
|
||||||
|
* user's box. Registers the input handler immediately and buffers input/size
|
||||||
|
* until the exec is ready (acquiring the box can take seconds on first connect),
|
||||||
|
* so the initial resize and early keystrokes aren't dropped. Used by both the
|
||||||
|
* job terminal (`bridge`) and the box terminal (`bridgeBox`); only the box to
|
||||||
|
* acquire, the working directory, and the env differ.
|
||||||
|
*/
|
||||||
|
const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||||
|
const execHolder: {
|
||||||
|
current?: { exec: import('dockerode').Exec; stream: Duplex };
|
||||||
|
} = {};
|
||||||
|
const pendingInput: Buffer[] = [];
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||||
|
const resize = parseResizeMessage(data, isBinary);
|
||||||
|
if (resize) {
|
||||||
|
cols = resize.cols;
|
||||||
|
rows = resize.rows;
|
||||||
|
void execHolder.current?.exec
|
||||||
|
.resize({ h: rows, w: cols })
|
||||||
|
.catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
if (execHolder.current) execHolder.current.stream.write(data);
|
||||||
// fall through: treat as raw input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (procHolder.current) procHolder.current.stdin.write(data);
|
|
||||||
else pendingInput.push(data);
|
else pendingInput.push(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
let acquired = false;
|
const handleHolder: { current?: BoxHandle } = {};
|
||||||
let released = false;
|
let released = false;
|
||||||
// Read through a function so TS doesn't narrow `released` to a constant — the
|
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||||
// cleanup handler flips it asynchronously when the socket closes.
|
// cleanup handler flips it asynchronously when the socket closes.
|
||||||
@@ -71,8 +102,9 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
procHolder.current?.kill();
|
execHolder.current?.stream.end();
|
||||||
if (acquired) releaseUserBox(workspace.username);
|
execHolder.current?.stream.destroy();
|
||||||
|
handleHolder.current?.release();
|
||||||
};
|
};
|
||||||
ws.on('close', cleanup);
|
ws.on('close', cleanup);
|
||||||
ws.on('error', cleanup);
|
ws.on('error', cleanup);
|
||||||
@@ -81,12 +113,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
// the terminal share the exact same container (Phase 2).
|
// the terminal share the exact same container (Phase 2).
|
||||||
let boxName: string;
|
let boxName: string;
|
||||||
try {
|
try {
|
||||||
boxName = await acquireUserBox({
|
if (opts.prepare) await opts.prepare();
|
||||||
username: workspace.username,
|
const handle = await opts.acquire();
|
||||||
workdir: workspace.workdir,
|
handleHolder.current = handle;
|
||||||
containerHome: workspace.containerHome,
|
boxName = handle.boxName;
|
||||||
});
|
|
||||||
acquired = true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ws.close(
|
ws.close(
|
||||||
1011,
|
1011,
|
||||||
@@ -95,64 +125,90 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isReleased()) return; // client disconnected during startup; cleanup ran
|
// Cleanup may have run before the awaited handle existed.
|
||||||
|
if (isReleased()) {
|
||||||
|
handleHolder.current.release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reattach a persistent tmux session across reconnects when available, else a
|
const docker = getDockerClient();
|
||||||
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
|
const container = docker.getContainer(boxName);
|
||||||
const launcher =
|
const exec = await container.exec({
|
||||||
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
|
AttachStdin: true,
|
||||||
// Reattach a persistent tmux session when tmux is present; otherwise fall back
|
AttachStdout: true,
|
||||||
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
|
AttachStderr: true,
|
||||||
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
|
Tty: true,
|
||||||
// non-interactive shell exit before the `||`, so the fallback never runs.
|
Cmd: SHELL_CMD,
|
||||||
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
|
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||||
'else exec bash -il; fi';
|
WorkingDir: opts.cwd,
|
||||||
const envFlags = [
|
});
|
||||||
'-e',
|
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||||
'TERM=xterm-256color',
|
execHolder.current = { exec, stream };
|
||||||
'-e',
|
|
||||||
`HOME=${workspace.containerHome}`,
|
|
||||||
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const proc = spawn(
|
if (isReleased()) {
|
||||||
env.containerRuntime,
|
stream.end();
|
||||||
[
|
stream.destroy();
|
||||||
'exec',
|
return;
|
||||||
'-i',
|
}
|
||||||
...envFlags,
|
|
||||||
'-w',
|
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
|
||||||
workspace.containerRepo,
|
|
||||||
boxName,
|
|
||||||
'/bin/bash',
|
|
||||||
'-lc',
|
|
||||||
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
|
|
||||||
],
|
|
||||||
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
|
||||||
);
|
|
||||||
procHolder.current = proc;
|
|
||||||
|
|
||||||
// Replay any keystrokes the client sent before the process was ready.
|
// Replay any keystrokes the client sent before the process was ready.
|
||||||
for (const buffered of pendingInput) proc.stdin.write(buffered);
|
for (const buffered of pendingInput) stream.write(buffered);
|
||||||
pendingInput.length = 0;
|
pendingInput.length = 0;
|
||||||
|
|
||||||
const forward = (chunk: Buffer) => {
|
stream.on('data', (chunk: Buffer) => {
|
||||||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||||
};
|
});
|
||||||
proc.stdout.on('data', forward);
|
stream.on('end', () => {
|
||||||
proc.stderr.on('data', forward);
|
|
||||||
proc.on('exit', () => {
|
|
||||||
if (ws.readyState === ws.OPEN) ws.close();
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
});
|
});
|
||||||
proc.on('error', () => {
|
stream.on('error', () => {
|
||||||
if (ws.readyState === ws.OPEN) ws.close();
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Job terminal: opens the shell at the repo checkout, with the job's selected
|
||||||
|
// secrets in the environment (it is scoped to that job's active workspace).
|
||||||
|
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||||
|
const workspace = getTerminalWorkspace(jobId);
|
||||||
|
if (!workspace) {
|
||||||
|
ws.close(1011, 'Workspace is not active.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runShellBridge(ws, {
|
||||||
|
acquire: () =>
|
||||||
|
acquireUserBox({
|
||||||
|
username: workspace.username,
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
}),
|
||||||
|
cwd: workspace.containerRepo,
|
||||||
|
envFlags: [
|
||||||
|
`HOME=${workspace.containerHome}`,
|
||||||
|
...workspace.secrets.map((secret) => `${secret.name}=${secret.value}`),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Box terminal: opens a login shell rooted at the user's home (`~`), with no
|
||||||
|
// per-job secrets — it is user-scoped, not tied to any job's workspace.
|
||||||
|
const bridgeBox = async (ws: WebSocket, username: string) => {
|
||||||
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
|
await runShellBridge(ws, {
|
||||||
|
prepare: () => ensureBashProfile(homeDir),
|
||||||
|
acquire: () =>
|
||||||
|
acquireUserBox({ username, workdir: homeDir, containerHome }),
|
||||||
|
cwd: containerHome,
|
||||||
|
envFlags: [`HOME=${containerHome}`],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
|
* Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP
|
||||||
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
* server. Two routes, each authorized by a short-lived token minted by the Next
|
||||||
* token minted by the Next app (which has already verified job ownership).
|
* app: `/jobs/:jobId/terminal?token=…` (job-scoped, after ownership check) and
|
||||||
|
* `/box/terminal?token=…` (user-scoped, username read from the token itself).
|
||||||
*/
|
*/
|
||||||
export const attachTerminalServer = (server: Server) => {
|
export const attachTerminalServer = (server: Server) => {
|
||||||
if (env.runtime !== 'docker') return;
|
if (env.runtime !== 'docker') return;
|
||||||
@@ -160,20 +216,43 @@ export const attachTerminalServer = (server: Server) => {
|
|||||||
|
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||||
|
const token = url.searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
// User-scoped box terminal: the token authorizes the connection, so the
|
||||||
|
// username is read FROM the token, then verified against the whole token.
|
||||||
|
if (url.pathname === '/box/terminal') {
|
||||||
|
const username = parseBoxTokenUsername(token);
|
||||||
|
if (
|
||||||
|
!username ||
|
||||||
|
!verifyBoxTerminalToken(token, username, env.terminalSecret)
|
||||||
|
) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
void bridgeBox(ws, username).catch(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||||
if (!match?.[1]) {
|
if (!match?.[1]) {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const jobId = decodeURIComponent(match[1]);
|
const jobId = decodeURIComponent(match[1]);
|
||||||
const token = url.searchParams.get('token') ?? '';
|
|
||||||
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
void bridge(ws, jobId);
|
void bridge(ws, jobId).catch(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,135 @@
|
|||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
|
listWorkspaceContainerNames,
|
||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
userContainerName,
|
userContainerName,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
|
|
||||||
// Phase 2: one persistent "box" container per user that all of their threads
|
// Phase 2: one persistent "box" container per user. Reference-counted by opaque
|
||||||
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
// handles (not a shared integer), serialized per-username with an async mutex,
|
||||||
// up while any thread workspace is active or a terminal is connected, and is
|
// and idle-reaped once no handle is held.
|
||||||
// reaped after an idle period once nothing holds it.
|
export type BoxHandle = { boxName: string; release: () => void };
|
||||||
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
|
||||||
const boxes = new Map<string, Box>();
|
|
||||||
|
|
||||||
export const acquireUserBox = async (args: {
|
type Box = {
|
||||||
|
name: string;
|
||||||
|
initialized: boolean;
|
||||||
|
refs: Set<symbol>;
|
||||||
|
idleTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
const boxes = new Map<string, Box>();
|
||||||
|
const locks = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
// Per-username async mutex: chain each operation after the previous one.
|
||||||
|
const withLock = <T>(username: string, fn: () => Promise<T>): Promise<T> => {
|
||||||
|
const previous = locks.get(username) ?? Promise.resolve();
|
||||||
|
const next = previous.then(fn, fn);
|
||||||
|
locks.set(
|
||||||
|
username,
|
||||||
|
next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReapIfIdle = (username: string) => {
|
||||||
|
const box = boxes.get(username);
|
||||||
|
if (!box || box.refs.size > 0) return;
|
||||||
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
|
box.idleTimer = setTimeout(() => {
|
||||||
|
void withLock(username, async () => {
|
||||||
|
const current = boxes.get(username);
|
||||||
|
if (current !== box || current.refs.size > 0) return;
|
||||||
|
await stopWorkspaceContainer(current.name);
|
||||||
|
if (boxes.get(username) === current && current.refs.size === 0) {
|
||||||
|
boxes.delete(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, env.boxIdleMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeHandle = (username: string, box: Box): BoxHandle => {
|
||||||
|
const token = Symbol('box-ref');
|
||||||
|
box.refs.add(token);
|
||||||
|
let released = false;
|
||||||
|
return {
|
||||||
|
boxName: box.name,
|
||||||
|
release: () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
box.refs.delete(token);
|
||||||
|
scheduleReapIfIdle(username);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acquireUserBox = (args: {
|
||||||
username: string;
|
username: string;
|
||||||
workdir: string;
|
workdir: string;
|
||||||
containerHome: string;
|
containerHome: string;
|
||||||
}): Promise<string> => {
|
}): Promise<BoxHandle> =>
|
||||||
const name = await ensureUserContainer(args);
|
withLock(args.username, async () => {
|
||||||
const box = boxes.get(args.username) ?? { refs: 0 };
|
let box = boxes.get(args.username);
|
||||||
if (box.idleTimer) {
|
if (box?.idleTimer) {
|
||||||
clearTimeout(box.idleTimer);
|
clearTimeout(box.idleTimer);
|
||||||
box.idleTimer = undefined;
|
box.idleTimer = undefined;
|
||||||
}
|
}
|
||||||
box.refs += 1;
|
if (!box) {
|
||||||
|
box = {
|
||||||
|
name: userContainerName(args.username),
|
||||||
|
initialized: false,
|
||||||
|
refs: new Set(),
|
||||||
|
};
|
||||||
boxes.set(args.username, box);
|
boxes.set(args.username, box);
|
||||||
return name;
|
}
|
||||||
|
// Register the ref before the slow Docker call so a failed acquire can
|
||||||
|
// release it without leaking registry state.
|
||||||
|
const handle = makeHandle(args.username, box);
|
||||||
|
try {
|
||||||
|
if (!box.initialized) {
|
||||||
|
box.name = await ensureUserContainer(args);
|
||||||
|
box.initialized = true;
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
} catch (error) {
|
||||||
|
handle.release();
|
||||||
|
if (boxes.get(args.username) === box && box.refs.size === 0) {
|
||||||
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
|
boxes.delete(args.username);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adopt boxes left by a prior worker process so the idle reaper cleans them up.
|
||||||
|
export const reconcileExistingBoxes = async (): Promise<void> => {
|
||||||
|
const names = await listWorkspaceContainerNames('spoon-box-');
|
||||||
|
for (const name of names) {
|
||||||
|
const username = name.replace(/^spoon-box-/, '');
|
||||||
|
if (boxes.has(username)) continue;
|
||||||
|
const box: Box = { name, initialized: false, refs: new Set() };
|
||||||
|
boxes.set(username, box);
|
||||||
|
scheduleReapIfIdle(username);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const releaseUserBox = (username: string) => {
|
export const runningBoxUsernames = (): Set<string> => new Set(boxes.keys());
|
||||||
|
|
||||||
|
// Drop a box from the in-memory registry (used after an explicit stop so a
|
||||||
|
// stopped box isn't treated as "held" by the idle reaper or reconcile logic).
|
||||||
|
export const resetBox = (username: string): void => {
|
||||||
const box = boxes.get(username);
|
const box = boxes.get(username);
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
box.refs = Math.max(0, box.refs - 1);
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
if (box.refs > 0) return;
|
|
||||||
box.idleTimer = setTimeout(() => {
|
|
||||||
void stopWorkspaceContainer(userContainerName(username));
|
|
||||||
boxes.delete(username);
|
boxes.delete(username);
|
||||||
}, env.boxIdleMs);
|
};
|
||||||
|
|
||||||
|
export const _resetBoxRegistryForTests = () => {
|
||||||
|
for (const box of boxes.values()) {
|
||||||
|
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||||
|
}
|
||||||
|
boxes.clear();
|
||||||
|
locks.clear();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import { runExecInContainer } from './runtime/docker';
|
import { runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
const client = new ConvexHttpClient(env.convexUrl);
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
@@ -31,14 +32,21 @@ export const fetchUserEnvironment = async (
|
|||||||
|
|
||||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
// Keep a written path inside the home directory.
|
/**
|
||||||
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
* A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc.
|
||||||
const target = path.resolve(homeDir, relPath);
|
* Write a minimal `.bash_profile` (only if absent) so an interactive login
|
||||||
const root = path.resolve(homeDir);
|
* shell — the agent's, or the user's box terminal — loads their environment.
|
||||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
* Shared by `materializeUserHome` and the box terminal bridge.
|
||||||
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
*/
|
||||||
}
|
export const ensureBashProfile = async (homeDir: string): Promise<void> => {
|
||||||
return target;
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
const bashProfile = path.join(homeDir, '.bash_profile');
|
||||||
|
await readFile(bashProfile, 'utf8').catch(async () => {
|
||||||
|
await writeFile(
|
||||||
|
bashProfile,
|
||||||
|
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,16 +64,7 @@ export const materializeUserHome = async (args: {
|
|||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||||
await mkdir(homeDir, { recursive: true });
|
await ensureBashProfile(homeDir);
|
||||||
|
|
||||||
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
|
|
||||||
const bashProfile = path.join(homeDir, '.bash_profile');
|
|
||||||
await readFile(bashProfile, 'utf8').catch(async () => {
|
|
||||||
await writeFile(
|
|
||||||
bashProfile,
|
|
||||||
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userEnv.enabled) return;
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
@@ -111,7 +110,9 @@ export const materializeUserHome = async (args: {
|
|||||||
|
|
||||||
// Editable overlay tree (wins over the repo/setup output).
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
for (const file of userEnv.files) {
|
for (const file of userEnv.files) {
|
||||||
const target = safeHomeJoin(homeDir, file.path);
|
const target = await assertContainedRealPath(homeDir, file.path, {
|
||||||
|
forWrite: true,
|
||||||
|
});
|
||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
await writeFile(target, file.content);
|
await writeFile(target, file.content);
|
||||||
if (file.isExecutable) await chmod(target, 0o755);
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
|||||||
+335
-686
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
type TeardownStep = () => unknown;
|
||||||
|
|
||||||
|
export const teardownWorkspace = async (steps: {
|
||||||
|
stopHeartbeat: TeardownStep;
|
||||||
|
removeActive: TeardownStep;
|
||||||
|
appendEvent?: TeardownStep;
|
||||||
|
abortAgent?: TeardownStep;
|
||||||
|
removeMaterializedEnv?: TeardownStep;
|
||||||
|
markStopped?: TeardownStep;
|
||||||
|
closeAgent?: TeardownStep;
|
||||||
|
stopContainer?: TeardownStep;
|
||||||
|
release: TeardownStep;
|
||||||
|
}) => {
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const call = (step: TeardownStep | undefined) => {
|
||||||
|
if (!step) return Promise.resolve();
|
||||||
|
try {
|
||||||
|
return Promise.resolve(step()).catch((error: unknown) => {
|
||||||
|
errors.push(error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const immediate = [call(steps.stopHeartbeat), call(steps.removeActive)];
|
||||||
|
await Promise.all(immediate);
|
||||||
|
await call(steps.appendEvent);
|
||||||
|
await call(steps.abortAgent);
|
||||||
|
await call(steps.removeMaterializedEnv);
|
||||||
|
await call(steps.markStopped);
|
||||||
|
await call(steps.closeAgent);
|
||||||
|
await call(steps.stopContainer);
|
||||||
|
await call(steps.release);
|
||||||
|
return { errors };
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
normalizeClaudeJsonLine,
|
||||||
normalizeCodexJsonLine,
|
normalizeCodexJsonLine,
|
||||||
normalizeOpenCodeEvent,
|
normalizeOpenCodeEvent,
|
||||||
|
normalizeOpenCodeRunLine,
|
||||||
} from '../../src/agent-events';
|
} from '../../src/agent-events';
|
||||||
|
|
||||||
describe('agent event normalization', () => {
|
describe('agent event normalization', () => {
|
||||||
@@ -291,4 +293,110 @@ describe('agent event normalization', () => {
|
|||||||
externalMessageId: 'message-2',
|
externalMessageId: 'message-2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes opencode run --format json output lines', () => {
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeRunLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: { part: { text: 'hi' }, messageID: 'm1' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'hi',
|
||||||
|
externalMessageId: 'm1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeRunLine(
|
||||||
|
JSON.stringify({
|
||||||
|
sessionID: 'ses_abc',
|
||||||
|
parts: [{ text: 'final answer' }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ kind: 'session', sessionId: 'ses_abc' },
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'assistant_completed',
|
||||||
|
content: 'final answer',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalizeOpenCodeRunLine('not json at all')).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status: 'not json at all',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Claude Code stream-json output lines', () => {
|
||||||
|
expect(
|
||||||
|
normalizeClaudeJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'init',
|
||||||
|
session_id: 'abc',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'session', sessionId: 'abc' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeClaudeJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'hello ' },
|
||||||
|
{ type: 'text', text: 'world' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello world' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeClaudeJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Bash',
|
||||||
|
input: JSON.stringify({ command: 'ls' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeClaudeJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
result: 'final text',
|
||||||
|
session_id: 'abc',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed', content: 'final text' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeClaudeJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'error_max_turns',
|
||||||
|
result: 'hit the turn limit',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'error', message: 'hit the turn limit' });
|
||||||
|
|
||||||
|
expect(normalizeClaudeJsonLine('not json at all')).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status: 'not json at all',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import type { AgentRuntime } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { Claim } from '../../src/runtime/provider';
|
||||||
|
import { getAdapter, registerAdapter } from '../../src/runtime/agent-runtime';
|
||||||
|
import { isCodexLoginProfile, opencodeModel } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
describe('agent runtime registry', () => {
|
||||||
|
test('registerAdapter/getAdapter resolves a registered runtime', () => {
|
||||||
|
const fake: AgentRuntime = {
|
||||||
|
name: 'codex',
|
||||||
|
prepareAuth: () => Promise.resolve(),
|
||||||
|
runTurn: () => Promise.resolve({}),
|
||||||
|
abort: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
registerAdapter('codex', () => fake);
|
||||||
|
expect(getAdapter('codex').name).toBe('codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAdapter throws for an unregistered runtime', () => {
|
||||||
|
expect(() => getAdapter('opencode')).toThrow(/No agent runtime adapter/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shared provider helpers re-exported from runtime/provider', () => {
|
||||||
|
test('isCodexLoginProfile detects opencode login profiles', () => {
|
||||||
|
expect(
|
||||||
|
isCodexLoginProfile({
|
||||||
|
aiProviderProfile: { provider: 'opencode_openai_login' },
|
||||||
|
} as Claim),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isCodexLoginProfile({} as Claim)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opencodeModel prefixes the provider', () => {
|
||||||
|
expect(
|
||||||
|
opencodeModel({
|
||||||
|
aiProviderProfile: { provider: 'anthropic', model: 'claude-x' },
|
||||||
|
} as Claim),
|
||||||
|
).toBe('anthropic/claude-x');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
rm,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
// Point boxHomePaths('t').homeDir at a real tmp home, then import box fresh so
|
||||||
|
// env.workdir (captured at import) reflects the tmp workdir for this test.
|
||||||
|
const loadWithHome = async () => {
|
||||||
|
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-box-'));
|
||||||
|
tempDirs.push(workdir);
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
process.env.SPOON_AGENT_WORKDIR = workdir;
|
||||||
|
const box = await import('../../src/box');
|
||||||
|
const homeDir = box.boxHomePaths('t').homeDir;
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
return { box, homeDir, workdir };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('box file helpers', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeBoxFile then readBoxFile round-trips', async () => {
|
||||||
|
const { box } = await loadWithHome();
|
||||||
|
await expect(
|
||||||
|
box.writeBoxFile('t', 'notes/todo.txt', 'hello box'),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
await expect(box.readBoxFile('t', 'notes/todo.txt')).resolves.toBe(
|
||||||
|
'hello box',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readBoxFile rejects a lexical .. escape', async () => {
|
||||||
|
const { box } = await loadWithHome();
|
||||||
|
await expect(
|
||||||
|
box.readBoxFile('t', '../../etc/passwd'),
|
||||||
|
).rejects.toThrow(/outside home/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a symlink inside home that points outside on read and write', async () => {
|
||||||
|
const { box, homeDir, workdir } = await loadWithHome();
|
||||||
|
const outside = path.join(workdir, 'outside');
|
||||||
|
await mkdir(outside, { recursive: true });
|
||||||
|
await writeFile(path.join(outside, 'secret'), 'S');
|
||||||
|
await symlink(path.join(outside, 'secret'), path.join(homeDir, 'link'));
|
||||||
|
|
||||||
|
await expect(box.readBoxFile('t', 'link')).rejects.toThrow(/outside home/);
|
||||||
|
await expect(
|
||||||
|
box.writeBoxFile('t', 'link', 'nope'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listBoxTree includes a created file and excludes node_modules', async () => {
|
||||||
|
const { box, homeDir } = await loadWithHome();
|
||||||
|
await writeFile(path.join(homeDir, 'readme.md'), '# hi');
|
||||||
|
await mkdir(path.join(homeDir, 'node_modules', 'pkg'), { recursive: true });
|
||||||
|
await writeFile(path.join(homeDir, 'node_modules', 'pkg', 'x.js'), '1');
|
||||||
|
|
||||||
|
const tree = await box.listBoxTree('t');
|
||||||
|
expect(tree.name).toBe('~');
|
||||||
|
expect(tree.path).toBe('');
|
||||||
|
expect(tree.type).toBe('directory');
|
||||||
|
const names = (tree.children ?? []).map((child) => child.name);
|
||||||
|
expect(names).toContain('readme.md');
|
||||||
|
expect(names).not.toContain('node_modules');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
process.env.SPOON_AGENT_WORKDIR = '/work';
|
||||||
|
return await import('../../src/box');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('boxHomePaths', () => {
|
||||||
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
|
test('derives the per-user home dir and container home', async () => {
|
||||||
|
const { boxHomePaths } = await load();
|
||||||
|
const paths = boxHomePaths('gib');
|
||||||
|
expect(paths.homeDir.endsWith('homes/gib')).toBe(true);
|
||||||
|
expect(paths.containerHome).toBe('/home/gib');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct usernames produce distinct home dirs', async () => {
|
||||||
|
const { boxHomePaths } = await load();
|
||||||
|
expect(boxHomePaths('gib').homeDir).not.toBe(
|
||||||
|
boxHomePaths('other').homeDir,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const loadDocker = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/docker');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('in-box process-group marker + kill helpers', () => {
|
||||||
|
test('buildMarkedCommand wraps argv as a setsid bash session carrying the marker', async () => {
|
||||||
|
const { buildMarkedCommand } = await loadDocker();
|
||||||
|
const argv = buildMarkedCommand('spoon-turn-abc', [
|
||||||
|
'codex',
|
||||||
|
'exec',
|
||||||
|
'--json',
|
||||||
|
'hi',
|
||||||
|
]);
|
||||||
|
expect(argv[0]).toBe('setsid');
|
||||||
|
expect(argv[1]).toBe('bash');
|
||||||
|
expect(argv[2]).toBe('-lc');
|
||||||
|
const script = argv[3] ?? '';
|
||||||
|
expect(script).toContain('# spoon-turn-abc');
|
||||||
|
expect(script).toContain("'codex' 'exec' '--json' 'hi'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMarkedCommand keeps a trailing exit after the CLI so bash survives tail-exec while propagating the exit code (C1 guard)', async () => {
|
||||||
|
const { buildMarkedCommand } = await loadDocker();
|
||||||
|
const script = buildMarkedCommand('spoon-turn-abc', ['codex', 'run'])[3] ?? '';
|
||||||
|
// The CLI must NOT be the final simple command (bash would tail-exec it,
|
||||||
|
// replacing the marker-carrying bash). The exit code must still propagate.
|
||||||
|
expect(script).toContain("'codex' 'run'\nrc=$?\nexit \"$rc\"");
|
||||||
|
expect(script.split('\n').at(-1)).toBe('exit "$rc"');
|
||||||
|
expect(script.split('\n').at(-1)).not.toBe("'codex' 'run'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMarkedCommand rejects a marker that could break out of the comment', async () => {
|
||||||
|
const { buildMarkedCommand } = await loadDocker();
|
||||||
|
expect(() => buildMarkedCommand('bad\nmarker', ['codex'])).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildKillScript TERM-then-KILLs every group matching the marker', async () => {
|
||||||
|
const { buildKillScript } = await loadDocker();
|
||||||
|
const script = buildKillScript('spoon-turn-abc');
|
||||||
|
expect(script).toContain("pgrep -f 'spoon-turn-abc'");
|
||||||
|
expect(script).toContain('kill -TERM -"$pgid"');
|
||||||
|
expect(script).toContain('kill -KILL -"$pgid"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildKillScript excludes its own process group so it does not kill itself (C2 guard)', async () => {
|
||||||
|
const { buildKillScript } = await loadDocker();
|
||||||
|
const script = buildKillScript('spoon-turn-abc');
|
||||||
|
// Computes its own pgid and skips any match sharing it (the kill script's
|
||||||
|
// own bash + its command-substitution subshells all carry the marker).
|
||||||
|
expect(script).toContain('self_pgid=$(ps -o pgid= -p $$ | tr -d \' \')');
|
||||||
|
expect(script).toContain('[ "$pgid" = "$self_pgid" ] && continue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildKillScript escalates TERM -> KILL with a sleep 2 in between (order matters)', async () => {
|
||||||
|
const { buildKillScript } = await loadDocker();
|
||||||
|
const script = buildKillScript('spoon-turn-abc');
|
||||||
|
const termIdx = script.indexOf('kill -TERM -"$pgid"');
|
||||||
|
const sleepIdx = script.indexOf('\nsleep 2\n');
|
||||||
|
const killIdx = script.indexOf('kill -KILL -"$pgid"');
|
||||||
|
expect(termIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(sleepIdx).toBeGreaterThan(termIdx);
|
||||||
|
expect(killIdx).toBeGreaterThan(sleepIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildKillScript rejects a marker that could break out of the pgrep pattern', async () => {
|
||||||
|
const { buildKillScript } = await loadDocker();
|
||||||
|
expect(() => buildKillScript('bad\nmarker')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
process.env.SPOON_AGENT_WORKDIR = '/work';
|
||||||
|
return await import('../../src/server');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('boxRoute', () => {
|
||||||
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
|
test('matches the four box actions', async () => {
|
||||||
|
const { boxRoute } = await load();
|
||||||
|
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
|
||||||
|
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
|
||||||
|
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
|
||||||
|
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown, nested, and trailing paths', async () => {
|
||||||
|
const { boxRoute } = await load();
|
||||||
|
expect(boxRoute('/box/other')).toBeNull();
|
||||||
|
expect(boxRoute('/box/file/extra')).toBeNull();
|
||||||
|
expect(boxRoute('/box/status/')).toBeNull();
|
||||||
|
expect(boxRoute('/box')).toBeNull();
|
||||||
|
expect(boxRoute('/jobs/x/file')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseBoxTokenUsername,
|
||||||
|
verifyBoxTerminalToken,
|
||||||
|
} from '../../src/terminal-token';
|
||||||
|
|
||||||
|
const mintBox = (username: string, expiresAt: number, secret: string) => {
|
||||||
|
const payload = `${expiresAt}.box.${username}`;
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('verifyBoxTerminalToken', () => {
|
||||||
|
const secret = 'test-secret';
|
||||||
|
|
||||||
|
test('accepts a valid, unexpired, username-matched token', () => {
|
||||||
|
const token = mintBox('alice', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an expired token', () => {
|
||||||
|
const token = mintBox('alice', Date.now() - 1, secret);
|
||||||
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token minted for another username', () => {
|
||||||
|
const token = mintBox('alice', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyBoxTerminalToken(token, 'bob', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token signed with a different secret', () => {
|
||||||
|
const token = mintBox('alice', Date.now() + 60_000, 'other-secret');
|
||||||
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input and an empty secret', () => {
|
||||||
|
expect(verifyBoxTerminalToken('garbage', 'alice', secret)).toBe(false);
|
||||||
|
expect(verifyBoxTerminalToken('', 'alice', secret)).toBe(false);
|
||||||
|
expect(
|
||||||
|
verifyBoxTerminalToken(mintBox('alice', Date.now() + 1000, ''), 'alice', ''),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a 3-part job token (cross-scheme confusion guard)', () => {
|
||||||
|
const payload = `${Date.now() + 60_000}.alice`;
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
const jobToken = `${payload}.${sig}`;
|
||||||
|
expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBoxTokenUsername', () => {
|
||||||
|
test('extracts the username from a well-formed box token', () => {
|
||||||
|
const token = mintBox('alice', Date.now() + 60_000, 'test-secret');
|
||||||
|
expect(parseBoxTokenUsername(token)).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a 3-part job token', () => {
|
||||||
|
expect(parseBoxTokenUsername('123.alice.sig')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when the literal box segment is missing', () => {
|
||||||
|
expect(parseBoxTokenUsername('123.notbox.alice.sig')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for an empty username segment', () => {
|
||||||
|
expect(parseBoxTokenUsername('123.box..sig')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for garbage input', () => {
|
||||||
|
expect(parseBoxTokenUsername('garbage')).toBeNull();
|
||||||
|
expect(parseBoxTokenUsername('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
const loadAdapter = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/claude-adapter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = {
|
||||||
|
job: {
|
||||||
|
_id: 'job-1',
|
||||||
|
prompt: 'hi',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/x',
|
||||||
|
forkOwner: 'o',
|
||||||
|
forkRepo: 'r',
|
||||||
|
upstreamOwner: 'u',
|
||||||
|
upstreamRepo: 'r',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: { model: 'claude-sonnet-4', reasoningEffort: 'medium' },
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'Anthropic',
|
||||||
|
provider: 'anthropic',
|
||||||
|
authType: 'api_key',
|
||||||
|
secret: 'sk-ant-test',
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
} as unknown as Claim;
|
||||||
|
|
||||||
|
const makeWorkspace = (): AdapterWorkspace => ({
|
||||||
|
claim,
|
||||||
|
workdir: '/tmp/spoon-claude-adapter-test-missing',
|
||||||
|
homeDir: '/tmp/spoon-claude-adapter-test-missing',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeOAuthWorkspace = (
|
||||||
|
homeDir: string,
|
||||||
|
authType: 'anthropic_oauth_json' | 'opencode_auth_json',
|
||||||
|
): AdapterWorkspace => ({
|
||||||
|
claim: {
|
||||||
|
...claim,
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'Anthropic',
|
||||||
|
provider: 'anthropic',
|
||||||
|
authType,
|
||||||
|
secret: JSON.stringify({ access_token: 'oauth-token' }),
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
} as unknown as Claim,
|
||||||
|
workdir: homeDir,
|
||||||
|
homeDir,
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: path.join(homeDir, 'Code/spoon'),
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (tempDirs.length) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (dir) await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ClaudeCodeAdapter prepareAuth', () => {
|
||||||
|
test('writes credentials.json for an anthropic_oauth_json profile', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
|
||||||
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
|
const adapter = createClaudeAdapter();
|
||||||
|
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'));
|
||||||
|
|
||||||
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
|
const contents = await readFile(credentialsPath, 'utf8');
|
||||||
|
expect(contents).toContain('oauth-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not write credentials.json for an opencode_auth_json profile', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-'));
|
||||||
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
|
const adapter = createClaudeAdapter();
|
||||||
|
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json'));
|
||||||
|
|
||||||
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
|
await expect(stat(credentialsPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ClaudeCodeAdapter', () => {
|
||||||
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
const captured: unknown[] = [];
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
captured.push(args.command);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'init',
|
||||||
|
session_id: 'abc',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: { content: [{ type: 'text', text: 'hi there' }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
result: 'final text',
|
||||||
|
session_id: 'abc',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createClaudeAdapter({ execStream });
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({ kind: 'session', sessionId: 'abc' });
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'hi there',
|
||||||
|
});
|
||||||
|
expect(result.finalMessage).toBe('final text');
|
||||||
|
expect(result.sessionId).toBe('abc');
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
|
||||||
|
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
|
||||||
|
// `claude -p` argv in the script.
|
||||||
|
const command = captured[0] as string[];
|
||||||
|
expect(command[0]).toBe('setsid');
|
||||||
|
expect(command[1]).toBe('bash');
|
||||||
|
expect(command[3]).toContain("'claude' '-p'");
|
||||||
|
expect(command[3]).toContain("'--output-format' 'stream-json'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/worker');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('planWorkdirCleanup', () => {
|
||||||
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
|
test('never removes the homes/ root or any home directory', async () => {
|
||||||
|
const { planWorkdirCleanup } = await load();
|
||||||
|
const plan = planWorkdirCleanup({
|
||||||
|
root: '/work',
|
||||||
|
rootEntries: [{ name: 'homes', isDirectory: true }],
|
||||||
|
homesEntries: { alice: [{ name: 'Code', isDirectory: true }] },
|
||||||
|
codeLeaves: {},
|
||||||
|
activeWorkdirs: new Set(),
|
||||||
|
runningBoxUsernames: new Set(),
|
||||||
|
});
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/homes');
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/homes/alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes legacy top-level job dirs not in the active set', async () => {
|
||||||
|
const { planWorkdirCleanup } = await load();
|
||||||
|
const plan = planWorkdirCleanup({
|
||||||
|
root: '/work',
|
||||||
|
rootEntries: [
|
||||||
|
{ name: 'homes', isDirectory: true },
|
||||||
|
{ name: 'legacy-job-123', isDirectory: true },
|
||||||
|
{ name: 'dev', isDirectory: true },
|
||||||
|
],
|
||||||
|
homesEntries: {},
|
||||||
|
codeLeaves: {},
|
||||||
|
activeWorkdirs: new Set(['/work/dev']),
|
||||||
|
runningBoxUsernames: new Set(),
|
||||||
|
});
|
||||||
|
expect(plan.removeDirs).toContain('/work/legacy-job-123');
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/dev');
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/homes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes per-thread checkouts only for users with no running box', async () => {
|
||||||
|
const { planWorkdirCleanup } = await load();
|
||||||
|
const plan = planWorkdirCleanup({
|
||||||
|
root: '/work',
|
||||||
|
rootEntries: [{ name: 'homes', isDirectory: true }],
|
||||||
|
homesEntries: {
|
||||||
|
alice: [{ name: 'Code', isDirectory: true }],
|
||||||
|
bob: [{ name: 'Code', isDirectory: true }],
|
||||||
|
},
|
||||||
|
codeLeaves: {
|
||||||
|
alice: ['/work/homes/alice/Code/spoon-a/branch-x'],
|
||||||
|
bob: ['/work/homes/bob/Code/spoon-b/branch-y'],
|
||||||
|
},
|
||||||
|
activeWorkdirs: new Set(),
|
||||||
|
runningBoxUsernames: new Set(['bob']),
|
||||||
|
});
|
||||||
|
expect(plan.removeDirs).toContain(
|
||||||
|
'/work/homes/alice/Code/spoon-a/branch-x',
|
||||||
|
);
|
||||||
|
expect(plan.removeDirs).not.toContain(
|
||||||
|
'/work/homes/bob/Code/spoon-b/branch-y',
|
||||||
|
);
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/homes/alice');
|
||||||
|
expect(plan.removeDirs).not.toContain('/work/homes/alice/Code');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
const pathExists = async (filePath: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAdapter = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/codex-adapter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = {
|
||||||
|
job: {
|
||||||
|
_id: 'job-1',
|
||||||
|
prompt: 'hi',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/x',
|
||||||
|
forkOwner: 'o',
|
||||||
|
forkRepo: 'r',
|
||||||
|
upstreamOwner: 'u',
|
||||||
|
upstreamRepo: 'r',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
} as unknown as Claim;
|
||||||
|
|
||||||
|
const makeWorkspace = (): AdapterWorkspace => ({
|
||||||
|
claim,
|
||||||
|
// A non-existent workdir so the --output-last-message read is a clean ENOENT.
|
||||||
|
workdir: '/tmp/spoon-codex-adapter-test-missing',
|
||||||
|
homeDir: '/tmp/spoon-codex-adapter-test-missing',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/spoon-codex-adapter-test-missing/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeAuthWorkspace = async (
|
||||||
|
profile: Record<string, unknown>,
|
||||||
|
): Promise<AdapterWorkspace> => {
|
||||||
|
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-auth-'));
|
||||||
|
tempDirs.push(workdir);
|
||||||
|
const repoDir = path.join(workdir, 'Code', 'spoon');
|
||||||
|
await mkdir(repoDir, { recursive: true });
|
||||||
|
return {
|
||||||
|
...makeWorkspace(),
|
||||||
|
claim: { ...claim, aiProviderProfile: profile } as unknown as Claim,
|
||||||
|
workdir,
|
||||||
|
homeDir: workdir,
|
||||||
|
repoDir,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CodexAdapter', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepareAuth skips auth.json for an api_key profile', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const adapter = createCodexAdapter();
|
||||||
|
const workspace = await makeAuthWorkspace({
|
||||||
|
id: 'p',
|
||||||
|
name: 'OpenAI',
|
||||||
|
provider: 'openai',
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'gpt-5',
|
||||||
|
// A raw API key, which is deliberately NOT valid JSON.
|
||||||
|
secret: 'sk-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.prepareAuth?.(workspace)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
|
||||||
|
const openCodeAuthPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.local',
|
||||||
|
'share',
|
||||||
|
'opencode',
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
await expect(pathExists(codexAuthPath)).resolves.toBe(false);
|
||||||
|
await expect(pathExists(openCodeAuthPath)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepareAuth writes auth.json for a ChatGPT-login profile', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const adapter = createCodexAdapter();
|
||||||
|
const secret = JSON.stringify({ tokens: { access: 'abc' } });
|
||||||
|
const workspace = await makeAuthWorkspace({
|
||||||
|
id: 'p',
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5',
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await adapter.prepareAuth?.(workspace);
|
||||||
|
|
||||||
|
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
|
||||||
|
await expect(readFile(codexAuthPath, 'utf8')).resolves.toBe(
|
||||||
|
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
|
||||||
|
);
|
||||||
|
const openCodeAuthPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.local',
|
||||||
|
'share',
|
||||||
|
'opencode',
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
await expect(readFile(openCodeAuthPath, 'utf8')).resolves.toBe(
|
||||||
|
`${JSON.stringify(JSON.parse(secret), null, 2)}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const captured: unknown[] = [];
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
captured.push(args.command);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: { id: 'item-1', type: 'agent_message', text: 'done' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(JSON.stringify({ type: 'turn.completed' }));
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createCodexAdapter({ execStream });
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'done\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
|
});
|
||||||
|
expect(events).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
// The turn ran through buildMarkedCommand (setsid bash session).
|
||||||
|
const command = captured[0] as string[];
|
||||||
|
expect(command[0]).toBe('setsid');
|
||||||
|
expect(command[1]).toBe('bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surfaces a turn.failed error while exiting zero', async () => {
|
||||||
|
const { createCodexAdapter } = await loadAdapter();
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'boom' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createCodexAdapter({ execStream });
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', () =>
|
||||||
|
Promise.resolve(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toContain('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
abortManagedCodexProcess,
|
||||||
|
managedCodexCommand,
|
||||||
|
} from '../../src/codex-process';
|
||||||
|
|
||||||
|
describe('managed Codex process', () => {
|
||||||
|
test('launches Codex in a job-specific process session', () => {
|
||||||
|
const command = managedCodexCommand({
|
||||||
|
jobId: 'job-1',
|
||||||
|
pidFile: '/home/alice/.codex/job-1.pid',
|
||||||
|
command: ['codex', 'exec', '--json', 'fix it'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
|
||||||
|
expect(command).toContain('/home/alice/.codex/job-1.pid');
|
||||||
|
expect(command).toContain('job-1');
|
||||||
|
expect(command.slice(-4)).toEqual(['codex', 'exec', '--json', 'fix it']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invokes an isolated in-box kill for the matching job', async () => {
|
||||||
|
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
|
||||||
|
|
||||||
|
await abortManagedCodexProcess({
|
||||||
|
jobId: 'job-1',
|
||||||
|
pidFile: '/home/alice/.codex/job-1.pid',
|
||||||
|
execute,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(execute).toHaveBeenCalledOnce();
|
||||||
|
const [command] = execute.mock.calls[0] as [string[]];
|
||||||
|
expect(command.slice(0, 2)).toEqual(['python3', '-c']);
|
||||||
|
expect(command).toContain('/home/alice/.codex/job-1.pid');
|
||||||
|
expect(command).toContain('job-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createHeartbeatController } from '../../src/heartbeat-controller';
|
||||||
|
|
||||||
|
describe('heartbeat controller', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
test('does not overlap ticks while a heartbeat is in flight', async () => {
|
||||||
|
let resolveHeartbeat!: (value: { cancelRequested: boolean }) => void;
|
||||||
|
const heartbeat = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ cancelRequested: boolean }>((resolve) => {
|
||||||
|
resolveHeartbeat = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const controller = createHeartbeatController({
|
||||||
|
intervalMs: 30_000,
|
||||||
|
heartbeat,
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onError: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.start('job-1');
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(heartbeat).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
resolveHeartbeat({ cancelRequested: false });
|
||||||
|
await Promise.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(heartbeat).toHaveBeenCalledTimes(2);
|
||||||
|
controller.stop('job-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes the timer before awaiting cancellation teardown', async () => {
|
||||||
|
let finishCancel!: () => void;
|
||||||
|
const onCancel = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishCancel = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const heartbeat = vi.fn().mockResolvedValue({ cancelRequested: true });
|
||||||
|
const controller = createHeartbeatController({
|
||||||
|
intervalMs: 30_000,
|
||||||
|
heartbeat,
|
||||||
|
onCancel,
|
||||||
|
onError: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.start('job-1');
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
expect(controller.has('job-1')).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(heartbeat).toHaveBeenCalledOnce();
|
||||||
|
finishCancel();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { getFunctionName } from 'convex/server';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getWorktreeDiff: vi.fn(() =>
|
||||||
|
Promise.resolve({ exitCode: 0, output: 'unexpected diff' }),
|
||||||
|
),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
streamExecInContainer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('convex/browser', () => ({
|
||||||
|
ConvexHttpClient: vi.fn(
|
||||||
|
class ConvexHttpClient {
|
||||||
|
mutation = mocks.mutation;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/env', () => ({
|
||||||
|
env: {
|
||||||
|
buildCreatedAt: 'test',
|
||||||
|
buildSha: 'test',
|
||||||
|
convexUrl: 'http://convex.test',
|
||||||
|
jobTimeoutMs: 1000,
|
||||||
|
runtime: 'docker',
|
||||||
|
workerId: 'worker-1',
|
||||||
|
workerToken: 'token',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/git', () => ({
|
||||||
|
cloneRepository: vi.fn(),
|
||||||
|
commitAndPush: vi.fn(),
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
getWorktreeDiff: mocks.getWorktreeDiff,
|
||||||
|
run: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/github', () => ({
|
||||||
|
getInstallationToken: vi.fn(),
|
||||||
|
openDraftPullRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/runtime/docker', () => ({
|
||||||
|
buildMarkedCommand: vi.fn(() => ['setsid', 'bash', '-lc', '# marker']),
|
||||||
|
killBoxProcessesByMarker: vi.fn(() => Promise.resolve()),
|
||||||
|
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
||||||
|
runExecInContainer: vi.fn(),
|
||||||
|
streamExecInContainer: mocks.streamExecInContainer,
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/user-container', () => ({
|
||||||
|
acquireUserBox: vi.fn(),
|
||||||
|
runningBoxUsernames: vi.fn(() => new Set()),
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/user-environment', () => ({
|
||||||
|
fetchUserEnvironment: vi.fn(),
|
||||||
|
materializeUserHome: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const decision = {
|
||||||
|
decision: 'sync',
|
||||||
|
risk: 'low',
|
||||||
|
summary: 'Safe to sync.',
|
||||||
|
ignoredCommitShas: [],
|
||||||
|
ignoredReason: '',
|
||||||
|
recommendedAction: 'Sync upstream.',
|
||||||
|
requiresUserApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => await import('../../src/worker');
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const worker = await load();
|
||||||
|
worker._resetActiveWorkspacesForTests();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maintenance workspace cleanup', () => {
|
||||||
|
test('releases and removes the workspace when status teardown fails', async () => {
|
||||||
|
const worker = await load();
|
||||||
|
const release = vi.fn();
|
||||||
|
const jobId = 'maintenance-mark';
|
||||||
|
mocks.mutation.mockImplementation((reference) => {
|
||||||
|
if (getFunctionName(reference) === 'agentJobs:markWorkspaceStopped') {
|
||||||
|
return Promise.reject(new Error('status teardown failed'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('message-1');
|
||||||
|
});
|
||||||
|
mocks.streamExecInContainer.mockImplementation(async (args) => {
|
||||||
|
await args.onStdoutLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
type: 'agent_message',
|
||||||
|
text: JSON.stringify(decision),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
});
|
||||||
|
worker._setActiveWorkspaceForTests(jobId, {
|
||||||
|
claim: {
|
||||||
|
job: {
|
||||||
|
_id: jobId as Id<'agentJobs'>,
|
||||||
|
prompt: 'Review maintenance changes',
|
||||||
|
jobType: 'maintenance_review',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/maintenance',
|
||||||
|
forkOwner: 'team',
|
||||||
|
forkRepo: 'spoon',
|
||||||
|
upstreamOwner: 'upstream',
|
||||||
|
upstreamRepo: 'spoon',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: {
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
},
|
||||||
|
workdir: '/tmp/workspace',
|
||||||
|
homeDir: '/tmp/workspace',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/workspace/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
boxHandle: { boxName: 'spoon-box-tester', release },
|
||||||
|
githubToken: 'github-token',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
const consoleError = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
worker.sendWorkspaceMessage(jobId, 'Review maintenance changes'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(release).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.getWorktreeDiff).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mocks.mutation.mock.calls.some(([, args]) => args.kind === 'diff'),
|
||||||
|
).toBe(false);
|
||||||
|
await expect(worker.stopWorkspace(jobId)).rejects.toThrow(
|
||||||
|
'Agent workspace is not active on this worker.',
|
||||||
|
);
|
||||||
|
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
removeMaterializedEnv,
|
||||||
|
writeMaterializedEnv,
|
||||||
|
} from '../../src/materialize-env';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
|
||||||
|
|
||||||
|
const exists = async (filePath: string) =>
|
||||||
|
access(filePath).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('materialize env', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes the env file 0600 at the returned path with quoted values', async () => {
|
||||||
|
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
|
||||||
|
tempDirs.push(repoDir);
|
||||||
|
|
||||||
|
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
|
||||||
|
{ name: 'A', value: 'x' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(exists(envPath)).resolves.toBe(true);
|
||||||
|
await expect(mode(envPath)).resolves.toBe(0o600);
|
||||||
|
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tightens perms to 0600 and overwrites content when the env file pre-exists 0644', async () => {
|
||||||
|
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
|
||||||
|
tempDirs.push(repoDir);
|
||||||
|
|
||||||
|
const preExisting = path.join(repoDir, '.env.local');
|
||||||
|
// Pre-create the target with loose perms. umask may mask the writeFile
|
||||||
|
// mode, so chmod explicitly and assert the precondition really is 0644.
|
||||||
|
await writeFile(preExisting, 'OLD', { mode: 0o644 });
|
||||||
|
await chmod(preExisting, 0o644);
|
||||||
|
await expect(mode(preExisting)).resolves.toBe(0o644);
|
||||||
|
|
||||||
|
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
|
||||||
|
{ name: 'A', value: 'x' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Overwrote the plaintext content AND tightened perms unconditionally.
|
||||||
|
await expect(mode(envPath)).resolves.toBe(0o600);
|
||||||
|
await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeMaterializedEnv deletes an existing file and no-ops when absent', async () => {
|
||||||
|
const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-'));
|
||||||
|
tempDirs.push(repoDir);
|
||||||
|
|
||||||
|
const envPath = await writeMaterializedEnv(repoDir, '.env.local', [
|
||||||
|
{ name: 'A', value: 'x' },
|
||||||
|
]);
|
||||||
|
await expect(exists(envPath)).resolves.toBe(true);
|
||||||
|
|
||||||
|
await removeMaterializedEnv(envPath);
|
||||||
|
await expect(exists(envPath)).resolves.toBe(false);
|
||||||
|
|
||||||
|
// No-ops when the file is already gone or the path is undefined.
|
||||||
|
await expect(removeMaterializedEnv(envPath)).resolves.toBeUndefined();
|
||||||
|
await expect(removeMaterializedEnv(undefined)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
const loadAdapter = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/opencode-adapter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = {
|
||||||
|
job: {
|
||||||
|
_id: 'job-1',
|
||||||
|
prompt: 'hi',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/x',
|
||||||
|
forkOwner: 'o',
|
||||||
|
forkRepo: 'r',
|
||||||
|
upstreamOwner: 'u',
|
||||||
|
upstreamRepo: 'r',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'OpenAI',
|
||||||
|
provider: 'openai',
|
||||||
|
authType: 'api_key',
|
||||||
|
secret: 'sk-test',
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
} as unknown as Claim;
|
||||||
|
|
||||||
|
const makeWorkspace = (): AdapterWorkspace => ({
|
||||||
|
claim,
|
||||||
|
workdir: '/tmp/spoon-opencode-adapter-test-missing',
|
||||||
|
homeDir: '/tmp/spoon-opencode-adapter-test-missing',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/spoon-opencode-adapter-test-missing/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'opencode',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenCodeAdapter', () => {
|
||||||
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
|
const { createOpenCodeAdapter } = await loadAdapter();
|
||||||
|
const captured: unknown[] = [];
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
captured.push(args.command);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: { part: { text: 'hi' }, messageID: 'm1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
sessionID: 'ses_abc',
|
||||||
|
parts: [{ text: 'final answer' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createOpenCodeAdapter({ execStream });
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'hi',
|
||||||
|
externalMessageId: 'm1',
|
||||||
|
});
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(result.sessionId).toBe('ses_abc');
|
||||||
|
expect(result.finalMessage).toBe('final answer');
|
||||||
|
|
||||||
|
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
|
||||||
|
// opencode run argv in the script.
|
||||||
|
const command = captured[0] as string[];
|
||||||
|
expect(command[0]).toBe('setsid');
|
||||||
|
expect(command[1]).toBe('bash');
|
||||||
|
expect(command[3]).toContain("'opencode' 'run' '--format' 'json'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { assertContainedRealPath } from '../../src/path-containment';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (dir) await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertContainedRealPath', () => {
|
||||||
|
test('allows a normal file inside the root', async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
|
||||||
|
const root = path.join(dir, 'repo');
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(path.join(root, 'a.txt'), 'hi');
|
||||||
|
await expect(assertContainedRealPath(root, 'a.txt')).resolves.toBe(
|
||||||
|
path.join(root, 'a.txt'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a lexical .. escape', async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
|
||||||
|
const root = path.join(dir, 'repo');
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await expect(assertContainedRealPath(root, '../secret')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects reading through a symlink that escapes the root', async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
|
||||||
|
const root = path.join(dir, 'repo');
|
||||||
|
const outside = path.join(dir, 'outside');
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await mkdir(outside, { recursive: true });
|
||||||
|
await writeFile(path.join(outside, 'secret'), 'S');
|
||||||
|
await symlink(outside, path.join(root, 'link'));
|
||||||
|
await expect(
|
||||||
|
assertContainedRealPath(root, 'link/secret'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a write whose final target is a symlink', async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), 'spoon-'));
|
||||||
|
const root = path.join(dir, 'repo');
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await symlink(path.join(dir, 'evil'), path.join(root, 'out'));
|
||||||
|
await expect(
|
||||||
|
assertContainedRealPath(root, 'out', { forWrite: true }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { createRedactor } from '../../src/redact';
|
||||||
|
|
||||||
|
describe('createRedactor', () => {
|
||||||
|
test('redacts group-less token patterns without emitting a literal $1', () => {
|
||||||
|
const redact = createRedactor([]);
|
||||||
|
const output = redact('token ghs_ABC123 and sk-xyz789 here');
|
||||||
|
|
||||||
|
expect(output).toContain('[redacted]');
|
||||||
|
expect(output).not.toContain('$1');
|
||||||
|
expect(output).not.toContain('ghs_ABC123');
|
||||||
|
expect(output).not.toContain('sk-xyz789');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts github_pat_ tokens to [redacted]', () => {
|
||||||
|
const redact = createRedactor([]);
|
||||||
|
const output = redact('github_pat_ABCDEF123456');
|
||||||
|
|
||||||
|
expect(output).toBe('[redacted]');
|
||||||
|
expect(output).not.toContain('$1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the key= prefix for key=value secrets', () => {
|
||||||
|
const redact = createRedactor([]);
|
||||||
|
|
||||||
|
expect(redact('api_key=secret')).toBe('api_key=[redacted]');
|
||||||
|
expect(redact('password=hunter2')).toBe('password=[redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts caller-supplied secret values verbatim', () => {
|
||||||
|
const redact = createRedactor(['super-secret-value']);
|
||||||
|
|
||||||
|
expect(redact('the value is super-secret-value')).toBe(
|
||||||
|
'the value is [redacted]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';
|
||||||
|
vi.doMock('../../src/worker', () => ({ getTerminalWorkspace: () => null }));
|
||||||
|
vi.doMock('../../src/user-container', () => ({
|
||||||
|
acquireUserBox: vi.fn(),
|
||||||
|
}));
|
||||||
|
return await import('../../src/terminal');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseResizeMessage', () => {
|
||||||
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
|
test('parses and clamps a resize control frame', async () => {
|
||||||
|
const { parseResizeMessage } = await load();
|
||||||
|
const msg = Buffer.from(
|
||||||
|
JSON.stringify({ type: 'resize', cols: 120, rows: 40 }),
|
||||||
|
);
|
||||||
|
expect(parseResizeMessage(msg, false)).toEqual({ cols: 120, rows: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for binary input (raw keystrokes)', async () => {
|
||||||
|
const { parseResizeMessage } = await load();
|
||||||
|
expect(parseResizeMessage(Buffer.from([1, 2, 3]), true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for non-resize JSON', async () => {
|
||||||
|
const { parseResizeMessage } = await load();
|
||||||
|
expect(
|
||||||
|
parseResizeMessage(Buffer.from('{"type":"other"}'), false),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps out-of-range dimensions into [1,1000]', async () => {
|
||||||
|
const { parseResizeMessage } = await load();
|
||||||
|
const msg = Buffer.from(
|
||||||
|
JSON.stringify({ type: 'resize', cols: 99999, rows: 0 }),
|
||||||
|
);
|
||||||
|
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveTurnOutcome,
|
||||||
|
shouldAppendCompletedContent,
|
||||||
|
} from '../../src/runtime/turn-outcome';
|
||||||
|
|
||||||
|
describe('shouldAppendCompletedContent', () => {
|
||||||
|
test('does not re-append when deltas already streamed the answer', () => {
|
||||||
|
// Claude/OpenCode emit the final answer as assistant_delta AND as the
|
||||||
|
// content-bearing assistant_completed event. Folding the completed
|
||||||
|
// content in again would produce "<answer><answer>".
|
||||||
|
expect(shouldAppendCompletedContent('the answer', 'the answer')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('appends completed content when nothing has streamed yet', () => {
|
||||||
|
expect(shouldAppendCompletedContent('', 'the final result')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats whitespace-only streamed text as empty', () => {
|
||||||
|
expect(shouldAppendCompletedContent(' \n', 'the final result')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not append when the completed event carries no content', () => {
|
||||||
|
expect(shouldAppendCompletedContent('the answer', undefined)).toBe(false);
|
||||||
|
expect(shouldAppendCompletedContent('', undefined)).toBe(false);
|
||||||
|
expect(shouldAppendCompletedContent('', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTurnOutcome', () => {
|
||||||
|
test('partial streamed text + error is surfaced as failed and text preserved', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: 'partial answer before crash',
|
||||||
|
error: 'exit code 1: rate limited',
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
expect(outcome.text).toBe('partial answer before crash');
|
||||||
|
expect(outcome.failure).toBe('codex failed:\nexit code 1: rate limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful turn with streamed text and no error has no failure', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: 'complete answer',
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
expect(outcome.text).toBe('complete answer');
|
||||||
|
expect(outcome.failure).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty turn with error is surfaced as failed', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: ' ',
|
||||||
|
error: 'exit code 137',
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
expect(outcome.failure).toBe('claude failed:\nexit code 137');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty turn with no error reports the no-response failure', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: '',
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
expect(outcome.failure).toBe(
|
||||||
|
'Codex completed without producing an assistant response.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers finalMessage text when nothing streamed and no error', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: '',
|
||||||
|
recoveredText: 'recovered from last-message file',
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
expect(outcome.text).toBe('recovered from last-message file');
|
||||||
|
expect(outcome.failure).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovered finalMessage text + error is still surfaced as failed', () => {
|
||||||
|
const outcome = resolveTurnOutcome({
|
||||||
|
assistantText: '',
|
||||||
|
recoveredText: 'recovered partial',
|
||||||
|
error: 'network drop',
|
||||||
|
runtime: 'codex',
|
||||||
|
});
|
||||||
|
expect(outcome.text).toBe('recovered partial');
|
||||||
|
expect(outcome.failure).toBe('codex failed:\nnetwork drop');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../src/env', () => ({ env: { boxIdleMs: 1000 } }));
|
||||||
|
vi.mock('../../src/runtime/docker', () => ({
|
||||||
|
ensureUserContainer: vi.fn((args: { username: string }) =>
|
||||||
|
Promise.resolve(`spoon-box-${args.username}`),
|
||||||
|
),
|
||||||
|
stopWorkspaceContainer: vi.fn(() => Promise.resolve()),
|
||||||
|
userContainerName: (username: string) => `spoon-box-${username}`,
|
||||||
|
listWorkspaceContainerNames: vi.fn(() => Promise.resolve([])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const load = async () => await import('../../src/user-container');
|
||||||
|
|
||||||
|
describe('box registry', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(async () => {
|
||||||
|
const module = await load();
|
||||||
|
module._resetBoxRegistryForTests();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializes concurrent acquire into a single docker run', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
module.acquireUserBox({
|
||||||
|
username: 'alice',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/alice',
|
||||||
|
}),
|
||||||
|
module.acquireUserBox({
|
||||||
|
username: 'alice',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/alice',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(a.boxName).toBe('spoon-box-alice');
|
||||||
|
a.release();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
|
||||||
|
b.release();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double-release of one handle does not reap a box held elsewhere', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
const a = await module.acquireUserBox({
|
||||||
|
username: 'bob',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/bob',
|
||||||
|
});
|
||||||
|
const b = await module.acquireUserBox({
|
||||||
|
username: 'bob',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/bob',
|
||||||
|
});
|
||||||
|
a.release();
|
||||||
|
a.release();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(docker.stopWorkspaceContainer).not.toHaveBeenCalled();
|
||||||
|
b.release();
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcileExistingBoxes adopts orphans with zero refs and reaps them', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
(
|
||||||
|
docker.listWorkspaceContainerNames as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValueOnce(['spoon-box-carol']);
|
||||||
|
await module.reconcileExistingBoxes();
|
||||||
|
expect(module.runningBoxUsernames().has('carol')).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(docker.stopWorkspaceContainer).toHaveBeenCalledWith(
|
||||||
|
'spoon-box-carol',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensures an adopted box once before returning acquire handles', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
|
||||||
|
'spoon-box-casey',
|
||||||
|
]);
|
||||||
|
await module.reconcileExistingBoxes();
|
||||||
|
|
||||||
|
const first = await module.acquireUserBox({
|
||||||
|
username: 'casey',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/casey',
|
||||||
|
});
|
||||||
|
const second = await module.acquireUserBox({
|
||||||
|
username: 'casey',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/casey',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(first.boxName).toBe('spoon-box-casey');
|
||||||
|
expect(second.boxName).toBe('spoon-box-casey');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries ensure after the first acquire fails', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
const ensure = vi.mocked(docker.ensureUserContainer);
|
||||||
|
ensure
|
||||||
|
.mockRejectedValueOnce(new Error('docker failed'))
|
||||||
|
.mockResolvedValueOnce('spoon-box-dana');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
module.acquireUserBox({
|
||||||
|
username: 'dana',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/dana',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('docker failed');
|
||||||
|
|
||||||
|
const handle = await module.acquireUserBox({
|
||||||
|
username: 'dana',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/dana',
|
||||||
|
});
|
||||||
|
expect(ensure).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handle.boxName).toBe('spoon-box-dana');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishes an old idle reap before a new acquire can ensure the box', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
const ensure = vi.mocked(docker.ensureUserContainer);
|
||||||
|
const stop = vi.mocked(docker.stopWorkspaceContainer);
|
||||||
|
const events: string[] = [];
|
||||||
|
let finishStop!: () => void;
|
||||||
|
const stopDone = new Promise<void>((resolve) => {
|
||||||
|
finishStop = resolve;
|
||||||
|
});
|
||||||
|
ensure.mockReset().mockImplementation((args: { username: string }) => {
|
||||||
|
events.push('ensure');
|
||||||
|
return Promise.resolve(`spoon-box-${args.username}`);
|
||||||
|
});
|
||||||
|
stop.mockReset().mockImplementationOnce(() => {
|
||||||
|
events.push('stop:start');
|
||||||
|
return stopDone.then(() => {
|
||||||
|
events.push('stop:end');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await module.acquireUserBox({
|
||||||
|
username: 'erin',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/erin',
|
||||||
|
});
|
||||||
|
first.release();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
const secondPromise = module.acquireUserBox({
|
||||||
|
username: 'erin',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/erin',
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events).toEqual(['ensure', 'stop:start']);
|
||||||
|
|
||||||
|
finishStop();
|
||||||
|
const second = await secondPromise;
|
||||||
|
expect(second.boxName).toBe('spoon-box-erin');
|
||||||
|
expect(events).toEqual(['ensure', 'stop:start', 'stop:end', 'ensure']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { abortManagedCodexProcess } from '../../src/codex-process';
|
||||||
|
import { teardownWorkspace } from '../../src/workspace-teardown';
|
||||||
|
|
||||||
|
describe('workspace teardown', () => {
|
||||||
|
test('event failure does not skip abort, stop, removal, or release', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const record = (name: string) => vi.fn(() => calls.push(name));
|
||||||
|
|
||||||
|
const result = await teardownWorkspace({
|
||||||
|
stopHeartbeat: record('heartbeat'),
|
||||||
|
removeActive: record('remove'),
|
||||||
|
appendEvent: vi.fn().mockRejectedValue(new Error('event failed')),
|
||||||
|
abortAgent: record('abort'),
|
||||||
|
markStopped: vi.fn().mockRejectedValue(new Error('mutation failed')),
|
||||||
|
closeAgent: record('close'),
|
||||||
|
stopContainer: vi.fn().mockRejectedValue(new Error('container failed')),
|
||||||
|
release: record('release'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual(['heartbeat', 'remove', 'abort', 'close', 'release']);
|
||||||
|
expect(result.errors).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes active state before asynchronous cancellation work', async () => {
|
||||||
|
const active = new Set(['job-1']);
|
||||||
|
let activeDuringAbort = true;
|
||||||
|
|
||||||
|
await teardownWorkspace({
|
||||||
|
stopHeartbeat: vi.fn(),
|
||||||
|
removeActive: () => active.delete('job-1'),
|
||||||
|
abortAgent: () => {
|
||||||
|
activeDuringAbort = active.has('job-1');
|
||||||
|
},
|
||||||
|
release: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activeDuringAbort).toBe(false);
|
||||||
|
expect(active.has('job-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex cancellation invokes isolated kill and leaves no active workspace', async () => {
|
||||||
|
const active = new Set(['job-1']);
|
||||||
|
const execute = vi.fn().mockResolvedValue({ exitCode: 0, output: '' });
|
||||||
|
|
||||||
|
await teardownWorkspace({
|
||||||
|
stopHeartbeat: vi.fn(),
|
||||||
|
removeActive: () => active.delete('job-1'),
|
||||||
|
abortAgent: async () =>
|
||||||
|
await abortManagedCodexProcess({
|
||||||
|
jobId: 'job-1',
|
||||||
|
pidFile: '/home/alice/.codex/job-1.pid',
|
||||||
|
execute,
|
||||||
|
}),
|
||||||
|
release: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(execute).toHaveBeenCalledOnce();
|
||||||
|
expect(active.has('job-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const AgentsRedirectPage = () => {
|
|
||||||
redirect('/threads?source=user_request');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AgentsRedirectPage;
|
|
||||||
@@ -3,27 +3,38 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||||
|
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||||
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Skeleton,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
|
||||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
const threads = useQuery(api.threads.listMine, { limit: 25 });
|
||||||
const activeSpoons = spoons.filter(
|
const spoonList = spoons ?? [];
|
||||||
|
const threadList = threads ?? [];
|
||||||
|
const metricsLoading = spoons === undefined || threads === undefined;
|
||||||
|
const activeSpoons = spoonList.filter(
|
||||||
(spoon) => spoon.status === 'active',
|
(spoon) => spoon.status === 'active',
|
||||||
).length;
|
).length;
|
||||||
const behind = spoons.filter(
|
const behind = spoonList.filter(
|
||||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||||
).length;
|
).length;
|
||||||
const diverged = spoons.filter(
|
const diverged = spoonList.filter(
|
||||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||||
).length;
|
).length;
|
||||||
const openPullRequests = spoons.reduce(
|
const openPullRequests = spoonList.reduce(
|
||||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -43,10 +54,22 @@ const DashboardPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{metricsLoading ? (
|
||||||
|
<div
|
||||||
|
role='status'
|
||||||
|
aria-label='Loading'
|
||||||
|
className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'
|
||||||
|
>
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className='h-32 w-full' />
|
||||||
|
))}
|
||||||
|
<span className='sr-only'>Loading</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label='Spoons'
|
label='Spoons'
|
||||||
value={spoons.length}
|
value={spoonList.length}
|
||||||
note={`${activeSpoons} active`}
|
note={`${activeSpoons} active`}
|
||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
/>
|
/>
|
||||||
@@ -59,7 +82,7 @@ const DashboardPage = () => {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label='Open threads'
|
label='Open threads'
|
||||||
value={
|
value={
|
||||||
threads.filter(
|
threadList.filter(
|
||||||
(thread) =>
|
(thread) =>
|
||||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
thread.status,
|
thread.status,
|
||||||
@@ -76,16 +99,23 @@ const DashboardPage = () => {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className='space-y-3'>
|
<section className='space-y-3'>
|
||||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||||
|
{threads === undefined ? (
|
||||||
|
<ListSkeleton rows={2} />
|
||||||
|
) : (
|
||||||
<MaintenanceQueue threads={threads} />
|
<MaintenanceQueue threads={threads} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-2'>
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
<section className='space-y-3'>
|
<section className='space-y-3'>
|
||||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||||
{spoons.length ? (
|
{spoons === undefined ? (
|
||||||
|
<ListSkeleton rows={3} />
|
||||||
|
) : spoons.length ? (
|
||||||
spoons
|
spoons
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
||||||
@@ -108,7 +138,9 @@ const DashboardPage = () => {
|
|||||||
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{syncRuns.length ? (
|
{syncRuns === undefined ? (
|
||||||
|
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||||
|
) : syncRuns.length ? (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{syncRuns.map((run) => (
|
{syncRuns.map((run) => (
|
||||||
<div
|
<div
|
||||||
@@ -137,7 +169,9 @@ const DashboardPage = () => {
|
|||||||
<CardTitle className='text-base'>Recent threads</CardTitle>
|
<CardTitle className='text-base'>Recent threads</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{threads.length ? (
|
{threads === undefined ? (
|
||||||
|
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||||
|
) : threads.length ? (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{threads.slice(0, 5).map((thread) => (
|
{threads.slice(0, 5).map((thread) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
const AppError = ({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
|
||||||
|
<h1 className='text-2xl font-semibold'>Something went wrong</h1>
|
||||||
|
<p className='text-muted-foreground max-w-md text-sm'>
|
||||||
|
This page hit an unexpected error. It has been reported.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => reset()}>Try again</Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppError;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { MachineShell } from '@/components/machine/machine-shell';
|
||||||
|
|
||||||
|
const MachinePage = () => <MachineShell />;
|
||||||
|
|
||||||
|
export default MachinePage;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
const NotFound = () => (
|
||||||
|
<main className='flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center'>
|
||||||
|
<h1 className='text-2xl font-semibold'>Not found</h1>
|
||||||
|
<p className='text-muted-foreground max-w-md text-sm'>
|
||||||
|
This item does not exist, or you do not have access to it.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href='/dashboard'>Back to dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const AiSettingsPage = () => redirect('/settings/ai-providers');
|
|
||||||
|
|
||||||
export default AiSettingsPage;
|
|
||||||
@@ -3,7 +3,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
import {
|
||||||
|
Bell,
|
||||||
|
Brain,
|
||||||
|
FileCog,
|
||||||
|
Github,
|
||||||
|
ServerCog,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@spoon/ui';
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
@@ -12,6 +20,7 @@ const settingsItems = [
|
|||||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||||
|
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
|
||||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
|
||||||
|
|
||||||
|
const SettingsNotificationsPage = () => {
|
||||||
|
return (
|
||||||
|
<section className='max-w-3xl space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>Notifications</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Choose which notifications also reach you by email. In-app
|
||||||
|
notifications always show in the bell; these toggles only control the
|
||||||
|
email copies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NotificationPreferencesPanel />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsNotificationsPage;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AvatarUpload,
|
AvatarUpload,
|
||||||
ProfileHeader,
|
ProfileHeader,
|
||||||
|
|||||||
@@ -21,7 +21,24 @@ const AgentWorkspacePage = () => {
|
|||||||
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
|
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
|
||||||
}, [job?.threadId, router]);
|
}, [job?.threadId, router]);
|
||||||
|
|
||||||
if (job?.threadId) {
|
if (job === undefined) {
|
||||||
|
return (
|
||||||
|
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job === null) {
|
||||||
|
return (
|
||||||
|
<main className='space-y-4 p-6'>
|
||||||
|
<p className='text-muted-foreground'>This workspace was not found.</p>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href={`/spoons/${params.spoonId}`}>Back to Spoon</Link>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.threadId) {
|
||||||
return (
|
return (
|
||||||
<main className='text-muted-foreground p-6'>
|
<main className='text-muted-foreground p-6'>
|
||||||
Opening thread workspace...
|
Opening thread workspace...
|
||||||
@@ -37,7 +54,7 @@ const AgentWorkspacePage = () => {
|
|||||||
Back to Spoon
|
Back to Spoon
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AgentWorkspaceShell jobId={jobId} />
|
<AgentWorkspaceShell key={jobId} jobId={jobId} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||||
|
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Skeleton,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -31,16 +32,19 @@ const formatDate = (value?: number) =>
|
|||||||
: 'Never';
|
: 'Never';
|
||||||
|
|
||||||
const SpoonsPage = () => {
|
const SpoonsPage = () => {
|
||||||
const router = useRouter();
|
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
const threads = useQuery(api.threads.listMine, { limit: 100 });
|
||||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
const spoonList = spoons ?? [];
|
||||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
const threadList = threads ?? [];
|
||||||
const needsReview = threads.filter(
|
const spoonsLoading = spoons === undefined;
|
||||||
|
const metricsLoading = spoons === undefined || threads === undefined;
|
||||||
|
const active = spoonList.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const needsReview = threadList.filter(
|
||||||
(thread) =>
|
(thread) =>
|
||||||
thread.spoonId &&
|
thread.spoonId &&
|
||||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||||
).length;
|
).length;
|
||||||
const upstreamWaiting = spoons.reduce(
|
const upstreamWaiting = spoonList.reduce(
|
||||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -65,7 +69,11 @@ const SpoonsPage = () => {
|
|||||||
<CardContent className='flex items-center justify-between p-4'>
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground text-sm'>Managed</p>
|
<p className='text-muted-foreground text-sm'>Managed</p>
|
||||||
<p className='text-2xl font-semibold'>{spoons.length}</p>
|
{spoonsLoading ? (
|
||||||
|
<Skeleton className='mt-1 h-8 w-10' />
|
||||||
|
) : (
|
||||||
|
<p className='text-2xl font-semibold'>{spoonList.length}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<GitBranch className='text-muted-foreground size-5' />
|
<GitBranch className='text-muted-foreground size-5' />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -74,7 +82,11 @@ const SpoonsPage = () => {
|
|||||||
<CardContent className='flex items-center justify-between p-4'>
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground text-sm'>Active</p>
|
<p className='text-muted-foreground text-sm'>Active</p>
|
||||||
|
{spoonsLoading ? (
|
||||||
|
<Skeleton className='mt-1 h-8 w-10' />
|
||||||
|
) : (
|
||||||
<p className='text-2xl font-semibold'>{active}</p>
|
<p className='text-2xl font-semibold'>{active}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RefreshCw className='text-muted-foreground size-5' />
|
<RefreshCw className='text-muted-foreground size-5' />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -83,14 +95,20 @@ const SpoonsPage = () => {
|
|||||||
<CardContent className='flex items-center justify-between p-4'>
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground text-sm'>Open threads</p>
|
<p className='text-muted-foreground text-sm'>Open threads</p>
|
||||||
|
{metricsLoading ? (
|
||||||
|
<Skeleton className='mt-1 h-8 w-10' />
|
||||||
|
) : (
|
||||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<MessageSquare className='text-muted-foreground size-5' />
|
<MessageSquare className='text-muted-foreground size-5' />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{spoons.length ? (
|
{spoons === undefined ? (
|
||||||
|
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
|
||||||
|
) : spoons.length ? (
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardContent className='p-0'>
|
<CardContent className='p-0'>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -111,22 +129,12 @@ const SpoonsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={spoon._id}
|
key={spoon._id}
|
||||||
role='link'
|
className='hover:bg-muted/50 relative cursor-pointer'
|
||||||
tabIndex={0}
|
|
||||||
className='hover:bg-muted/50 cursor-pointer'
|
|
||||||
onClick={() => router.push(href)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(href);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableCell className='pl-4'>
|
<TableCell className='pl-4'>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className='group inline-flex min-w-0 flex-col'
|
className='group inline-flex min-w-0 flex-col after:absolute after:inset-0 after:content-[""]'
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<span className='group-hover:text-primary font-medium transition-colors'>
|
<span className='group-hover:text-primary font-medium transition-colors'>
|
||||||
{spoon.name}
|
{spoon.name}
|
||||||
@@ -170,10 +178,7 @@ const SpoonsPage = () => {
|
|||||||
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
|
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
|
||||||
<TableCell className='pr-4 text-right'>
|
<TableCell className='pr-4 text-right'>
|
||||||
<Button size='sm' variant='outline' asChild>
|
<Button size='sm' variant='outline' asChild>
|
||||||
<Link
|
<Link href={href} className='relative z-10'>
|
||||||
href={href}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
Open
|
Open
|
||||||
<ArrowUpRight className='size-3' />
|
<ArrowUpRight className='size-3' />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -201,7 +206,7 @@ const SpoonsPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{spoons.length ? (
|
{spoonList.length ? (
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
Actionable upstream commits waiting across all Spoons:{' '}
|
Actionable upstream commits waiting across all Spoons:{' '}
|
||||||
{upstreamWaiting}
|
{upstreamWaiting}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ const ThreadDetailPage = () => {
|
|||||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details === null) {
|
||||||
|
return (
|
||||||
|
<main className='text-muted-foreground p-6'>
|
||||||
|
This thread was not found.
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { thread, spoon, latestJob } = details;
|
const { thread, spoon, latestJob } = details;
|
||||||
if (latestJob && spoon) {
|
if (latestJob && spoon) {
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +61,7 @@ const ThreadDetailPage = () => {
|
|||||||
Back to Spoon
|
Back to Spoon
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AgentWorkspaceShell jobId={latestJob._id} />
|
<AgentWorkspaceShell key={latestJob._id} jobId={latestJob._id} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
|
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { MessageSquare, Plus } from 'lucide-react';
|
import { MessageSquare, Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
|
|||||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||||
const threads =
|
const threads = useQuery(api.threads.listMine, {
|
||||||
useQuery(api.threads.listMine, {
|
|
||||||
source: source as
|
source: source as
|
||||||
| 'all'
|
| 'all'
|
||||||
| 'user_request'
|
| 'user_request'
|
||||||
@@ -70,8 +70,9 @@ const ThreadsPage = () => {
|
|||||||
| 'failed'
|
| 'failed'
|
||||||
| 'cancelled',
|
| 'cancelled',
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}) ?? [];
|
});
|
||||||
const visibleThreads = threads.filter((thread) => {
|
const threadsLoading = threads === undefined;
|
||||||
|
const visibleThreads = (threads ?? []).filter((thread) => {
|
||||||
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||||
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||||
return false;
|
return false;
|
||||||
@@ -312,25 +313,25 @@ const ThreadsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{visibleThreads.length ? (
|
{threadsLoading ? (
|
||||||
|
<ListSkeleton rows={4} />
|
||||||
|
) : visibleThreads.length ? (
|
||||||
visibleThreads.map((thread) => (
|
visibleThreads.map((thread) => (
|
||||||
<Card
|
<Card
|
||||||
key={thread._id}
|
key={thread._id}
|
||||||
role='link'
|
className='hover:border-primary/50 relative cursor-pointer shadow-none transition-colors'
|
||||||
tabIndex={0}
|
|
||||||
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
|
|
||||||
onClick={() => router.push(threadTarget(thread))}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(threadTarget(thread));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
<h2 className='truncate font-medium'>
|
||||||
|
<Link
|
||||||
|
href={threadTarget(thread)}
|
||||||
|
className='after:absolute after:inset-0 after:content-[""]'
|
||||||
|
>
|
||||||
|
{thread.title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
{thread.spoonName ? (
|
{thread.spoonName ? (
|
||||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -361,15 +362,10 @@ const ThreadsPage = () => {
|
|||||||
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
|
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
|
<div className='relative z-10 mt-2 flex justify-start gap-2 md:justify-end'>
|
||||||
{thread.latestAgentJobId ? (
|
{thread.latestAgentJobId ? (
|
||||||
<Button size='sm' variant='outline' asChild>
|
<Button size='sm' variant='outline' asChild>
|
||||||
<Link
|
<Link href={threadTarget(thread)}>Open workspace</Link>
|
||||||
href={threadTarget(thread)}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
Open workspace
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{thread.latestJobPullRequestUrl ? (
|
{thread.latestJobPullRequestUrl ? (
|
||||||
@@ -378,7 +374,6 @@ const ThreadsPage = () => {
|
|||||||
href={thread.latestJobPullRequestUrl}
|
href={thread.latestJobPullRequestUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
PR
|
PR
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const UpdatesRedirectPage = () => {
|
|
||||||
redirect('/threads?source=upstream_update');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UpdatesRedirectPage;
|
|
||||||
@@ -100,12 +100,14 @@ const ForgotPassword = () => {
|
|||||||
await signIn('password', formData).then(() => {
|
await signIn('password', formData).then(() => {
|
||||||
setEmail(values.email);
|
setEmail(values.email);
|
||||||
setFlow('reset-verification');
|
setFlow('reset-verification');
|
||||||
|
// Only clear the form once the request succeeded; a failed request
|
||||||
|
// keeps the typed email so the user can retry without re-entering it.
|
||||||
|
forgotPasswordForm.reset();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error resetting password: ', error);
|
console.error('Error resetting password: ', error);
|
||||||
toast.error('Error resetting password.');
|
toast.error('Error resetting password.');
|
||||||
} finally {
|
} finally {
|
||||||
forgotPasswordForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,12 +123,14 @@ const ForgotPassword = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn('password', formData);
|
await signIn('password', formData);
|
||||||
|
// Only clear on success (navigation follows); a failed submit keeps the
|
||||||
|
// entered password so the user can correct and retry.
|
||||||
|
resetVerificationForm.reset();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error resetting password: ', error);
|
console.error('Error resetting password: ', error);
|
||||||
toast.error('Error resetting password.');
|
toast.error('Error resetting password.');
|
||||||
} finally {
|
} finally {
|
||||||
resetVerificationForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -230,7 +234,7 @@ const ForgotPassword = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Please enter the one-time password sent to your
|
Please enter the one-time password sent to your
|
||||||
phone.
|
email.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<div className='flex w-full flex-col items-center'>
|
<div className='flex w-full flex-col items-center'>
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className='w-5/6 text-center' />
|
||||||
@@ -265,7 +269,7 @@ const ForgotPassword = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>
|
<FormLabel className='text-xl'>
|
||||||
Confirm Passsword
|
Confirm Password
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
|
|
||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
|
||||||
return {
|
|
||||||
title: 'Profile',
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
googleBot: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfileLayout = async ({
|
|
||||||
children,
|
|
||||||
}: Readonly<{ children: React.ReactNode }>) => {
|
|
||||||
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
export default ProfileLayout;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const Profile = () => {
|
|
||||||
redirect('/settings/profile');
|
|
||||||
};
|
|
||||||
export default Profile;
|
|
||||||
@@ -132,12 +132,14 @@ const SignIn = () => {
|
|||||||
formData.append('flow', flow);
|
formData.append('flow', flow);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
await signIn('password', formData).then(() => {
|
||||||
|
signInForm.reset();
|
||||||
|
router.push('/dashboard');
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing in:', error);
|
console.error('Error signing in:', error);
|
||||||
toast.error('Error signing in.');
|
toast.error('Error signing in.');
|
||||||
} finally {
|
} finally {
|
||||||
signInForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -153,6 +155,7 @@ const SignIn = () => {
|
|||||||
if (values.confirmPassword !== values.password)
|
if (values.confirmPassword !== values.password)
|
||||||
throw new ConvexError('Passwords do not match.');
|
throw new ConvexError('Passwords do not match.');
|
||||||
await signIn('password', formData).then(() => {
|
await signIn('password', formData).then(() => {
|
||||||
|
signUpForm.reset();
|
||||||
setEmail(values.email);
|
setEmail(values.email);
|
||||||
setFlow('email-verification');
|
setFlow('email-verification');
|
||||||
});
|
});
|
||||||
@@ -160,7 +163,6 @@ const SignIn = () => {
|
|||||||
console.error('Error signing up:', error);
|
console.error('Error signing up:', error);
|
||||||
toast.error('Error signing up.');
|
toast.error('Error signing up.');
|
||||||
} finally {
|
} finally {
|
||||||
signUpForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -174,12 +176,14 @@ const SignIn = () => {
|
|||||||
formData.append('email', email);
|
formData.append('email', email);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
await signIn('password', formData).then(() => {
|
||||||
|
verifyEmailForm.reset();
|
||||||
|
router.push('/dashboard');
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying email:', error);
|
console.error('Error verifying email:', error);
|
||||||
toast.error('Error verifying email.');
|
toast.error('Error verifying email.');
|
||||||
} finally {
|
} finally {
|
||||||
verifyEmailForm.reset();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -232,7 +236,7 @@ const SignIn = () => {
|
|||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing Up...'
|
pendingText='Verifying...'
|
||||||
className='mx-auto w-2/3 text-xl font-semibold'
|
className='mx-auto w-2/3 text-xl font-semibold'
|
||||||
>
|
>
|
||||||
Verify Email
|
Verify Email
|
||||||
@@ -423,7 +427,7 @@ const SignIn = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>
|
<FormLabel className='text-xl'>
|
||||||
Confirm Passsword
|
Confirm Password
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async (request: Request) =>
|
||||||
|
await withBox(async (username) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return await proxyBox(
|
||||||
|
username,
|
||||||
|
'file',
|
||||||
|
{ method: 'GET' },
|
||||||
|
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PUT = async (request: Request) =>
|
||||||
|
await withBox(
|
||||||
|
async (username) =>
|
||||||
|
await proxyBox(username, 'file', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: await request.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const POST = async (request: Request) =>
|
||||||
|
await withBox(
|
||||||
|
async (username) =>
|
||||||
|
await proxyBox(username, 'lifecycle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: await request.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async () =>
|
||||||
|
await withBox(
|
||||||
|
async (username) => await proxyBox(username, 'status', { method: 'GET' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { mintBoxTerminalToken, resolveBoxUsername } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async () => {
|
||||||
|
const resolved = await resolveBoxUsername();
|
||||||
|
if (!resolved.ok) return resolved.response;
|
||||||
|
const minted = mintBoxTerminalToken(resolved.username);
|
||||||
|
return minted
|
||||||
|
? NextResponse.json(minted)
|
||||||
|
: NextResponse.json(
|
||||||
|
{ error: 'Terminal is not configured on this deployment.' },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async () =>
|
||||||
|
await withBox(
|
||||||
|
async (username) => await proxyBox(username, 'tree', { method: 'GET' }),
|
||||||
|
);
|
||||||
@@ -35,7 +35,14 @@ export const POST = async (
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const details = await fetchQuery(api.threads.get, { threadId }, { token });
|
const details = (await fetchQuery(
|
||||||
|
api.threads.get,
|
||||||
|
{ threadId },
|
||||||
|
{ token },
|
||||||
|
));
|
||||||
|
if (details === null) {
|
||||||
|
return NextResponse.json({ error: 'Thread not found.' }, { status: 404 });
|
||||||
|
}
|
||||||
const latestJob = details.latestJob;
|
const latestJob = details.latestJob;
|
||||||
const canSendToWorker =
|
const canSendToWorker =
|
||||||
latestJob &&
|
latestJob &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ArrowDown,
|
||||||
Ban,
|
Ban,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
@@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui';
|
|||||||
|
|
||||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||||
import { parseDiffFileForPath } from './diff-utils';
|
import { parseDiffFileForPath } from './diff-utils';
|
||||||
|
import { isNearBottom } from './workspace-helpers';
|
||||||
|
|
||||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export const AgentThread = ({
|
|||||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||||
const diffTheme = useDiffTheme();
|
const diffTheme = useDiffTheme();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
|
||||||
const chatMessages = useMemo(
|
const chatMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
messages.filter((message) => {
|
messages.filter((message) => {
|
||||||
@@ -111,20 +114,35 @@ export const AgentThread = ({
|
|||||||
? []
|
? []
|
||||||
: workspaceChanges;
|
: workspaceChanges;
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollToLatest = () => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const distanceFromBottom =
|
|
||||||
node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
||||||
if (distanceFromBottom < 160 || agentTurnActive) {
|
|
||||||
if (typeof node.scrollTo === 'function') {
|
if (typeof node.scrollTo === 'function') {
|
||||||
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||||
} else {
|
} else {
|
||||||
node.scrollTop = node.scrollHeight;
|
node.scrollTop = node.scrollHeight;
|
||||||
}
|
}
|
||||||
|
setShowJumpToLatest(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only auto-scroll to the newest content when the user is already near the
|
||||||
|
// bottom. If they have scrolled up to read, don't yank them — surface a
|
||||||
|
// "jump to latest" affordance instead. (The agent streaming no longer forces
|
||||||
|
// a scroll; that was fighting the reader.)
|
||||||
|
useEffect(() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
if (isNearBottom(node)) {
|
||||||
|
if (typeof node.scrollTo === 'function') {
|
||||||
|
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
}
|
||||||
|
setShowJumpToLatest(false);
|
||||||
|
} else {
|
||||||
|
setShowJumpToLatest(true);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agentTurnActive,
|
|
||||||
events.length,
|
events.length,
|
||||||
interactions.length,
|
interactions.length,
|
||||||
messages.length,
|
messages.length,
|
||||||
@@ -227,8 +245,13 @@ export const AgentThread = ({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className='relative flex min-h-0 flex-1 flex-col'>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
|
onScroll={() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (node && isNearBottom(node)) setShowJumpToLatest(false);
|
||||||
|
}}
|
||||||
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
||||||
>
|
>
|
||||||
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
||||||
@@ -430,6 +453,19 @@ export const AgentThread = ({
|
|||||||
yet.
|
yet.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showJumpToLatest ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute bottom-3 left-1/2 z-10 -translate-x-1/2 shadow-md'
|
||||||
|
onClick={scrollToLatest}
|
||||||
|
>
|
||||||
|
<ArrowDown className='size-3' />
|
||||||
|
Jump to latest
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='border-border flex-none space-y-2 border-t p-3'>
|
<div className='border-border flex-none space-y-2 border-t p-3'>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
FileCode,
|
FileCode,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
|
PanelLeft,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -25,6 +26,11 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
Button,
|
Button,
|
||||||
|
cn,
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -41,6 +47,7 @@ import { FileTree } from './file-tree';
|
|||||||
import { JobStatusBar } from './job-status-bar';
|
import { JobStatusBar } from './job-status-bar';
|
||||||
import { WorkspaceActions } from './workspace-actions';
|
import { WorkspaceActions } from './workspace-actions';
|
||||||
import { WorkspaceTerminal } from './workspace-terminal';
|
import { WorkspaceTerminal } from './workspace-terminal';
|
||||||
|
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';
|
||||||
|
|
||||||
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||||
|
|
||||||
@@ -51,6 +58,9 @@ type OpenFileState = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// Set when the agent edited this file on disk while the user had unsaved
|
||||||
|
// local edits, so the UI can warn instead of silently stomping either side.
|
||||||
|
conflicted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingOverwrite = {
|
type PendingOverwrite = {
|
||||||
@@ -61,17 +71,27 @@ type PendingOverwrite = {
|
|||||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||||
const job = useQuery(api.agentJobs.get, { jobId });
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
const messages =
|
const messages =
|
||||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
useQuery(
|
||||||
|
api.agentJobs.listMessages,
|
||||||
|
job ? { jobId, limit: 200 } : 'skip',
|
||||||
|
) ?? [];
|
||||||
const events =
|
const events =
|
||||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ??
|
||||||
|
[];
|
||||||
const workspaceChanges =
|
const workspaceChanges =
|
||||||
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
|
useQuery(
|
||||||
|
api.agentJobs.listWorkspaceChanges,
|
||||||
|
job ? { jobId, limit: 200 } : 'skip',
|
||||||
|
) ?? [];
|
||||||
const interactions =
|
const interactions =
|
||||||
useQuery(api.agentJobs.listInteractionRequests, {
|
useQuery(
|
||||||
jobId,
|
api.agentJobs.listInteractionRequests,
|
||||||
status: 'all',
|
job ? { jobId, status: 'all' } : 'skip',
|
||||||
}) ?? [];
|
) ?? [];
|
||||||
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
const uiState = useQuery(
|
||||||
|
api.agentJobs.getWorkspaceUiState,
|
||||||
|
job ? { jobId } : 'skip',
|
||||||
|
);
|
||||||
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
||||||
const createJobForThread = useMutation(api.agentJobs.createForThread);
|
const createJobForThread = useMutation(api.agentJobs.createForThread);
|
||||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||||
@@ -94,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
useState<WorkspaceTab>('editor');
|
useState<WorkspaceTab>('editor');
|
||||||
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||||
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||||
|
const [fileTreeOpen, setFileTreeOpen] = useState(false);
|
||||||
|
|
||||||
|
// Per-loader sequence guards: only the latest response for each loader is
|
||||||
|
// applied, so a slower earlier fetch can't overwrite newer tree/diff data.
|
||||||
|
const treeSequencerRef = useRef<
|
||||||
|
ReturnType<typeof createRequestSequencer> | undefined
|
||||||
|
>(undefined);
|
||||||
|
treeSequencerRef.current ??= createRequestSequencer();
|
||||||
|
const treeSequencer = treeSequencerRef.current;
|
||||||
|
const diffSequencerRef = useRef<
|
||||||
|
ReturnType<typeof createRequestSequencer> | undefined
|
||||||
|
>(undefined);
|
||||||
|
diffSequencerRef.current ??= createRequestSequencer();
|
||||||
|
const diffSequencer = diffSequencerRef.current;
|
||||||
|
|
||||||
|
// Count consecutive workspace status/health failures. A single transient
|
||||||
|
// 'workspace is not active' blip (worker restart) must not flash the big red
|
||||||
|
// recovery panel — only surface it after RECOVERY_THRESHOLD in a row.
|
||||||
|
const consecutiveFailuresRef = useRef(0);
|
||||||
|
const processedChangeSignatureRef = useRef(0);
|
||||||
|
const conflictToastPathsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const recordWorkspaceSuccess = useCallback(() => {
|
||||||
|
consecutiveFailuresRef.current = 0;
|
||||||
|
setWorkspaceError(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recordWorkspaceFailure = useCallback((message: string) => {
|
||||||
|
consecutiveFailuresRef.current += 1;
|
||||||
|
if (shouldShowRecovery(consecutiveFailuresRef.current)) {
|
||||||
|
setWorkspaceError(message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const workspaceDisabled =
|
const workspaceDisabled =
|
||||||
!job ||
|
!job ||
|
||||||
@@ -122,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
const loadTree = useCallback(async () => {
|
||||||
|
const token = treeSequencer.next();
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||||
setWorkspaceError(undefined);
|
if (!treeSequencer.isLatest(token)) return;
|
||||||
|
recordWorkspaceSuccess();
|
||||||
setTree(data.tree);
|
setTree(data.tree);
|
||||||
}, [jobId]);
|
}, [jobId, recordWorkspaceSuccess, treeSequencer]);
|
||||||
|
|
||||||
const loadDiff = useCallback(async () => {
|
const loadDiff = useCallback(async () => {
|
||||||
|
const token = diffSequencer.next();
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as DiffResponse;
|
const data = (await response.json()) as DiffResponse;
|
||||||
setWorkspaceError(undefined);
|
if (!diffSequencer.isLatest(token)) return;
|
||||||
|
recordWorkspaceSuccess();
|
||||||
setDiff(data.diff);
|
setDiff(data.diff);
|
||||||
}, [jobId]);
|
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
|
||||||
|
|
||||||
const loadAgentStatus = useCallback(async () => {
|
const loadAgentStatus = useCallback(async () => {
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||||
@@ -143,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
setAgentTurnActive(false);
|
setAgentTurnActive(false);
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
if (body.includes('workspace is not active')) {
|
if (body.includes('workspace is not active')) {
|
||||||
setWorkspaceError(body);
|
recordWorkspaceFailure(body);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { active?: boolean };
|
const data = (await response.json()) as { active?: boolean };
|
||||||
setWorkspaceError(undefined);
|
recordWorkspaceSuccess();
|
||||||
setAgentTurnActive(Boolean(data.active));
|
setAgentTurnActive(Boolean(data.active));
|
||||||
}, [jobId]);
|
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
|
||||||
|
|
||||||
const loadFile = useCallback(
|
const loadFile = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
@@ -183,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-fetch an already-open file and replace its editor buffer. Used when the
|
||||||
|
// agent edits a file that is currently open: if the local buffer is clean we
|
||||||
|
// adopt the new on-disk content; if the user has unsaved edits we flag a
|
||||||
|
// conflict instead of stomping their work.
|
||||||
|
const refreshFileBuffer = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = (await response.json()) as FileResponse;
|
||||||
|
setFiles((current) => {
|
||||||
|
const existing = current[path];
|
||||||
|
if (!existing) return current;
|
||||||
|
if (existing.content !== existing.savedContent) {
|
||||||
|
return { ...current, [path]: { ...existing, conflicted: true } };
|
||||||
|
}
|
||||||
|
conflictToastPathsRef.current.delete(path);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
path,
|
||||||
|
content: data.content,
|
||||||
|
savedContent: data.content,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
conflicted: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[jobId],
|
||||||
|
);
|
||||||
|
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
setOpenFilePaths((current) =>
|
setOpenFilePaths((current) =>
|
||||||
@@ -209,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceReady) return;
|
if (!workspaceReady) return;
|
||||||
|
// Recovery is driven solely by the recurring status heartbeat
|
||||||
|
// (loadAgentStatus) so a one-off tree/diff hiccup can't trip the panel.
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setWorkspaceError(error instanceof Error ? error.message : String(error));
|
|
||||||
};
|
};
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
void loadTree().catch(handleError);
|
void loadTree().catch(handleError);
|
||||||
@@ -249,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
(latest, change) => Math.max(latest, change._creationTime),
|
(latest, change) => Math.max(latest, change._creationTime),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
// Read the (freshly-allocated-each-render) changes array via a ref inside the
|
||||||
|
// signature-gated effect below, so the effect's deps stay on the stable
|
||||||
|
// numeric signature instead of the array identity.
|
||||||
|
const workspaceChangesRef = useRef(workspaceChanges);
|
||||||
|
workspaceChangesRef.current = workspaceChanges;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceReady) return;
|
if (!workspaceReady) return;
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -264,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
loadTree,
|
loadTree,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// When the agent records a workspace change that touches a currently OPEN
|
||||||
|
// file, refresh that file's editor buffer (clean buffers adopt the new
|
||||||
|
// content; dirty buffers get a conflict flag + one-time toast) so the editor
|
||||||
|
// doesn't keep showing pre-edit content.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceReady) return;
|
||||||
|
if (workspaceChangeSignature <= processedChangeSignatureRef.current) return;
|
||||||
|
const previousSignature = processedChangeSignatureRef.current;
|
||||||
|
processedChangeSignatureRef.current = workspaceChangeSignature;
|
||||||
|
const changedPaths = new Set(
|
||||||
|
workspaceChangesRef.current
|
||||||
|
.filter((change) => change._creationTime > previousSignature)
|
||||||
|
.map((change) => change.path),
|
||||||
|
);
|
||||||
|
for (const path of openFilePaths) {
|
||||||
|
if (!changedPaths.has(path)) continue;
|
||||||
|
const file = files[path];
|
||||||
|
if (!file) continue;
|
||||||
|
if (file.content !== file.savedContent) {
|
||||||
|
setFiles((current) =>
|
||||||
|
current[path]
|
||||||
|
? { ...current, [path]: { ...current[path], conflicted: true } }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
if (!conflictToastPathsRef.current.has(path)) {
|
||||||
|
conflictToastPathsRef.current.add(path);
|
||||||
|
toast.warning(`${path} changed on disk`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
void refreshFileBuffer(path).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
files,
|
||||||
|
openFilePaths,
|
||||||
|
refreshFileBuffer,
|
||||||
|
workspaceChangeSignature,
|
||||||
|
workspaceReady,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uiState || hydratedUiState) return;
|
if (!uiState || hydratedUiState) return;
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
@@ -323,6 +460,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (job === null) {
|
||||||
|
return (
|
||||||
|
<main className='text-muted-foreground p-6'>
|
||||||
|
This workspace was not found.
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||||
const recoverWorkspace = async () => {
|
const recoverWorkspace = async () => {
|
||||||
if (!job.threadId) return;
|
if (!job.threadId) return;
|
||||||
@@ -374,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
}));
|
}));
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
|
conflictToastPathsRef.current.delete(path);
|
||||||
setFiles((current) => ({
|
setFiles((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[path]: {
|
[path]: {
|
||||||
@@ -384,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
content,
|
content,
|
||||||
savedContent: content,
|
savedContent: content,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
conflicted: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await loadDiff();
|
await loadDiff();
|
||||||
@@ -473,7 +620,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
<main className='border-border bg-muted/20 flex h-[calc(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
|
||||||
<JobStatusBar job={job} />
|
<JobStatusBar job={job} />
|
||||||
{workspacePending && !workspaceError ? (
|
{workspacePending && !workspaceError ? (
|
||||||
<div className='border-border bg-background border-b p-4'>
|
<div className='border-border bg-background border-b p-4'>
|
||||||
@@ -555,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
<div className='border-border bg-background flex items-center justify-between gap-2 border-b px-4 py-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='lg:hidden'
|
||||||
|
onClick={() => setFileTreeOpen(true)}
|
||||||
|
>
|
||||||
|
<PanelLeft className='size-4' />
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
|
<div className='flex flex-1 items-center justify-end'>
|
||||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<Sheet open={fileTreeOpen} onOpenChange={setFileTreeOpen}>
|
||||||
|
<SheetContent side='left' className='w-[300px] p-0 lg:hidden'>
|
||||||
|
<SheetHeader className='border-border border-b'>
|
||||||
|
<SheetTitle>Files</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<FileTree
|
||||||
|
tree={tree}
|
||||||
|
selectedPath={activeFilePath}
|
||||||
|
expandedPaths={expandedDirectoryPaths}
|
||||||
|
onSelect={(path) => {
|
||||||
|
openFile(path);
|
||||||
|
setFileTreeOpen(false);
|
||||||
|
}}
|
||||||
|
onToggleDirectory={toggleDirectory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
<div
|
<div
|
||||||
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
|
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
|
||||||
style={
|
style={
|
||||||
@@ -566,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<aside className='border-border bg-background min-h-0 border-r'>
|
<aside className='border-border bg-background hidden min-h-0 border-r lg:block'>
|
||||||
<div className='border-border border-b p-3'>
|
<div className='border-border border-b p-3'>
|
||||||
<h2 className='text-sm font-semibold'>Files</h2>
|
<h2 className='text-sm font-semibold'>Files</h2>
|
||||||
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||||
@@ -638,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
savedContent={activeFile?.savedContent ?? ''}
|
savedContent={activeFile?.savedContent ?? ''}
|
||||||
readOnly={workspaceDisabled}
|
readOnly={workspaceDisabled}
|
||||||
vimEnabled={vimEnabled}
|
vimEnabled={vimEnabled}
|
||||||
|
conflicted={activeFile?.conflicted}
|
||||||
onSave={saveFile}
|
onSave={saveFile}
|
||||||
onVimEnabledChange={setVimEnabled}
|
onVimEnabledChange={setVimEnabled}
|
||||||
onChange={(content) => {
|
onChange={(content) => {
|
||||||
@@ -659,11 +838,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value='terminal'
|
value='terminal'
|
||||||
className='m-0 min-h-0 flex-1 overflow-hidden'
|
forceMount
|
||||||
|
className={cn(
|
||||||
|
'm-0 min-h-0 flex-1 overflow-hidden',
|
||||||
|
activeWorkspaceTab !== 'terminal' && 'hidden',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<WorkspaceTerminal
|
<WorkspaceTerminal
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
active={workspaceReady}
|
||||||
|
visible={activeWorkspaceTab === 'terminal'}
|
||||||
|
waitingLabel={
|
||||||
|
workspacePending ? 'Waiting for workspace…' : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const EDITOR_FONT_FAMILY =
|
|||||||
|
|
||||||
type MonacoEditorInstance = {
|
type MonacoEditorInstance = {
|
||||||
getModel?: () => unknown;
|
getModel?: () => unknown;
|
||||||
|
dispose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VimMode = {
|
type VimMode = {
|
||||||
@@ -36,6 +37,7 @@ export const CodeEditor = ({
|
|||||||
savedContent,
|
savedContent,
|
||||||
readOnly,
|
readOnly,
|
||||||
vimEnabled,
|
vimEnabled,
|
||||||
|
conflicted,
|
||||||
onSave,
|
onSave,
|
||||||
onChange,
|
onChange,
|
||||||
onVimEnabledChange,
|
onVimEnabledChange,
|
||||||
@@ -45,6 +47,7 @@ export const CodeEditor = ({
|
|||||||
savedContent: string;
|
savedContent: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
vimEnabled: boolean;
|
vimEnabled: boolean;
|
||||||
|
conflicted?: boolean;
|
||||||
onSave: (content: string) => Promise<void>;
|
onSave: (content: string) => Promise<void>;
|
||||||
onChange: (content: string) => void;
|
onChange: (content: string) => void;
|
||||||
onVimEnabledChange: (enabled: boolean) => void;
|
onVimEnabledChange: (enabled: boolean) => void;
|
||||||
@@ -56,6 +59,18 @@ export const CodeEditor = ({
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||||
|
|
||||||
|
// Dispose the Monaco instance and clear the ref on unmount so a remount
|
||||||
|
// (e.g. the Phase-1 key={jobId} swap) doesn't leave a stale editor/vim
|
||||||
|
// binding leaked behind the new mount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
vimRef.current?.dispose();
|
||||||
|
vimRef.current = null;
|
||||||
|
editorRef.current?.dispose?.();
|
||||||
|
editorRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -102,7 +117,11 @@ export const CodeEditor = ({
|
|||||||
Editor
|
Editor
|
||||||
</p>
|
</p>
|
||||||
<p className='truncate font-mono text-xs'>{path}</p>
|
<p className='truncate font-mono text-xs'>{path}</p>
|
||||||
{dirty ? (
|
{conflicted ? (
|
||||||
|
<p className='text-amber-500 text-xs'>
|
||||||
|
This file changed on disk — your unsaved edits may conflict.
|
||||||
|
</p>
|
||||||
|
) : dirty ? (
|
||||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const scheduleTerminalFits = (deps: {
|
||||||
|
fit: () => void;
|
||||||
|
sendResize: () => void;
|
||||||
|
fontsReady: Promise<unknown>;
|
||||||
|
loadNerdFont: () => Promise<unknown>;
|
||||||
|
raf: (cb: () => void) => void;
|
||||||
|
isAborted: () => boolean;
|
||||||
|
}): void => {
|
||||||
|
const refit = () => {
|
||||||
|
if (deps.isAborted()) return;
|
||||||
|
deps.fit();
|
||||||
|
deps.sendResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialFit = new Promise<void>((resolve) => {
|
||||||
|
deps.raf(() => {
|
||||||
|
refit();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void Promise.all([deps.fontsReady, initialFit])
|
||||||
|
.then(refit)
|
||||||
|
.catch(() => undefined);
|
||||||
|
void Promise.all([deps.loadNerdFont(), initialFit])
|
||||||
|
.then(refit)
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Small pure helpers extracted from the agent workspace so the tricky UX
|
||||||
|
* decisions (out-of-order response guarding, recovery thresholding, and
|
||||||
|
* near-bottom auto-scroll) can be unit tested in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A monotonically increasing request sequencer. Each loader call takes a fresh
|
||||||
|
* token via {@link RequestSequencer.next} before firing its fetch, then checks
|
||||||
|
* {@link RequestSequencer.isLatest} once the response resolves. A slower earlier
|
||||||
|
* request whose token is no longer the latest is dropped so it can't overwrite
|
||||||
|
* newer data (the diff/tree load race).
|
||||||
|
*/
|
||||||
|
export type RequestSequencer = {
|
||||||
|
next: () => number;
|
||||||
|
isLatest: (token: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRequestSequencer = (): RequestSequencer => {
|
||||||
|
let current = 0;
|
||||||
|
return {
|
||||||
|
next: () => {
|
||||||
|
current += 1;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
isLatest: (token: number) => token === current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of consecutive workspace status/health failures required before the
|
||||||
|
* "needs recovery" panel is shown. A single transient
|
||||||
|
* `workspace is not active` blip (e.g. during a worker restart) must not trip
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export const RECOVERY_THRESHOLD = 3;
|
||||||
|
|
||||||
|
export const shouldShowRecovery = (
|
||||||
|
consecutiveFailures: number,
|
||||||
|
threshold: number = RECOVERY_THRESHOLD,
|
||||||
|
): boolean => consecutiveFailures >= threshold;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance from the bottom (px) within which the thread is considered "near the
|
||||||
|
* bottom" and safe to auto-scroll on new content. Outside this band the user
|
||||||
|
* has scrolled up to read and must not be yanked.
|
||||||
|
*/
|
||||||
|
export const NEAR_BOTTOM_THRESHOLD = 80;
|
||||||
|
|
||||||
|
export const isNearBottom = (
|
||||||
|
metrics: { scrollHeight: number; scrollTop: number; clientHeight: number },
|
||||||
|
threshold: number = NEAR_BOTTOM_THRESHOLD,
|
||||||
|
): boolean =>
|
||||||
|
metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight < threshold;
|
||||||
@@ -1,254 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
import { XtermSession } from './xterm-session';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
|
|
||||||
import { Button } from '@spoon/ui';
|
|
||||||
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
|
||||||
|
|
||||||
const TERMINAL_FONT =
|
|
||||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
|
||||||
|
|
||||||
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
|
||||||
|
|
||||||
const darkTheme: ITheme = {
|
|
||||||
background: '#080e14',
|
|
||||||
foreground: '#eef3f5',
|
|
||||||
cursor: '#1fb895',
|
|
||||||
cursorAccent: '#080e14',
|
|
||||||
selectionBackground: '#1fb89544',
|
|
||||||
black: '#10171e',
|
|
||||||
red: '#f3625d',
|
|
||||||
green: '#8fd6b4',
|
|
||||||
yellow: '#e3b341',
|
|
||||||
blue: '#6aa6ff',
|
|
||||||
magenta: '#b692e8',
|
|
||||||
cyan: '#5fd0e0',
|
|
||||||
white: '#cdd6dc',
|
|
||||||
brightBlack: '#93a1a9',
|
|
||||||
brightRed: '#f3625d',
|
|
||||||
brightGreen: '#8fd6b4',
|
|
||||||
brightYellow: '#e3b341',
|
|
||||||
brightBlue: '#6aa6ff',
|
|
||||||
brightMagenta: '#b692e8',
|
|
||||||
brightCyan: '#5fd0e0',
|
|
||||||
brightWhite: '#eef3f5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const lightTheme: ITheme = {
|
|
||||||
background: '#f7fbfa',
|
|
||||||
foreground: '#0d1218',
|
|
||||||
cursor: '#007560',
|
|
||||||
cursorAccent: '#f7fbfa',
|
|
||||||
selectionBackground: '#00756033',
|
|
||||||
black: '#0d1218',
|
|
||||||
red: '#d73337',
|
|
||||||
green: '#2f8f6e',
|
|
||||||
yellow: '#9a6b00',
|
|
||||||
blue: '#2f6bd8',
|
|
||||||
magenta: '#7c4dd1',
|
|
||||||
cyan: '#0f7d92',
|
|
||||||
white: '#26323c',
|
|
||||||
brightBlack: '#555f68',
|
|
||||||
brightRed: '#d73337',
|
|
||||||
brightGreen: '#2f8f6e',
|
|
||||||
brightYellow: '#9a6b00',
|
|
||||||
brightBlue: '#2f6bd8',
|
|
||||||
brightMagenta: '#7c4dd1',
|
|
||||||
brightCyan: '#0f7d92',
|
|
||||||
brightWhite: '#0d1218',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorkspaceTerminal = ({
|
export const WorkspaceTerminal = ({
|
||||||
jobId,
|
jobId,
|
||||||
active,
|
active,
|
||||||
|
visible = true,
|
||||||
|
waitingLabel,
|
||||||
}: {
|
}: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) => {
|
visible?: boolean;
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
waitingLabel?: string;
|
||||||
const termRef = useRef<Terminal | null>(null);
|
}) => (
|
||||||
const { resolvedTheme } = useTheme();
|
<XtermSession
|
||||||
const themeIsLight = resolvedTheme === 'light';
|
active={active}
|
||||||
const [status, setStatus] = useState<Status>('connecting');
|
visible={visible}
|
||||||
const [errorText, setErrorText] = useState<string>();
|
waitingLabel={waitingLabel}
|
||||||
const [reconnectKey, setReconnectKey] = useState(0);
|
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
||||||
|
sessionKey={jobId}
|
||||||
// Update the live terminal's theme without tearing down the session.
|
/>
|
||||||
useEffect(() => {
|
);
|
||||||
if (termRef.current) {
|
|
||||||
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
|
||||||
}
|
|
||||||
}, [themeIsLight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const signal = abortController.signal;
|
|
||||||
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
|
||||||
// the first guard (it changes asynchronously, on cleanup).
|
|
||||||
const isAborted = () => signal.aborted;
|
|
||||||
let ws: WebSocket | undefined;
|
|
||||||
let resizeObserver: ResizeObserver | undefined;
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
const start = async () => {
|
|
||||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
|
||||||
[
|
|
||||||
import('@xterm/xterm'),
|
|
||||||
import('@xterm/addon-fit'),
|
|
||||||
import('@xterm/addon-web-links'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (isAborted()) return;
|
|
||||||
|
|
||||||
setStatus('connecting');
|
|
||||||
setErrorText(undefined);
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
if (!isAborted()) setStatus('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isAborted()) return;
|
|
||||||
if (response.status === 503) {
|
|
||||||
setStatus('unconfigured');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
setStatus('error');
|
|
||||||
setErrorText(await response.text().catch(() => undefined));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { url } = (await response.json()) as { url: string };
|
|
||||||
|
|
||||||
const term = new Terminal({
|
|
||||||
fontFamily: TERMINAL_FONT,
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
cursorBlink: true,
|
|
||||||
theme: themeIsLight ? lightTheme : darkTheme,
|
|
||||||
allowProposedApi: true,
|
|
||||||
scrollback: 5000,
|
|
||||||
});
|
|
||||||
const fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.loadAddon(new WebLinksAddon());
|
|
||||||
term.open(container);
|
|
||||||
fit.fit();
|
|
||||||
termRef.current = term;
|
|
||||||
|
|
||||||
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
|
|
||||||
// repaint once ready so powerline/oh-my-posh/eza icons render.
|
|
||||||
void document.fonts
|
|
||||||
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
|
|
||||||
.then(() => {
|
|
||||||
if (!isAborted()) term.refresh(0, term.rows - 1);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
const sendResize = () => {
|
|
||||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws = new WebSocket(url);
|
|
||||||
ws.binaryType = 'arraybuffer';
|
|
||||||
ws.onopen = () => {
|
|
||||||
if (isAborted()) return;
|
|
||||||
setStatus('connected');
|
|
||||||
sendResize();
|
|
||||||
term.focus();
|
|
||||||
};
|
|
||||||
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
|
||||||
if (typeof event.data === 'string') term.write(event.data);
|
|
||||||
else term.write(new Uint8Array(event.data));
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (!isAborted()) setStatus('closed');
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
if (!isAborted()) setStatus('error');
|
|
||||||
};
|
|
||||||
|
|
||||||
term.onData((data) => {
|
|
||||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
|
||||||
});
|
|
||||||
term.onResize(() => sendResize());
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
// ignore transient layout errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
};
|
|
||||||
|
|
||||||
void start();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
abortController.abort();
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
ws?.close();
|
|
||||||
termRef.current?.dispose();
|
|
||||||
termRef.current = null;
|
|
||||||
};
|
|
||||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [active, jobId, reconnectKey]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='relative flex h-full min-h-0 flex-col'>
|
|
||||||
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
{status === 'connected'
|
|
||||||
? 'Connected · workspace shell'
|
|
||||||
: status === 'connecting'
|
|
||||||
? 'Connecting…'
|
|
||||||
: status === 'closed'
|
|
||||||
? 'Session ended'
|
|
||||||
: status === 'unconfigured'
|
|
||||||
? 'Terminal not configured'
|
|
||||||
: 'Connection error'}
|
|
||||||
</p>
|
|
||||||
{status === 'closed' || status === 'error' ? (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setReconnectKey((key) => key + 1)}
|
|
||||||
>
|
|
||||||
Reconnect
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{status === 'unconfigured' ? (
|
|
||||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
|
||||||
The terminal is not enabled on this deployment.
|
|
||||||
<br />
|
|
||||||
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
|
||||||
<div ref={containerRef} className='h-full w-full' />
|
|
||||||
{errorText ? (
|
|
||||||
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
|
||||||
{errorText}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
import { scheduleTerminalFits } from './terminal-fit';
|
||||||
|
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
const TERMINAL_FONT =
|
||||||
|
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
|
// Persisted user preference for copy-on-select. A stored value takes precedence
|
||||||
|
// over the `copyOnSelect` prop default so the toggle sticks across sessions.
|
||||||
|
const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect';
|
||||||
|
|
||||||
|
const readStoredCopyOnSelect = (): boolean | undefined => {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY);
|
||||||
|
if (stored === 'true') return true;
|
||||||
|
if (stored === 'false') return false;
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable (private mode, SSR); fall back to prop.
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type XtermStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'connecting'
|
||||||
|
| 'connected'
|
||||||
|
| 'closed'
|
||||||
|
| 'error'
|
||||||
|
| 'unconfigured';
|
||||||
|
|
||||||
|
const darkTheme: ITheme = {
|
||||||
|
background: '#080e14',
|
||||||
|
foreground: '#eef3f5',
|
||||||
|
cursor: '#1fb895',
|
||||||
|
cursorAccent: '#080e14',
|
||||||
|
selectionBackground: '#1fb89544',
|
||||||
|
black: '#10171e',
|
||||||
|
red: '#f3625d',
|
||||||
|
green: '#8fd6b4',
|
||||||
|
yellow: '#e3b341',
|
||||||
|
blue: '#6aa6ff',
|
||||||
|
magenta: '#b692e8',
|
||||||
|
cyan: '#5fd0e0',
|
||||||
|
white: '#cdd6dc',
|
||||||
|
brightBlack: '#93a1a9',
|
||||||
|
brightRed: '#f3625d',
|
||||||
|
brightGreen: '#8fd6b4',
|
||||||
|
brightYellow: '#e3b341',
|
||||||
|
brightBlue: '#6aa6ff',
|
||||||
|
brightMagenta: '#b692e8',
|
||||||
|
brightCyan: '#5fd0e0',
|
||||||
|
brightWhite: '#eef3f5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightTheme: ITheme = {
|
||||||
|
background: '#f7fbfa',
|
||||||
|
foreground: '#0d1218',
|
||||||
|
cursor: '#007560',
|
||||||
|
cursorAccent: '#f7fbfa',
|
||||||
|
selectionBackground: '#00756033',
|
||||||
|
black: '#0d1218',
|
||||||
|
red: '#d73337',
|
||||||
|
green: '#2f8f6e',
|
||||||
|
yellow: '#9a6b00',
|
||||||
|
blue: '#2f6bd8',
|
||||||
|
magenta: '#7c4dd1',
|
||||||
|
cyan: '#0f7d92',
|
||||||
|
white: '#26323c',
|
||||||
|
brightBlack: '#555f68',
|
||||||
|
brightRed: '#d73337',
|
||||||
|
brightGreen: '#2f8f6e',
|
||||||
|
brightYellow: '#9a6b00',
|
||||||
|
brightBlue: '#2f6bd8',
|
||||||
|
brightMagenta: '#7c4dd1',
|
||||||
|
brightCyan: '#0f7d92',
|
||||||
|
brightWhite: '#0d1218',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XtermSession = ({
|
||||||
|
active,
|
||||||
|
visible = true,
|
||||||
|
tokenUrl,
|
||||||
|
sessionKey,
|
||||||
|
waitingLabel,
|
||||||
|
copyOnSelect,
|
||||||
|
label = 'workspace shell',
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
tokenUrl: string;
|
||||||
|
sessionKey: string;
|
||||||
|
waitingLabel?: string;
|
||||||
|
copyOnSelect?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
const fitRef = useRef<{ fit: () => void } | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const themeIsLight = resolvedTheme === 'light';
|
||||||
|
const [status, setStatus] = useState<XtermStatus>('idle');
|
||||||
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
const [reconnectKey, setReconnectKey] = useState(0);
|
||||||
|
// localStorage overrides the prop default; the prop is only the initial value
|
||||||
|
// when nothing has been persisted yet.
|
||||||
|
const [copyEnabled, setCopyEnabled] = useState<boolean>(
|
||||||
|
() => readStoredCopyOnSelect() ?? copyOnSelect ?? false,
|
||||||
|
);
|
||||||
|
// The connect effect registers onSelectionChange once; it reads the current
|
||||||
|
// toggle through this ref so live flips take effect without a reconnect.
|
||||||
|
const copyEnabledRef = useRef(copyEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
copyEnabledRef.current = copyEnabled;
|
||||||
|
}, [copyEnabled]);
|
||||||
|
|
||||||
|
const toggleCopyOnSelect = () => {
|
||||||
|
setCopyEnabled((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next));
|
||||||
|
} catch {
|
||||||
|
// Persistence is best-effort; ignore storage failures.
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the live terminal's theme without tearing down the session.
|
||||||
|
useEffect(() => {
|
||||||
|
if (termRef.current) {
|
||||||
|
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
||||||
|
}
|
||||||
|
}, [themeIsLight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
||||||
|
// the first guard (it changes asynchronously, on cleanup).
|
||||||
|
const isAborted = () => signal.aborted;
|
||||||
|
let ws: WebSocket | undefined;
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
||||||
|
[
|
||||||
|
import('@xterm/xterm'),
|
||||||
|
import('@xterm/addon-fit'),
|
||||||
|
import('@xterm/addon-web-links'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (isAborted()) return;
|
||||||
|
|
||||||
|
setStatus('connecting');
|
||||||
|
setErrorText(undefined);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(tokenUrl, { signal });
|
||||||
|
} catch {
|
||||||
|
if (!isAborted()) setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAborted()) return;
|
||||||
|
if (response.status === 503) {
|
||||||
|
setStatus('unconfigured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus('error');
|
||||||
|
setErrorText(await response.text().catch(() => undefined));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { url } = (await response.json()) as { url: string };
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
fontFamily: TERMINAL_FONT,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: themeIsLight ? lightTheme : darkTheme,
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.loadAddon(new WebLinksAddon());
|
||||||
|
term.open(container);
|
||||||
|
termRef.current = term;
|
||||||
|
fitRef.current = fit;
|
||||||
|
|
||||||
|
// Copy-on-select: when enabled, mirror the selection to the clipboard.
|
||||||
|
// Registered once; the ref lets live toggle flips apply without a
|
||||||
|
// reconnect. Clipboard access is guarded — it may be unavailable or
|
||||||
|
// denied, and empty selections are skipped.
|
||||||
|
term.onSelectionChange(() => {
|
||||||
|
if (!copyEnabledRef.current) return;
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
try {
|
||||||
|
void navigator.clipboard.writeText(selection).catch(() => {
|
||||||
|
// clipboard write denied/unavailable; ignore
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// navigator.clipboard missing entirely; ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendResize = () => {
|
||||||
|
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleTerminalFits({
|
||||||
|
fit: () => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
// ignore transient layout errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendResize,
|
||||||
|
fontsReady: document.fonts.ready,
|
||||||
|
loadNerdFont: () =>
|
||||||
|
document.fonts.load("16px 'Symbols Nerd Font Mono'", '').then(() => {
|
||||||
|
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||||
|
}),
|
||||||
|
raf: (cb) => requestAnimationFrame(cb),
|
||||||
|
isAborted,
|
||||||
|
});
|
||||||
|
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (isAborted()) return;
|
||||||
|
setStatus('connected');
|
||||||
|
sendResize();
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
||||||
|
if (typeof event.data === 'string') term.write(event.data);
|
||||||
|
else term.write(new Uint8Array(event.data));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!isAborted()) setStatus('closed');
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (!isAborted()) setStatus('error');
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
||||||
|
});
|
||||||
|
term.onResize(() => sendResize());
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
// ignore transient layout errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
};
|
||||||
|
|
||||||
|
void start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
ws?.close();
|
||||||
|
termRef.current?.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active, sessionKey, reconnectKey]);
|
||||||
|
|
||||||
|
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
|
||||||
|
// tab is inactive. When the terminal becomes visible again, refit inside a
|
||||||
|
// frame (so layout has settled) and push the new dimensions to the PTY. The
|
||||||
|
// WebSocket stays open across visibility changes — this never reconnects.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !active) return;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
} catch {
|
||||||
|
// ignore transient layout errors
|
||||||
|
}
|
||||||
|
const ws = wsRef.current;
|
||||||
|
const term = termRef.current;
|
||||||
|
if (ws?.readyState === WebSocket.OPEN && term) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [visible, active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative flex h-full min-h-0 flex-col'>
|
||||||
|
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{status === 'connected'
|
||||||
|
? `Connected · ${label}`
|
||||||
|
: status === 'idle'
|
||||||
|
? (waitingLabel ? 'Waiting…' : 'Idle')
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'Connecting…'
|
||||||
|
: status === 'closed'
|
||||||
|
? 'Session ended'
|
||||||
|
: status === 'unconfigured'
|
||||||
|
? 'Terminal not configured'
|
||||||
|
: 'Connection error'}
|
||||||
|
</p>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<label className='text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='accent-primary size-3.5'
|
||||||
|
checked={copyEnabled}
|
||||||
|
onChange={toggleCopyOnSelect}
|
||||||
|
/>
|
||||||
|
Copy on select
|
||||||
|
</label>
|
||||||
|
{status === 'closed' || status === 'error' ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setReconnectKey((key) => key + 1)}
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status === 'unconfigured' ? (
|
||||||
|
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||||
|
The terminal is not enabled on this deployment.
|
||||||
|
<br />
|
||||||
|
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||||
|
</div>
|
||||||
|
) : !active && waitingLabel ? (
|
||||||
|
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||||
|
{waitingLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||||
|
<div ref={containerRef} className='h-full w-full' />
|
||||||
|
{errorText ? (
|
||||||
|
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -42,7 +42,11 @@ type Provider =
|
|||||||
| 'cloudflare_ai_gateway'
|
| 'cloudflare_ai_gateway'
|
||||||
| 'custom_openai_compatible'
|
| 'custom_openai_compatible'
|
||||||
| 'opencode_openai_login';
|
| 'opencode_openai_login';
|
||||||
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
|
type AuthType =
|
||||||
|
| 'api_key'
|
||||||
|
| 'opencode_auth_json'
|
||||||
|
| 'anthropic_oauth_json'
|
||||||
|
| 'none';
|
||||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
const saveProfileRef = makeFunctionReference<
|
const saveProfileRef = makeFunctionReference<
|
||||||
@@ -69,33 +73,75 @@ const setDefaultProfileRef = makeFunctionReference<
|
|||||||
>('aiProviderProfiles:setDefault');
|
>('aiProviderProfiles:setDefault');
|
||||||
|
|
||||||
const providerOptions: {
|
const providerOptions: {
|
||||||
|
key: string;
|
||||||
value: Provider;
|
value: Provider;
|
||||||
label: string;
|
label: string;
|
||||||
authType: AuthType;
|
authType: AuthType;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
|
|
||||||
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
|
|
||||||
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
|
|
||||||
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
|
|
||||||
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
|
|
||||||
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
|
|
||||||
{
|
{
|
||||||
|
key: 'openai',
|
||||||
|
value: 'openai',
|
||||||
|
label: 'OpenAI API key',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anthropic',
|
||||||
|
value: 'anthropic',
|
||||||
|
label: 'Anthropic API key',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'google',
|
||||||
|
value: 'google',
|
||||||
|
label: 'Google Gemini API key',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'openrouter',
|
||||||
|
value: 'openrouter',
|
||||||
|
label: 'OpenRouter',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'requesty',
|
||||||
|
value: 'requesty',
|
||||||
|
label: 'Requesty',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{ key: 'litellm', value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
|
||||||
|
{
|
||||||
|
key: 'cloudflare_ai_gateway',
|
||||||
value: 'cloudflare_ai_gateway',
|
value: 'cloudflare_ai_gateway',
|
||||||
label: 'Cloudflare AI Gateway',
|
label: 'Cloudflare AI Gateway',
|
||||||
authType: 'api_key',
|
authType: 'api_key',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'custom_openai_compatible',
|
||||||
value: 'custom_openai_compatible',
|
value: 'custom_openai_compatible',
|
||||||
label: 'Custom OpenAI-compatible',
|
label: 'Custom OpenAI-compatible',
|
||||||
authType: 'api_key',
|
authType: 'api_key',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: 'opencode_openai_login',
|
||||||
value: 'opencode_openai_login',
|
value: 'opencode_openai_login',
|
||||||
label: 'Codex ChatGPT login',
|
label: 'Codex ChatGPT login',
|
||||||
authType: 'opencode_auth_json',
|
authType: 'opencode_auth_json',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'anthropic_oauth_json',
|
||||||
|
value: 'anthropic',
|
||||||
|
label: 'Anthropic OAuth (paste credentials)',
|
||||||
|
authType: 'anthropic_oauth_json',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const defaultProviderOption: (typeof providerOptions)[number] = {
|
||||||
|
key: 'openai',
|
||||||
|
value: 'openai',
|
||||||
|
label: 'OpenAI API key',
|
||||||
|
authType: 'api_key',
|
||||||
|
};
|
||||||
|
|
||||||
const reasoningOptions: ReasoningEffort[] = [
|
const reasoningOptions: ReasoningEffort[] = [
|
||||||
'none',
|
'none',
|
||||||
'minimal',
|
'minimal',
|
||||||
@@ -112,17 +158,14 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
||||||
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
||||||
const [name, setName] = useState('OpenAI');
|
const [name, setName] = useState('OpenAI');
|
||||||
const [provider, setProvider] = useState<Provider>('openai');
|
const [providerKey, setProviderKey] = useState('openai');
|
||||||
const selectedProvider = useMemo(
|
const selectedProvider = useMemo(
|
||||||
() =>
|
() =>
|
||||||
providerOptions.find((option) => option.value === provider) ??
|
providerOptions.find((option) => option.key === providerKey) ??
|
||||||
({
|
defaultProviderOption,
|
||||||
value: 'openai',
|
[providerKey],
|
||||||
label: 'OpenAI API key',
|
|
||||||
authType: 'api_key',
|
|
||||||
} satisfies (typeof providerOptions)[number]),
|
|
||||||
[provider],
|
|
||||||
);
|
);
|
||||||
|
const provider = selectedProvider.value;
|
||||||
const [secret, setSecret] = useState('');
|
const [secret, setSecret] = useState('');
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [defaultModelValue, setDefaultModelValue] = useState(
|
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||||
@@ -146,7 +189,7 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setProfileId(undefined);
|
setProfileId(undefined);
|
||||||
setProvider('openai');
|
setProviderKey('openai');
|
||||||
setSecret('');
|
setSecret('');
|
||||||
setBaseUrl('');
|
setBaseUrl('');
|
||||||
setDefaultModelValue('');
|
setDefaultModelValue('');
|
||||||
@@ -160,7 +203,16 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
const edit = (profile: (typeof profiles)[number]) => {
|
const edit = (profile: (typeof profiles)[number]) => {
|
||||||
setProfileId(profile._id);
|
setProfileId(profile._id);
|
||||||
setName(profile.name);
|
setName(profile.name);
|
||||||
setProvider(profile.provider as Provider);
|
setProviderKey(
|
||||||
|
providerOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.value === profile.provider &&
|
||||||
|
option.authType === profile.authType,
|
||||||
|
)?.key ??
|
||||||
|
providerOptions.find((option) => option.value === profile.provider)
|
||||||
|
?.key ??
|
||||||
|
'openai',
|
||||||
|
);
|
||||||
setSecret('');
|
setSecret('');
|
||||||
setBaseUrl(profile.baseUrl ?? '');
|
setBaseUrl(profile.baseUrl ?? '');
|
||||||
setDefaultModelValue(profile.defaultModel);
|
setDefaultModelValue(profile.defaultModel);
|
||||||
@@ -233,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
<Select
|
<Select
|
||||||
value={defaultProfile?._id ?? ''}
|
value={defaultProfile?._id ?? ''}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
|
try {
|
||||||
await setDefaultProfile({
|
await setDefaultProfile({
|
||||||
profileId: value as Id<'aiProviderProfiles'>,
|
profileId: value as Id<'aiProviderProfiles'>,
|
||||||
});
|
});
|
||||||
toast.success('Default AI provider updated.');
|
toast.success('Default AI provider updated.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Could not update default AI provider.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -280,8 +340,16 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
size='icon'
|
size='icon'
|
||||||
aria-label='Remove provider'
|
aria-label='Remove provider'
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
try {
|
||||||
await removeProfile({ profileId: profile._id });
|
await removeProfile({ profileId: profile._id });
|
||||||
toast.success('AI provider removed.');
|
toast.success('AI provider removed.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Could not remove AI provider.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className='size-4' />
|
<Trash2 className='size-4' />
|
||||||
@@ -314,16 +382,14 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>Provider</Label>
|
<Label>Provider</Label>
|
||||||
<Select
|
<Select
|
||||||
value={provider}
|
value={providerKey}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const nextProvider = value as Provider;
|
const nextOption =
|
||||||
setProvider(nextProvider);
|
providerOptions.find((option) => option.key === value) ??
|
||||||
resetModelOptions(nextProvider);
|
defaultProviderOption;
|
||||||
setName(
|
setProviderKey(nextOption.key);
|
||||||
providerOptions
|
resetModelOptions(nextOption.value);
|
||||||
.find((option) => option.value === nextProvider)
|
setName(nextOption.label.replace(' API key', ''));
|
||||||
?.label.replace(' API key', '') ?? 'AI provider',
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -331,7 +397,7 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{providerOptions.map((option) => (
|
{providerOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.key} value={option.key}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -342,6 +408,8 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
<Label>
|
<Label>
|
||||||
{selectedProvider.authType === 'opencode_auth_json'
|
{selectedProvider.authType === 'opencode_auth_json'
|
||||||
? 'Codex auth JSON'
|
? 'Codex auth JSON'
|
||||||
|
: selectedProvider.authType === 'anthropic_oauth_json'
|
||||||
|
? 'Anthropic credentials JSON'
|
||||||
: 'API key'}
|
: 'API key'}
|
||||||
</Label>
|
</Label>
|
||||||
{selectedProvider.authType === 'opencode_auth_json' ? (
|
{selectedProvider.authType === 'opencode_auth_json' ? (
|
||||||
@@ -361,6 +429,23 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
be treated like a password.
|
be treated like a password.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
) : selectedProvider.authType === 'anthropic_oauth_json' ? (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
value={secret}
|
||||||
|
placeholder='Paste the full .credentials.json contents.'
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Copy your Claude credentials file from{' '}
|
||||||
|
<code className='bg-muted rounded px-1 py-0.5'>
|
||||||
|
~/.claude/.credentials.json
|
||||||
|
</code>
|
||||||
|
. Spoon writes it into the isolated job workspace as
|
||||||
|
Claude's auth cache. It is stored encrypted and should
|
||||||
|
be treated like a password.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type='password'
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
|
{connection && connection.status !== 'active' ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
connection.status === 'revoked'
|
||||||
|
? 'rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-600'
|
||||||
|
: 'rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{connection.status === 'revoked'
|
||||||
|
? 'GitHub App installation removed'
|
||||||
|
: 'GitHub access needs re-authorization'}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1'>
|
||||||
|
{connection.status === 'revoked'
|
||||||
|
? 'Reinstall the app to resume syncing.'
|
||||||
|
: 'Reconnect the app to resume syncing.'}
|
||||||
|
</p>
|
||||||
|
{installUrl ? (
|
||||||
|
<a
|
||||||
|
className='mt-2 inline-block underline'
|
||||||
|
href={installUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Reconnect GitHub App
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{connection ? (
|
{connection ? (
|
||||||
<div className='grid gap-2 text-sm'>
|
<div className='grid gap-2 text-sm'>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
const techStack = [
|
|
||||||
{
|
|
||||||
category: 'Frontend',
|
|
||||||
technologies: [
|
|
||||||
{ name: 'Next.js 16', description: 'React framework with App Router' },
|
|
||||||
{ name: 'Expo 54', description: 'React Native framework' },
|
|
||||||
{ name: 'React 19', description: 'Latest React with Server Components' },
|
|
||||||
{
|
|
||||||
name: 'Tailwind CSS v4',
|
|
||||||
description: 'Utility-first CSS framework',
|
|
||||||
},
|
|
||||||
{ name: 'shadcn/ui', description: 'Beautiful component library' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Backend',
|
|
||||||
technologies: [
|
|
||||||
{ name: 'Convex', description: 'Self-hosted reactive backend' },
|
|
||||||
{
|
|
||||||
name: '@convex-dev/auth',
|
|
||||||
description: 'Multi-provider authentication',
|
|
||||||
},
|
|
||||||
{ name: 'UseSend', description: 'Self-hosted email service' },
|
|
||||||
{ name: 'File Storage', description: 'Built-in file uploads' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Developer Tools',
|
|
||||||
technologies: [
|
|
||||||
{ name: 'Turborepo', description: 'High-performance build system' },
|
|
||||||
{ name: 'TypeScript', description: 'Type-safe development' },
|
|
||||||
{ name: 'Bun', description: 'Fast package manager and runtime' },
|
|
||||||
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
|
|
||||||
{ name: 'Docker', description: 'Containerized deployment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TechStack = () => (
|
|
||||||
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
|
||||||
<div className='container mx-auto px-4 py-24'>
|
|
||||||
<div className='mx-auto max-w-6xl'>
|
|
||||||
<div className='mb-16 text-center'>
|
|
||||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
|
|
||||||
Modern Tech Stack
|
|
||||||
</h2>
|
|
||||||
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
|
||||||
Built with the latest and greatest tools for maximum productivity
|
|
||||||
and performance.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-12 md:grid-cols-3'>
|
|
||||||
{techStack.map((stack) => (
|
|
||||||
<div key={stack.category}>
|
|
||||||
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
|
|
||||||
<ul className='space-y-4'>
|
|
||||||
{stack.technologies.map((tech) => (
|
|
||||||
<li key={tech.name}>
|
|
||||||
<div className='text-foreground font-medium'>
|
|
||||||
{tech.name}
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground text-sm'>
|
|
||||||
{tech.description}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
@@ -29,6 +29,10 @@ export const AvatarDropdown = () => {
|
|||||||
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
||||||
);
|
);
|
||||||
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
||||||
|
// Empty/whitespace names must fall through to the email, so intentionally
|
||||||
|
// treat a blank string as "no name" (nullish coalescing would keep it).
|
||||||
|
const trimmedName = user?.name?.trim();
|
||||||
|
const displayName = trimmedName?.length ? trimmedName : user?.email?.trim();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -61,16 +65,16 @@ export const AvatarDropdown = () => {
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align='end'>
|
<DropdownMenuContent align='end'>
|
||||||
{(user?.name ?? user?.email) && (
|
{displayName && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className='text-center font-bold'>
|
<DropdownMenuLabel className='text-center font-bold'>
|
||||||
{user.name?.trim() ?? user.email?.trim()}
|
{displayName}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href='/profile' className='w-full cursor-pointer'>
|
<Link href='/settings/profile' className='w-full cursor-pointer'>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
|
|||||||
import { ThemeToggle } from '@spoon/ui';
|
import { ThemeToggle } from '@spoon/ui';
|
||||||
|
|
||||||
import { AvatarDropdown } from './AvatarDropdown';
|
import { AvatarDropdown } from './AvatarDropdown';
|
||||||
|
import { NotificationBell } from './notification-bell';
|
||||||
|
|
||||||
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
|||||||
...themeToggleProps?.buttonProps,
|
...themeToggleProps?.buttonProps,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NotificationBell />
|
||||||
<AvatarDropdown />
|
<AvatarDropdown />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['year', 365 * 24 * 60 * 60 * 1000],
|
||||||
|
['month', 30 * 24 * 60 * 60 * 1000],
|
||||||
|
['day', 24 * 60 * 60 * 1000],
|
||||||
|
['hour', 60 * 60 * 1000],
|
||||||
|
['minute', 60 * 1000],
|
||||||
|
];
|
||||||
|
|
||||||
|
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
|
||||||
|
numeric: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRelativeTime = (timestamp: number): string => {
|
||||||
|
const delta = timestamp - Date.now();
|
||||||
|
const magnitude = Math.abs(delta);
|
||||||
|
for (const [unit, ms] of RELATIVE_UNITS) {
|
||||||
|
if (magnitude >= ms) {
|
||||||
|
return relativeFormatter.format(Math.round(delta / ms), unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'just now';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationBell = () => {
|
||||||
|
const { isAuthenticated } = useConvexAuth();
|
||||||
|
const unreadCount = useQuery(
|
||||||
|
api.notifications.unreadCount,
|
||||||
|
isAuthenticated ? {} : 'skip',
|
||||||
|
);
|
||||||
|
const notifications = useQuery(
|
||||||
|
api.notifications.listMine,
|
||||||
|
isAuthenticated ? { limit: 10 } : 'skip',
|
||||||
|
);
|
||||||
|
const markRead = useMutation(api.notifications.markRead);
|
||||||
|
const markAllRead = useMutation(api.notifications.markAllRead);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = unreadCount ?? 0;
|
||||||
|
const badgeLabel = count > 9 ? '9+' : String(count);
|
||||||
|
const items = notifications ?? [];
|
||||||
|
|
||||||
|
// Fire-and-forget: never block navigation on the read write, but surface a
|
||||||
|
// toast if it fails so we don't silently assume success.
|
||||||
|
const handleMarkRead = async (notificationId: Id<'notifications'>) => {
|
||||||
|
try {
|
||||||
|
await markRead({ notificationId });
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark the notification as read.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllRead({});
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark notifications as read.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='relative'
|
||||||
|
aria-label='Notifications'
|
||||||
|
>
|
||||||
|
<Bell className='h-5 w-5' />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className='bg-primary text-primary-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold'>
|
||||||
|
{badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end' className='w-80'>
|
||||||
|
<div className='flex items-center justify-between gap-2 px-2 py-1.5'>
|
||||||
|
<DropdownMenuLabel className='p-0'>Notifications</DropdownMenuLabel>
|
||||||
|
{count > 0 && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => void handleMarkAllRead()}
|
||||||
|
className='text-muted-foreground hover:text-foreground cursor-pointer text-xs'
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground px-2 py-6 text-center text-sm'>
|
||||||
|
No notifications yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map((notification) => (
|
||||||
|
<DropdownMenuItem key={notification._id} asChild>
|
||||||
|
<Link
|
||||||
|
href={notification.link ?? '#'}
|
||||||
|
onClick={() => void handleMarkRead(notification._id)}
|
||||||
|
className='flex w-full cursor-pointer flex-col items-start gap-0.5'
|
||||||
|
>
|
||||||
|
<span className='flex w-full items-center gap-2'>
|
||||||
|
<span className='flex-1 truncate text-sm font-medium'>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
{notification.readAt === undefined && (
|
||||||
|
<span
|
||||||
|
className='bg-primary size-2 shrink-0 rounded-full'
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground line-clamp-2 text-xs'>
|
||||||
|
{notification.body}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground text-[11px]'>
|
||||||
|
{formatRelativeTime(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -37,6 +38,11 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
|||||||
icon: MessagesSquare,
|
icon: MessagesSquare,
|
||||||
label: 'Threads',
|
label: 'Threads',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/machine',
|
||||||
|
icon: Server,
|
||||||
|
label: 'Machine',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/settings/profile',
|
href: '/settings/profile',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Loader2, Play, RotateCw, Server, Square } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
export type BoxStatus = {
|
||||||
|
running: boolean;
|
||||||
|
image: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
memoryLimitBytes: number | null;
|
||||||
|
containerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LifecycleAction = 'start' | 'stop' | 'restart';
|
||||||
|
|
||||||
|
const formatUptime = (startedAt: string | null): string => {
|
||||||
|
if (!startedAt) return '—';
|
||||||
|
const started = new Date(startedAt).getTime();
|
||||||
|
if (Number.isNaN(started)) return '—';
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
|
||||||
|
const days = Math.floor(seconds / 86_400);
|
||||||
|
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||||
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
if (minutes > 0) return `${minutes}m`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMemory = (bytes: number | null): string => {
|
||||||
|
if (bytes === null) return 'Unlimited';
|
||||||
|
const gib = bytes / 1024 ** 3;
|
||||||
|
return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Detail = ({ label, value }: { label: string; value: string }) => (
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className='truncate font-mono text-sm'>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stopping or restarting the box tears down the SAME per-user container that a
|
||||||
|
// running agent thread job executes in, so both actions are destructive and get
|
||||||
|
// a confirmation step. Start is safe and stays a plain button.
|
||||||
|
const ConfirmLifecycleButton = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) => (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='outline' size='sm' disabled={busy}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant='destructive' onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BoxStatusCard = ({
|
||||||
|
status,
|
||||||
|
pendingAction,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
status: BoxStatus | null | undefined;
|
||||||
|
pendingAction: LifecycleAction | null;
|
||||||
|
onAction: (action: LifecycleAction) => void;
|
||||||
|
}) => {
|
||||||
|
const loading = status === undefined;
|
||||||
|
const running = Boolean(status?.running);
|
||||||
|
const busy = pendingAction !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between gap-4 space-y-0'>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-base'>
|
||||||
|
<Server className='size-4' />
|
||||||
|
My Machine
|
||||||
|
</CardTitle>
|
||||||
|
{loading ? (
|
||||||
|
<Badge variant='outline' className='gap-1'>
|
||||||
|
<Loader2 className='size-3 animate-spin' />
|
||||||
|
Checking…
|
||||||
|
</Badge>
|
||||||
|
) : running ? (
|
||||||
|
<Badge className='gap-1'>Running</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant='secondary'>Stopped</Badge>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||||
|
<Detail label='Image' value={status?.image ?? '—'} />
|
||||||
|
<Detail
|
||||||
|
label='Uptime'
|
||||||
|
value={running ? formatUptime(status?.startedAt ?? null) : '—'}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
label='Memory'
|
||||||
|
value={formatMemory(status?.memoryLimitBytes ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{running ? (
|
||||||
|
<>
|
||||||
|
<ConfirmLifecycleButton
|
||||||
|
label='Stop'
|
||||||
|
busy={busy}
|
||||||
|
icon={
|
||||||
|
pendingAction === 'stop' ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Square className='size-4' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title='Stop this machine?'
|
||||||
|
description='Stopping the machine shuts down the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
|
||||||
|
confirmLabel='Stop machine'
|
||||||
|
onConfirm={() => onAction('stop')}
|
||||||
|
/>
|
||||||
|
<ConfirmLifecycleButton
|
||||||
|
label='Restart'
|
||||||
|
busy={busy}
|
||||||
|
icon={
|
||||||
|
pendingAction === 'restart' ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<RotateCw className='size-4' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title='Restart this machine?'
|
||||||
|
description='Restarting the machine recreates the container it runs in. Any agent thread job currently running in this machine will be interrupted and lose its in-progress work.'
|
||||||
|
confirmLabel='Restart machine'
|
||||||
|
onConfirm={() => onAction('restart')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={busy || loading}
|
||||||
|
onClick={() => onAction('start')}
|
||||||
|
>
|
||||||
|
{pendingAction === 'start' ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Play className='size-4' />
|
||||||
|
)}
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FileResponse,
|
||||||
|
FileTreeNode,
|
||||||
|
} from '@/components/agent-workspace/types';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { CodeEditor } from '@/components/agent-workspace/code-editor';
|
||||||
|
import { FileTabs } from '@/components/agent-workspace/file-tabs';
|
||||||
|
import { FileTree } from '@/components/agent-workspace/file-tree';
|
||||||
|
import { XtermSession } from '@/components/agent-workspace/xterm-session';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { FolderTree, SquareTerminal } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||||
|
|
||||||
|
import type { BoxStatus, LifecycleAction } from './box-status-card';
|
||||||
|
import { BoxStatusCard } from './box-status-card';
|
||||||
|
|
||||||
|
const STATUS_POLL_MS = 10_000;
|
||||||
|
|
||||||
|
type OpenFileState = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
savedContent: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MachineShell = () => {
|
||||||
|
const environment = useQuery(api.userEnvironment.getMine, {});
|
||||||
|
const username = environment?.username ?? '';
|
||||||
|
const firstName = environment?.firstName;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<BoxStatus | null | undefined>(undefined);
|
||||||
|
const [pendingAction, setPendingAction] = useState<LifecycleAction | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||||
|
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||||
|
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
||||||
|
const [activeFilePath, setActiveFilePath] = useState<string>();
|
||||||
|
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [vimEnabled, setVimEnabled] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'files' | 'terminal'>('files');
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/box/status');
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const data = (await response.json()) as { status: BoxStatus };
|
||||||
|
setStatus(data.status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus((current) => current ?? null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTree = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/box/tree');
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||||
|
setTree(data.tree);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshStatus();
|
||||||
|
void loadTree();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void refreshStatus();
|
||||||
|
}, STATUS_POLL_MS);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [refreshStatus, loadTree]);
|
||||||
|
|
||||||
|
const runLifecycle = useCallback(
|
||||||
|
(action: LifecycleAction) => {
|
||||||
|
if (pendingAction) return;
|
||||||
|
setPendingAction(action);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/box/lifecycle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const data = (await response.json()) as { status: BoxStatus };
|
||||||
|
setStatus(data.status);
|
||||||
|
toast.success(
|
||||||
|
action === 'stop'
|
||||||
|
? 'Machine stopped.'
|
||||||
|
: action === 'restart'
|
||||||
|
? 'Machine restarted.'
|
||||||
|
: 'Machine started.',
|
||||||
|
);
|
||||||
|
void loadTree();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(`Could not ${action} your machine.`);
|
||||||
|
void refreshStatus();
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[pendingAction, loadTree, refreshStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFile = useCallback(async (path: string) => {
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: current[path] ?? {
|
||||||
|
path,
|
||||||
|
content: '',
|
||||||
|
savedContent: '',
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/box/file?path=${encodeURIComponent(path)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const data = (await response.json()) as FileResponse;
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[data.path]: {
|
||||||
|
path: data.path,
|
||||||
|
content: data.content,
|
||||||
|
savedContent: data.content,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setOpenFilePaths((current) =>
|
||||||
|
current.includes(path) ? current : [...current, path],
|
||||||
|
);
|
||||||
|
setActiveFilePath(path);
|
||||||
|
if (!files[path]) {
|
||||||
|
void loadFile(path).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setFiles((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[path];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setOpenFilePaths((current) =>
|
||||||
|
current.filter((filePath) => filePath !== path),
|
||||||
|
);
|
||||||
|
toast.error('Could not load file.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[files, loadFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeFileContent = async (path: string, content: string) => {
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
...(current[path] ?? { path, savedContent: '', loading: false }),
|
||||||
|
content,
|
||||||
|
saving: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const response = await fetch('/api/box/file', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ path, content }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
...(current[path] ?? {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
savedContent: '',
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
toast.error('Could not save file.');
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
...(current[path] ?? { path, loading: false }),
|
||||||
|
content,
|
||||||
|
savedContent: content,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
toast.success('File saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFile = async (content: string) => {
|
||||||
|
if (!activeFilePath) return;
|
||||||
|
await writeFileContent(activeFilePath, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFile = (path: string) => {
|
||||||
|
const index = openFilePaths.indexOf(path);
|
||||||
|
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
|
||||||
|
setOpenFilePaths(nextOpen);
|
||||||
|
setFiles((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[path];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (activeFilePath === path) {
|
||||||
|
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string) => {
|
||||||
|
setExpandedDirectoryPaths((current) =>
|
||||||
|
current.includes(path)
|
||||||
|
? current.filter((directoryPath) => directoryPath !== path)
|
||||||
|
: [...current, path],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||||
|
const running = Boolean(status?.running);
|
||||||
|
const starting = pendingAction === 'start' || pendingAction === 'restart';
|
||||||
|
// The terminal only shows this while the box is not running. Distinguish a box
|
||||||
|
// that is starting/restarting from one that is simply stopped so the label is
|
||||||
|
// accurate instead of always claiming "Starting…".
|
||||||
|
const terminalWaitingLabel = starting
|
||||||
|
? 'Starting your machine…'
|
||||||
|
: 'Machine is stopped — start it to open a terminal.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>
|
||||||
|
{firstName ? `${firstName}'s Machine` : 'My Machine'}
|
||||||
|
</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Your always-available dev box — a persistent home, a terminal, and a
|
||||||
|
file browser rooted at <code className='font-mono'>~</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BoxStatusCard
|
||||||
|
status={status}
|
||||||
|
pendingAction={pendingAction}
|
||||||
|
onAction={runLifecycle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
||||||
|
className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[600px] flex-col overflow-hidden rounded-md border'
|
||||||
|
>
|
||||||
|
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
|
||||||
|
<TabsTrigger
|
||||||
|
value='files'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<FolderTree className='size-4' />
|
||||||
|
Files
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='terminal'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<SquareTerminal className='size-4' />
|
||||||
|
Terminal
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value='files'
|
||||||
|
className='m-0 grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]'
|
||||||
|
>
|
||||||
|
<aside className='border-border bg-background min-h-0 border-r'>
|
||||||
|
<div className='border-border border-b p-3'>
|
||||||
|
<h2 className='text-sm font-semibold'>Home</h2>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Files in your persistent home
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FileTree
|
||||||
|
tree={tree}
|
||||||
|
selectedPath={activeFilePath}
|
||||||
|
expandedPaths={expandedDirectoryPaths}
|
||||||
|
onSelect={openFile}
|
||||||
|
onToggleDirectory={toggleDirectory}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
|
||||||
|
<FileTabs
|
||||||
|
tabs={openFilePaths.map((path) => ({
|
||||||
|
path,
|
||||||
|
dirty: files[path]
|
||||||
|
? files[path].content !== files[path].savedContent
|
||||||
|
: false,
|
||||||
|
}))}
|
||||||
|
activePath={activeFilePath}
|
||||||
|
onActivate={setActiveFilePath}
|
||||||
|
onClose={closeFile}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
path={activeFilePath}
|
||||||
|
content={activeFile?.content ?? ''}
|
||||||
|
savedContent={activeFile?.savedContent ?? ''}
|
||||||
|
readOnly={false}
|
||||||
|
vimEnabled={vimEnabled}
|
||||||
|
onSave={saveFile}
|
||||||
|
onVimEnabledChange={setVimEnabled}
|
||||||
|
onChange={(content) => {
|
||||||
|
if (!activeFilePath) return;
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[activeFilePath]: {
|
||||||
|
...(current[activeFilePath] ?? {
|
||||||
|
path: activeFilePath,
|
||||||
|
savedContent: '',
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
}),
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value='terminal'
|
||||||
|
forceMount
|
||||||
|
className='m-0 min-h-0 flex-1 overflow-hidden data-[state=inactive]:hidden'
|
||||||
|
>
|
||||||
|
<XtermSession
|
||||||
|
active={running}
|
||||||
|
visible={activeTab === 'terminal'}
|
||||||
|
tokenUrl='/api/box/terminal-token'
|
||||||
|
sessionKey={username}
|
||||||
|
waitingLabel={terminalWaitingLabel}
|
||||||
|
label='your machine'
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -160,6 +160,7 @@ export const DotfilesManager = () => {
|
|||||||
|
|
||||||
const saveSelected = async (next: string) => {
|
const saveSelected = async (next: string) => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
try {
|
||||||
await putFile({
|
await putFile({
|
||||||
path: selected.path,
|
path: selected.path,
|
||||||
content: next,
|
content: next,
|
||||||
@@ -167,6 +168,9 @@ export const DotfilesManager = () => {
|
|||||||
});
|
});
|
||||||
setSavedContent(next);
|
setSavedContent(next);
|
||||||
toast.success('Saved.');
|
toast.success('Saved.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Save failed.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importAll = async (incoming: UploadFile[]) => {
|
const importAll = async (incoming: UploadFile[]) => {
|
||||||
@@ -231,11 +235,15 @@ export const DotfilesManager = () => {
|
|||||||
|
|
||||||
const deleteSelected = async () => {
|
const deleteSelected = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
try {
|
||||||
await removeFile({ fileId: selected._id as never });
|
await removeFile({ fileId: selected._id as never });
|
||||||
setSelected(undefined);
|
setSelected(undefined);
|
||||||
setContent('');
|
setContent('');
|
||||||
setSavedContent('');
|
setSavedContent('');
|
||||||
toast.success('Deleted.');
|
toast.success('Deleted.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Delete failed.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -345,8 +353,12 @@ export const DotfilesManager = () => {
|
|||||||
<RepoPanel
|
<RepoPanel
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
|
try {
|
||||||
await updateEnv(values);
|
await updateEnv(values);
|
||||||
toast.success('Saved.');
|
toast.success('Saved.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Save failed.');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Card, Label, Switch } from '@spoon/ui';
|
||||||
|
|
||||||
|
type PreferenceField =
|
||||||
|
| 'emailEnabled'
|
||||||
|
| 'emailMaintenance'
|
||||||
|
| 'emailAgentTurnFinished'
|
||||||
|
| 'emailAgentNeedsInput'
|
||||||
|
| 'emailSyncFailed'
|
||||||
|
| 'emailConnectionReauth';
|
||||||
|
|
||||||
|
const perKindToggles: {
|
||||||
|
field: Exclude<PreferenceField, 'emailEnabled'>;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
field: 'emailMaintenance',
|
||||||
|
label: 'Maintenance threads',
|
||||||
|
description: 'A maintenance review thread opens for one of your Spoons.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailAgentTurnFinished',
|
||||||
|
label: 'Agent finished a turn',
|
||||||
|
description: 'An agent finishes a turn and changes are ready to review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailAgentNeedsInput',
|
||||||
|
label: 'Agent needs your input',
|
||||||
|
description: 'An agent pauses and needs your input to continue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailSyncFailed',
|
||||||
|
label: 'A sync fails',
|
||||||
|
description: 'A scheduled sync with upstream fails for one of your Spoons.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailConnectionReauth',
|
||||||
|
label: 'A connection needs re-authentication',
|
||||||
|
description: 'A git connection is revoked and needs to be reconnected.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NotificationPreferencesPanel = () => {
|
||||||
|
// Render directly from the reactive query: after a save resolves, the query
|
||||||
|
// re-emits the stored value, so there is no local copy to fall out of sync.
|
||||||
|
const prefs = useQuery(api.notifications.getPreferences);
|
||||||
|
const updatePreferences = useMutation(api.notifications.updatePreferences);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const onToggle = async (field: PreferenceField, next: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updatePreferences({ [field]: next });
|
||||||
|
toast.success('Preferences saved.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not save preferences.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefs === undefined) {
|
||||||
|
return (
|
||||||
|
<Card className='p-4 shadow-none'>
|
||||||
|
<p className='text-muted-foreground text-sm'>Loading preferences…</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perKindDisabled = saving || !prefs.emailEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='divide-border divide-y p-0 shadow-none'>
|
||||||
|
<div className='flex items-center justify-between gap-4 p-4'>
|
||||||
|
<div className='space-y-0.5'>
|
||||||
|
<Label className='text-sm font-medium'>Email notifications</Label>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Send email for your notifications. Turn this off to silence every
|
||||||
|
kind below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label='Email notifications'
|
||||||
|
checked={prefs.emailEnabled}
|
||||||
|
disabled={saving}
|
||||||
|
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{perKindToggles.map(({ field, label, description }) => (
|
||||||
|
<div
|
||||||
|
key={field}
|
||||||
|
className='flex items-center justify-between gap-4 p-4'
|
||||||
|
>
|
||||||
|
<div className='space-y-0.5'>
|
||||||
|
<Label className='text-sm font-medium'>{label}</Label>
|
||||||
|
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label={label}
|
||||||
|
checked={prefs.emailEnabled && prefs[field]}
|
||||||
|
disabled={perKindDisabled}
|
||||||
|
onCheckedChange={(next) => void onToggle(field, next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,18 +31,17 @@ type WorkerHealth = {
|
|||||||
convexUrl: string;
|
convexUrl: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
containerRuntime: string;
|
containerRuntime: string;
|
||||||
containerAccess: string;
|
|
||||||
jobImage: string;
|
jobImage: string;
|
||||||
workdir: string;
|
workdir: string;
|
||||||
network?: string;
|
network?: string;
|
||||||
httpPort: number;
|
httpPort: number;
|
||||||
activeWorkspaceCount: number;
|
activeWorkspaceCount: number;
|
||||||
workspaceContainers: string[];
|
boxContainers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CleanupResult = {
|
type CleanupResult = {
|
||||||
removedContainers: string[];
|
|
||||||
removedWorkdirs: string[];
|
removedWorkdirs: string[];
|
||||||
|
boxContainers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkerHealthPanel = () => {
|
export const WorkerHealthPanel = () => {
|
||||||
@@ -103,9 +102,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const result = (await response.json()) as CleanupResult;
|
const result = (await response.json()) as CleanupResult;
|
||||||
toast.success(
|
toast.success(`Cleaned ${result.removedWorkdirs.length} workdirs.`);
|
||||||
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
|
|
||||||
);
|
|
||||||
await refreshHealth();
|
await refreshHealth();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -165,9 +162,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
{health.ok ? 'healthy' : 'unhealthy'}
|
{health.ok ? 'healthy' : 'unhealthy'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant='outline'>{health.workerId}</Badge>
|
<Badge variant='outline'>{health.workerId}</Badge>
|
||||||
<Badge variant='outline'>
|
<Badge variant='outline'>{health.containerRuntime}</Badge>
|
||||||
{health.containerRuntime} / {health.containerAccess}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||||
<div>
|
<div>
|
||||||
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>Box containers</p>
|
||||||
Workspace containers
|
|
||||||
</p>
|
|
||||||
<p className='mt-1 font-mono text-sm'>
|
<p className='mt-1 font-mono text-sm'>
|
||||||
{health.workspaceContainers.length
|
{health.boxContainers.length
|
||||||
? health.workspaceContainers.join(', ')
|
? health.boxContainers.join(', ')
|
||||||
: 'none'}
|
: 'none'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
Remove inactive Spoon job containers and inactive directories
|
Remove inactive directories under the configured worker workdir.
|
||||||
under the configured worker workdir.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -24,9 +24,17 @@ import {
|
|||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||||
|
type RuntimeName = 'codex' | 'opencode' | 'claude';
|
||||||
|
|
||||||
|
const runtimeLabels: Record<RuntimeName, string> = {
|
||||||
|
codex: 'Codex',
|
||||||
|
opencode: 'OpenCode',
|
||||||
|
claude: 'Claude',
|
||||||
|
};
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
runtime?: 'opencode' | 'openai_direct';
|
runtime?: RuntimeName | 'openai_direct';
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
installCommand?: string;
|
installCommand?: string;
|
||||||
@@ -91,6 +99,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||||
settings?.aiProviderProfileId ?? '__default',
|
settings?.aiProviderProfileId ?? '__default',
|
||||||
);
|
);
|
||||||
|
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
||||||
const selectedProfile = profiles.find(
|
const selectedProfile = profiles.find(
|
||||||
(profile) =>
|
(profile) =>
|
||||||
profile._id ===
|
profile._id ===
|
||||||
@@ -118,6 +127,59 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
: settings.reasoningEffort,
|
: settings.reasoningEffort,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The parent gates rendering on a different query than `settings`, so this
|
||||||
|
// form can mount before the agent settings record resolves. Re-seed every
|
||||||
|
// field once the real record arrives (and again if the parent swaps to a
|
||||||
|
// different spoon) so a Save cannot persist the initial defaults over the
|
||||||
|
// user's saved configuration.
|
||||||
|
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || hydratedSpoonId.current === spoon._id) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setEnabled(settings.enabled);
|
||||||
|
setDefaultBaseBranch(
|
||||||
|
settings.defaultBaseBranch ??
|
||||||
|
spoon.forkDefaultBranch ??
|
||||||
|
spoon.upstreamDefaultBranch,
|
||||||
|
);
|
||||||
|
setBranchPrefix(settings.branchPrefix);
|
||||||
|
setInstallCommand(settings.installCommand ?? '');
|
||||||
|
setCheckCommand(settings.checkCommand ?? '');
|
||||||
|
setTestCommand(settings.testCommand ?? '');
|
||||||
|
setEnvFilePath(settings.envFilePath ?? '.env.local');
|
||||||
|
setCustomEnvFilePath(settings.customEnvFilePath ?? '');
|
||||||
|
setMaterializeEnvFileByDefault(
|
||||||
|
settings.materializeEnvFileByDefault ?? false,
|
||||||
|
);
|
||||||
|
setAutoDetectCommands(settings.autoDetectCommands ?? true);
|
||||||
|
setAllowUserFileEditing(settings.allowUserFileEditing ?? true);
|
||||||
|
setAiProviderProfileId(settings.aiProviderProfileId ?? '__default');
|
||||||
|
setAgentModel(settings.aiProviderProfileId ? settings.agentModel : '');
|
||||||
|
setReasoningEffort(
|
||||||
|
!settings.aiProviderProfileId
|
||||||
|
? 'medium'
|
||||||
|
: settings.reasoningEffort === 'none'
|
||||||
|
? 'minimal'
|
||||||
|
: settings.reasoningEffort,
|
||||||
|
);
|
||||||
|
hydratedSpoonId.current = spoon._id;
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [settings, spoon]);
|
||||||
|
|
||||||
|
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
|
||||||
|
[]) as RuntimeName[];
|
||||||
|
const settingsRuntime = settings?.runtime as RuntimeName | undefined;
|
||||||
|
const effectiveRuntime =
|
||||||
|
runtime && supportedRuntimes.includes(runtime)
|
||||||
|
? runtime
|
||||||
|
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
|
||||||
|
? settingsRuntime
|
||||||
|
: supportedRuntimes[0];
|
||||||
|
const runtimeValid = Boolean(
|
||||||
|
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
|
||||||
|
);
|
||||||
|
|
||||||
const selectableModels = selectedModelProfile?.configured
|
const selectableModels = selectedModelProfile?.configured
|
||||||
? selectedModelProfile.models
|
? selectedModelProfile.models
|
||||||
: [];
|
: [];
|
||||||
@@ -135,7 +197,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
await update({
|
await update({
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
enabled,
|
enabled,
|
||||||
runtime: 'opencode',
|
runtime: effectiveRuntime,
|
||||||
defaultBaseBranch,
|
defaultBaseBranch,
|
||||||
branchPrefix,
|
branchPrefix,
|
||||||
installCommand: installCommand || undefined,
|
installCommand: installCommand || undefined,
|
||||||
@@ -186,7 +248,25 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>Runtime</Label>
|
<Label>Runtime</Label>
|
||||||
<Input value='OpenCode workspace' disabled />
|
<Select
|
||||||
|
value={effectiveRuntime ?? ''}
|
||||||
|
onValueChange={(value) => setRuntime(value as RuntimeName)}
|
||||||
|
disabled={!supportedRuntimes.length}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select a provider profile first' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{supportedRuntimes.map((name) => (
|
||||||
|
<SelectItem key={name} value={name}>
|
||||||
|
{runtimeLabels[name]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Runtimes available for the selected provider profile.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>AI provider profile</Label>
|
<Label>AI provider profile</Label>
|
||||||
@@ -400,6 +480,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={
|
disabled={
|
||||||
!selectedProfile?.configured ||
|
!selectedProfile?.configured ||
|
||||||
|
!runtimeValid ||
|
||||||
!selectableModels.some((model) => model.id === selectedAgentModel)
|
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -223,8 +223,16 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
|||||||
remoteName={remote.remoteName}
|
remoteName={remote.remoteName}
|
||||||
url={remote.url}
|
url={remote.url}
|
||||||
onRemove={async () => {
|
onRemove={async () => {
|
||||||
|
try {
|
||||||
await removeRemote({ remoteId: remote._id });
|
await removeRemote({ remoteId: remote._id });
|
||||||
toast.success('Remote removed.');
|
toast.success('Remote removed.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Could not remove remote.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -130,24 +130,35 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBulkSaving(true);
|
setBulkSaving(true);
|
||||||
try {
|
let imported = 0;
|
||||||
|
const failed: string[] = [];
|
||||||
for (const secret of parsedBulk.secrets) {
|
for (const secret of parsedBulk.secrets) {
|
||||||
|
try {
|
||||||
await createSecret({
|
await createSecret({
|
||||||
spoonId,
|
spoonId,
|
||||||
name: secret.name,
|
name: secret.name,
|
||||||
value: secret.value,
|
value: secret.value,
|
||||||
description: 'Imported from .env',
|
description: 'Imported from .env',
|
||||||
});
|
});
|
||||||
|
imported += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
failed.push(secret.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkSaving(false);
|
||||||
|
|
||||||
|
if (imported > 0) {
|
||||||
|
toast.success(`${imported} secret${imported === 1 ? '' : 's'} imported.`);
|
||||||
|
}
|
||||||
|
if (failed.length) {
|
||||||
|
toast.error(
|
||||||
|
`Could not import ${failed.length} secret${failed.length === 1 ? '' : 's'}: ${failed.join(', ')}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setBulkText('');
|
setBulkText('');
|
||||||
setBulkDialogOpen(false);
|
setBulkDialogOpen(false);
|
||||||
toast.success(`${parsedBulk.secrets.length} secrets imported.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not import secrets.');
|
|
||||||
} finally {
|
|
||||||
setBulkSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const readEnvFile = async (file?: File) => {
|
const readEnvFile = async (file?: File) => {
|
||||||
@@ -310,8 +321,16 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
|
|||||||
size='icon'
|
size='icon'
|
||||||
aria-label={`Remove ${secret.name}`}
|
aria-label={`Remove ${secret.name}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
try {
|
||||||
await removeSecret({ secretId: secret._id });
|
await removeSecret({ secretId: secret._id });
|
||||||
toast.success('Secret removed.');
|
toast.success('Secret removed.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Could not remove secret.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className='size-4' />
|
<Trash2 className='size-4' />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const labels: Record<string, string> = {
|
|||||||
checking: 'Checking',
|
checking: 'Checking',
|
||||||
conflict: 'Conflict',
|
conflict: 'Conflict',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
rate_limited: 'Rate limited',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
@@ -35,10 +36,16 @@ const variants: Record<
|
|||||||
active: 'default',
|
active: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Warning-tone statuses that need an amber accent the base variants don't
|
||||||
|
// provide. Matches the app's existing amber warning styling.
|
||||||
|
const classNames: Record<string, string> = {
|
||||||
|
rate_limited: 'border-amber-500/40 bg-amber-500/10 text-amber-700',
|
||||||
|
};
|
||||||
|
|
||||||
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
|
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
|
||||||
const value = status ?? 'unknown';
|
const value = status ?? 'unknown';
|
||||||
return (
|
return (
|
||||||
<Badge variant={variants[value] ?? 'outline'}>
|
<Badge variant={variants[value] ?? 'outline'} className={classNames[value]}>
|
||||||
{labels[value] ?? value.replaceAll('_', ' ')}
|
{labels[value] ?? value.replaceAll('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { MessageSquarePlus } from 'lucide-react';
|
import { MessageSquarePlus } from 'lucide-react';
|
||||||
@@ -25,9 +25,17 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
type RuntimeName = 'codex' | 'opencode' | 'claude';
|
||||||
|
|
||||||
|
const runtimeLabels: Record<RuntimeName, string> = {
|
||||||
|
codex: 'Codex',
|
||||||
|
opencode: 'OpenCode',
|
||||||
|
claude: 'Claude',
|
||||||
|
};
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
runtime?: 'opencode' | 'openai_direct';
|
runtime?: RuntimeName | 'openai_direct';
|
||||||
agentModel: string;
|
agentModel: string;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
envFilePath?: string;
|
envFilePath?: string;
|
||||||
@@ -72,7 +80,35 @@ export const ThreadWorkspaceForm = ({
|
|||||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||||
agentSettings?.aiProviderProfileId ?? '__settings',
|
agentSettings?.aiProviderProfileId ?? '__settings',
|
||||||
);
|
);
|
||||||
|
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// The parent gates rendering on a different query than `agentSettings`, so
|
||||||
|
// this form can mount before the agent settings record resolves. Re-seed the
|
||||||
|
// settings-derived fields once the real record arrives (and again if the
|
||||||
|
// parent swaps to a different spoon) so the form reflects saved config rather
|
||||||
|
// than the initial defaults.
|
||||||
|
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentSettings || hydratedSpoonId.current === spoon._id) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setBaseBranch(
|
||||||
|
agentSettings.defaultBaseBranch ??
|
||||||
|
spoon.forkDefaultBranch ??
|
||||||
|
spoon.upstreamDefaultBranch,
|
||||||
|
);
|
||||||
|
setMaterializeEnvFile(agentSettings.materializeEnvFileByDefault ?? false);
|
||||||
|
setEnvFilePath(
|
||||||
|
agentSettings.envFilePath === 'custom'
|
||||||
|
? (agentSettings.customEnvFilePath ?? '.env.local')
|
||||||
|
: (agentSettings.envFilePath ?? '.env.local'),
|
||||||
|
);
|
||||||
|
setAiProviderProfileId(agentSettings.aiProviderProfileId ?? '__settings');
|
||||||
|
hydratedSpoonId.current = spoon._id;
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [agentSettings, spoon]);
|
||||||
|
|
||||||
const effectiveProviderProfileId =
|
const effectiveProviderProfileId =
|
||||||
aiProviderProfileId === '__settings'
|
aiProviderProfileId === '__settings'
|
||||||
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||||
@@ -87,6 +123,18 @@ export const ThreadWorkspaceForm = ({
|
|||||||
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||||
: profile._id === aiProviderProfileId,
|
: profile._id === aiProviderProfileId,
|
||||||
);
|
);
|
||||||
|
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
|
||||||
|
[]) as RuntimeName[];
|
||||||
|
const settingsRuntime = agentSettings?.runtime as RuntimeName | undefined;
|
||||||
|
const effectiveRuntime =
|
||||||
|
runtime && supportedRuntimes.includes(runtime)
|
||||||
|
? runtime
|
||||||
|
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
|
||||||
|
? settingsRuntime
|
||||||
|
: supportedRuntimes[0];
|
||||||
|
const runtimeValid = Boolean(
|
||||||
|
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -97,6 +145,7 @@ export const ThreadWorkspaceForm = ({
|
|||||||
prompt,
|
prompt,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
requestedBranchName: requestedBranchName || undefined,
|
requestedBranchName: requestedBranchName || undefined,
|
||||||
|
runtime: effectiveRuntime,
|
||||||
materializeEnvFile,
|
materializeEnvFile,
|
||||||
envFilePath: materializeEnvFile ? envFilePath : undefined,
|
envFilePath: materializeEnvFile ? envFilePath : undefined,
|
||||||
aiProviderProfileId:
|
aiProviderProfileId:
|
||||||
@@ -140,7 +189,22 @@ export const ThreadWorkspaceForm = ({
|
|||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>Workspace runtime</Label>
|
<Label>Workspace runtime</Label>
|
||||||
<Input value='OpenCode workspace' disabled />
|
<Select
|
||||||
|
value={effectiveRuntime ?? ''}
|
||||||
|
onValueChange={(value) => setRuntime(value as RuntimeName)}
|
||||||
|
disabled={!supportedRuntimes.length}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select a provider first' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{supportedRuntimes.map((name) => (
|
||||||
|
<SelectItem key={name} value={name}>
|
||||||
|
{runtimeLabels[name]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>AI provider</Label>
|
<Label>AI provider</Label>
|
||||||
@@ -219,7 +283,10 @@ export const ThreadWorkspaceForm = ({
|
|||||||
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
|
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button type='submit' disabled={submitting || !hasProvider}>
|
<Button
|
||||||
|
type='submit'
|
||||||
|
disabled={submitting || !hasProvider || !runtimeValid}
|
||||||
|
>
|
||||||
{submitting ? 'Creating...' : 'Create thread'}
|
{submitting ? 'Creating...' : 'Create thread'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { cn, Skeleton } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const ListSkeleton = ({
|
||||||
|
rows = 3,
|
||||||
|
rowClassName = 'h-20 w-full',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
rows?: number;
|
||||||
|
rowClassName?: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
role='status'
|
||||||
|
aria-label='Loading'
|
||||||
|
data-testid='list-skeleton'
|
||||||
|
className={cn('space-y-3', className)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className={rowClassName} />
|
||||||
|
))}
|
||||||
|
<span className='sr-only'>Loading</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -9,6 +9,8 @@ import { fetchQuery } from 'convex/nextjs';
|
|||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { buildBoxToken } from './box-terminal-token';
|
||||||
|
|
||||||
const terminalSecret = () =>
|
const terminalSecret = () =>
|
||||||
env.SPOON_AGENT_TERMINAL_SECRET ??
|
env.SPOON_AGENT_TERMINAL_SECRET ??
|
||||||
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
||||||
@@ -29,6 +31,18 @@ export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
|
|||||||
return { url, expiresAt };
|
return { url, expiresAt };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// User-scoped variant of mintTerminalToken for a user's persistent box. The
|
||||||
|
// username is supplied by resolveBoxUsername (derived from the authed Convex
|
||||||
|
// user), never from the request. Returns null when the terminal feature is not
|
||||||
|
// configured.
|
||||||
|
export const mintBoxTerminalToken = (username: string) =>
|
||||||
|
buildBoxToken(
|
||||||
|
username,
|
||||||
|
terminalSecret(),
|
||||||
|
env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
type RouteContext = {
|
type RouteContext = {
|
||||||
params: Promise<{ jobId: string }> | { jobId: string };
|
params: Promise<{ jobId: string }> | { jobId: string };
|
||||||
};
|
};
|
||||||
@@ -144,3 +158,74 @@ export const withOwnedJob = async (
|
|||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolves the authed caller's own box username from Convex. This is the
|
||||||
|
// security boundary: the username comes from the authed user's environment,
|
||||||
|
// never from the request, so a caller can only ever reach their own box.
|
||||||
|
export const resolveBoxUsername = async (): Promise<
|
||||||
|
{ ok: true; username: string } | { ok: false; response: NextResponse }
|
||||||
|
> => {
|
||||||
|
const token = await convexAuthNextjsToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mine = await fetchQuery(api.userEnvironment.getMine, {}, { token });
|
||||||
|
return { ok: true as const, username: mine.username };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors proxyWorker but targets `/box/${action}` and force-sets the derived
|
||||||
|
// username as `user` (overwriting any client-supplied value) so the worker only
|
||||||
|
// ever operates on the caller's own box.
|
||||||
|
export const proxyBox = async (
|
||||||
|
username: string,
|
||||||
|
action: 'status' | 'lifecycle' | 'tree' | 'file',
|
||||||
|
init?: RequestInit,
|
||||||
|
search?: URLSearchParams,
|
||||||
|
) => {
|
||||||
|
const token = workerToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = new URL(`/box/${action}`, env.SPOON_AGENT_WORKER_URL);
|
||||||
|
if (search) {
|
||||||
|
for (const [key, value] of search) url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
url.searchParams.set('user', username);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'content-type':
|
||||||
|
response.headers.get('content-type') ?? 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience wrapper mirroring withOwnedJob: resolve the caller's own box
|
||||||
|
// username, then run the handler; 401 when unauthenticated, 500 on throw.
|
||||||
|
export const withBox = async (
|
||||||
|
handler: (username: string) => Promise<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveBoxUsername();
|
||||||
|
if (!resolved.ok) return resolved.response;
|
||||||
|
return await handler(resolved.username);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
// Pure token builder for the box terminal connection, extracted so it can be
|
||||||
|
// unit-tested without pulling in `server-only`/Convex. Mirrors the worker's
|
||||||
|
// `verifyBoxTerminalToken` format so the worker accepts the minted token:
|
||||||
|
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
|
||||||
|
// Returns null when the terminal feature is not configured (secret/WS base).
|
||||||
|
export const buildBoxToken = (
|
||||||
|
username: string,
|
||||||
|
secret: string | undefined,
|
||||||
|
wsBase: string | undefined,
|
||||||
|
now: number,
|
||||||
|
): { url: string; expiresAt: number } | null => {
|
||||||
|
if (!secret || !wsBase) return null;
|
||||||
|
const expiresAt = now + 2 * 60 * 1000;
|
||||||
|
const payload = `${expiresAt}.box.${username}`;
|
||||||
|
const signature = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
const token = `${payload}.${signature}`;
|
||||||
|
const url = `${wsBase.replace(/\/$/, '')}/box/terminal?token=${encodeURIComponent(token)}`;
|
||||||
|
return { url, expiresAt };
|
||||||
|
};
|
||||||
@@ -8,12 +8,9 @@ const isAuthRoute = createRouteMatcher(['/sign-in', '/forgot-password']);
|
|||||||
const isProtectedRoute = createRouteMatcher([
|
const isProtectedRoute = createRouteMatcher([
|
||||||
'/dashboard(.*)',
|
'/dashboard(.*)',
|
||||||
'/spoons(.*)',
|
'/spoons(.*)',
|
||||||
'/updates(.*)',
|
|
||||||
'/agents(.*)',
|
|
||||||
'/threads(.*)',
|
'/threads(.*)',
|
||||||
'/github(.*)',
|
'/github(.*)',
|
||||||
'/settings(.*)',
|
'/settings(.*)',
|
||||||
'/profile(.*)',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default convexAuthNextjsMiddleware(
|
export default convexAuthNextjsMiddleware(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user